diff --git a/.agents/skills/component-view-test/SKILL.md b/.agents/skills/component-view-test/SKILL.md index 91a7fcf8788..7821d88a60c 100644 --- a/.agents/skills/component-view-test/SKILL.md +++ b/.agents/skills/component-view-test/SKILL.md @@ -56,6 +56,8 @@ tests/component-view/ ├── mocks.ts ← Engine + native mocks (import this first, always) ├── render.tsx ← renderComponentViewScreen, renderScreenWithRoutes ├── stateFixture.ts ← StateFixtureBuilder (createStateFixture) +├── platform.ts ← describeForPlatforms, itForPlatforms (run per iOS/Android) +├── api-mocking/ ← HTTP API mocks (nock) — extensible, one file per feature ├── presets/ ← initialState() builders — one file per feature area └── renderers/ ← renderView() functions — one file per feature area ``` diff --git a/.agents/skills/component-view-test/references/navigation-mocking.md b/.agents/skills/component-view-test/references/navigation-mocking.md index 78bb923d829..f52312f67dc 100644 --- a/.agents/skills/component-view-test/references/navigation-mocking.md +++ b/.agents/skills/component-view-test/references/navigation-mocking.md @@ -134,102 +134,62 @@ Route names live in `app/constants/navigation/Routes.ts`. ## External Service / API Mocking -Some views call external services **directly** (not through Engine controllers) — e.g. a `getTrendingTokens()` function imported from a package, or a `fetch()` call to an external API. These cannot be driven through Redux state overrides. +Some views call **external HTTP APIs** (e.g. `fetch()` to a REST endpoint). Those requests cannot be driven through Redux state. The framework provides an **api-mocking** layer using [nock](https://github.com/nock/nock) so tests intercept HTTP at the network level **without** using `jest.mock` on service modules (which would violate the “only Engine and allowed native mocks” rule). -### Current pattern — jest.mock on the service module +### Preferred pattern — nock (api-mocking folder) -When a view calls an external service function directly, mock the module in a dedicated file under `tests/component-view/mocks/` and expose setup/clear helpers: +All HTTP API mocks for component view tests live under `tests/component-view/api-mocking/`. Each feature has one file (e.g. `trending.ts`) that exports: -```typescript -// tests/component-view/mocks/myFeatureApiMocks.ts -import { getMyFeatureData } from '@metamask/some-package'; - -export const getMyFeatureDataMock = getMyFeatureData as jest.Mock; +- Mock response data (e.g. `mockTrendingTokensData`) +- A **setup** function (e.g. `setupTrendingApiFetchMock(responseData?, customReply?)`) that uses nock to intercept the endpoint +- A **clear** function (e.g. `clearTrendingApiMocks()`) to call in `afterEach` -export const mockFeatureData = [ - { id: 'item-1', name: 'Token A', price: '100.00', change24h: 5.2 }, - { id: 'item-2', name: 'Token B', price: '200.00', change24h: -1.8 }, -]; +Shared nock lifecycle helpers (`clearAllNockMocks`, `disableNetConnect`, `teardownNock`) are in `api-mocking/nockHelpers.ts`. To **add a new API mock** for another view, add a file `api-mocking/.ts` following the pattern in `api-mocking/trending.ts` (mock data, `setupXxxApiMock`, `clearXxxApiMocks` using `nockHelpers`), and call setup/clear in the view test’s `beforeEach`/`afterEach`. -export const setupMyFeatureApiMock = (data = mockFeatureData) => { - getMyFeatureDataMock.mockImplementation(async () => data); -}; - -export const clearMyFeatureApiMocks = () => { - jest.clearAllMocks(); -}; -``` - -In the test file, declare the `jest.mock` at module scope and use `beforeEach`/`afterEach` for lifecycle: +**Example (trending):** ```typescript -// NOTE: antipattern — only Engine and native modules should be mocked in view tests. -// This is a temporary workaround for service functions called directly from components, -// not through Engine. Track removal in the linked issue. -// eslint-disable-next-line no-restricted-syntax -jest.mock('@metamask/some-package', () => { - const actual = jest.requireActual('@metamask/some-package'); - return { ...actual, getMyFeatureData: jest.fn().mockResolvedValue([]) }; -}); - import { - setupMyFeatureApiMock, - clearMyFeatureApiMocks, - mockFeatureData, - getMyFeatureDataMock, -} from '../../../../tests/component-view/mocks/myFeatureApiMocks'; - -describe('MyFeatureView', () => { - beforeEach(() => { - setupMyFeatureApiMock(mockFeatureData); - }); - - afterEach(() => { - clearMyFeatureApiMocks(); - }); - - it('shows token list after data loads from the external service', async () => { - const { findByText } = renderMyFeatureWithRoutes(); + setupTrendingApiFetchMock, + clearTrendingApiMocks, + mockTrendingTokensData, + mockBnbChainToken, +} from '../../../../tests/component-view/api-mocking/trending'; + +beforeEach(() => { + setupTrendingApiFetchMock(mockTrendingTokensData); +}); +afterEach(() => { + clearTrendingApiMocks(); +}); - expect(await findByText('Token A')).toBeOnTheScreen(); +it('user sees trending tokens section with mocked data', async () => { + const { findByText, queryByTestId } = renderTrendingViewWithRoutes(); + await waitFor(async () => { + expect(await findByText('Ethereum')).toBeOnTheScreen(); }); + // assert rows with assertTrendingTokenRowsVisibility(...) +}); - it('shows only filtered results when a specific param is passed', async () => { - getMyFeatureDataMock.mockImplementation(async (params) => { - if (params?.chainId === 'eip155:56') return [mockBnbData]; - return mockFeatureData; - }); - - const { findByText } = renderMyFeatureWithRoutes(); - // ... interact to trigger the filter, then assert +it('displays only BNB tokens when BNB Chain network filter is selected', async () => { + setupTrendingApiFetchMock(mockTrendingTokensData, (uri) => { + const url = new URL(uri, 'https://token.api.cx.metamask.io'); + const chainIdsParam = url.searchParams.get('chainIds') ?? ''; + const chainIds = chainIdsParam.split(',').map((s) => s.trim()); + if (chainIds.length === 1 && chainIds[0] === 'eip155:56') { + return mockBnbChainToken; + } + return mockTrendingTokensData; }); + const { getByTestId, findByText, queryByTestId } = + renderTrendingViewWithRoutes(); + // ... navigate to full view, open network filter, select BNB Chain + // assert visible: [BNB], missing: [ETH, BTC, UNI] }); ``` -> ⚠️ **This is a known antipattern.** The golden rule is that only Engine and allowed native modules should be mocked in `*.view.test.*` files. Mocking a service module directly bypasses the ESLint guard (note the `eslint-disable` comment). Always link to a tracking issue and plan to migrate to a proper solution. - -### Future pattern — Mock Service Worker (MSW) - -> 📌 **Placeholder — no example exists yet in this codebase.** +### Fallback — jest.mock on the service module (antipattern) -For views that call HTTP endpoints directly (via `fetch`), the intended approach is [Mock Service Worker (msw)](https://mswjs.io/), which intercepts requests at the network level without needing `jest.mock`. This keeps tests closer to real behavior and avoids the module-mock antipattern. +When a view calls an external **function** (not `fetch`) from a package and that function cannot be replaced by nock (e.g. no HTTP), you may mock the module in a file under `tests/component-view/mocks/` and use setup/clear helpers. This requires an `eslint-disable` and is a **known antipattern**; prefer moving the integration to an HTTP API and using api-mocking, or drive data through Engine/Redux when possible. -When the first MSW-based view test is written, document the setup here: - -```typescript -// TODO: Add MSW setup example once the first test using it is merged. -// Expected shape: -// -// import { setupServer } from 'msw/node'; -// import { http, HttpResponse } from 'msw'; -// -// const server = setupServer( -// http.get('https://api.example.com/tokens', () => -// HttpResponse.json(mockTokensData), -// ), -// ); -// -// beforeAll(() => server.listen()); -// afterEach(() => server.resetHandlers()); -// afterAll(() => server.close()); -``` +> ⚠️ Only Engine and allowed native modules should be mocked in `*.view.test.*` files. Mocking a service module directly bypasses the ESLint guard. Always link to a tracking issue and plan to migrate to nock (api-mocking) or Engine/Redux. diff --git a/.agents/skills/component-view-test/references/reference.md b/.agents/skills/component-view-test/references/reference.md index c2cf8b6de1e..0c69d15158e 100644 --- a/.agents/skills/component-view-test/references/reference.md +++ b/.agents/skills/component-view-test/references/reference.md @@ -203,6 +203,7 @@ yarn eslint | Engine + native mocks | `tests/component-view/mocks.ts` | | render, renderScreenWithRoutes | `tests/component-view/render.tsx` | | StateFixtureBuilder | `tests/component-view/stateFixture.ts` | +| HTTP API mocks (nock) | `tests/component-view/api-mocking/` (per-feature) | | Feature renderers (per view) | `tests/component-view/renderers/` (e.g. bridge, wallet) | | Feature presets (per view) | `tests/component-view/presets/` (e.g. bridge, wallet) | | DeepPartial type | `app/util/test/renderWithProvider` | diff --git a/.agents/skills/component-view-test/references/writing-tests.md b/.agents/skills/component-view-test/references/writing-tests.md index 2e4d983eef8..c003dfac022 100644 --- a/.agents/skills/component-view-test/references/writing-tests.md +++ b/.agents/skills/component-view-test/references/writing-tests.md @@ -12,6 +12,7 @@ Before writing any test, read: - Any existing `*.view.test.tsx` for the same component - The relevant preset(s) in `tests/component-view/presets/` - The relevant renderer(s) in `tests/component-view/renderers/` +- If the view calls an external HTTP API: `tests/component-view/api-mocking/` and any existing `api-mocking/.ts` for that API (see navigation-mocking.md, External Service / API Mocking) --- @@ -194,6 +195,38 @@ const defaultBridgeWithTokens = (overrides?: Record) => { Then each test only specifies its delta from this baseline. +### describe / it and platform (iOS + Android) + +Import from `tests/component-view/platform`. All helpers accept an optional **filter** (3rd arg): `'ios'` | `'android'` | `['ios','android']` | `{ only: 'ios' }` | `{ skip: ['android'] }`. Env: `TEST_OS=ios` or `TEST_OS=android` to run only one OS. + +| Helper | Use | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `describeForPlatforms(name, define, filter?)` | One describe per OS. Inside, `define({ os })`; use `it()` or `itForPlatforms()` — each runs once per that OS. | +| `itForPlatforms(name, (ctx) => {}, filter?)` | One `it` per OS. Callback receives `{ os }`. | +| `itOnlyForPlatforms(name, fn, filter?)` | Same as `itForPlatforms` but registers `it.only`. | +| `itEach(table)(name, (row) => {}, filter?)` | One `it` per table row × per OS. Use `$key` in name to interpolate row fields. | +| `describeEach(table)(name, (row) => { it('...', () => {}); }, filter?)` | One describe per row × per OS. Use `$key` in name. | +| `getTargetPlatforms(filter?)` | Returns `['ios','android']` (or filtered list) for custom loops. | + +Example — `itEach` (each case runs on iOS and Android): + +```typescript +import { itEach } from '../../../../../../tests/component-view/platform'; + +const cases = [ + { name: 'renders empty', amount: '0' }, + { name: 'displays fiat', amount: '1' }, +]; +itEach(cases)('$name', ({ amount }) => { + const { findByDisplayValue } = renderDefault({ + bridge: { sourceAmount: amount }, + }); + expect(findByDisplayValue(amount)).toBeOnTheScreen(); +}); +``` + +Jest modifiers (`it.only`, `it.skip`, `describe.only`, `describe.skip`) work as usual inside these blocks. + ### Minimal template ```typescript diff --git a/.agents/skills/e2e-test/SKILL.md b/.agents/skills/e2e-test/SKILL.md index bdbeb07681e..a64fcf9b5f3 100644 --- a/.agents/skills/e2e-test/SKILL.md +++ b/.agents/skills/e2e-test/SKILL.md @@ -3,7 +3,8 @@ name: e2e-test description: Add and fix Detox E2E tests (smoke and regression) for MetaMask Mobile using withFixtures, Page Objects, and tests/framework. Use when creating a new spec, - fixing a failing E2E test, or adding page objects and selectors. + fixing a failing E2E test, adding page objects and selectors, or adding + MetaMetrics analytics expectations (analyticsExpectations). --- # E2E Test Builder — Skill @@ -44,6 +45,10 @@ Task → What do you need? │ → Open references/mocking.md (testSpecificMock, setupRemoteFeatureFlagsMock, setupMockRequest) │ → When writing the spec: open references/writing-tests.md │ +├─ MetaMetrics / Segment analytics assertions (`analyticsExpectations` on `withFixtures`) +│ → Open [tests/docs/analytics-e2e.md](../../../tests/docs/analytics-e2e.md) (config shape, teardown order, presets under `tests/helpers/analytics/expectations/`, `runAnalyticsExpectations`) +│ → When wiring a spec: still follow references/writing-tests.md for `withFixtures` usage +│ └─ Run tests, debug failures, or self-review → Open references/running-tests.md (build check, detox commands, common failures, retry patterns) ``` @@ -89,4 +94,5 @@ Documentation is split by **action**. Open only the reference that matches what | **Writing or updating a spec** | [references/writing-tests.md](references/writing-tests.md) | New spec file, spec structure, FixtureBuilder patterns, smoke/regression templates. | | **Page Objects and selectors** | [references/page-objects.md](references/page-objects.md) | Create or update POM classes, selector/testId conventions, Matchers/Gestures/Assertions API. | | **API and feature flag mocking** | [references/mocking.md](references/mocking.md) | testSpecificMock, setupRemoteFeatureFlagsMock, setupMockRequest, shared mock files. | +| **MetaMetrics / analytics expectations** | [tests/docs/analytics-e2e.md](../../../tests/docs/analytics-e2e.md) | `analyticsExpectations` on `withFixtures`, declarative checks, presets in `tests/helpers/analytics/expectations/`. | | **Running tests, debugging, fixing failures** | [references/running-tests.md](references/running-tests.md) | Build check, detox run commands, lint/tsc, common failures table, retry patterns, iteration loop. | diff --git a/.cursor/rules/deeplink-handler-guidelines.mdc b/.cursor/rules/deeplink-handler-guidelines.mdc index b04f0dfaf05..8b8acefec55 100644 --- a/.cursor/rules/deeplink-handler-guidelines.mdc +++ b/.cursor/rules/deeplink-handler-guidelines.mdc @@ -12,6 +12,8 @@ This guide walks you through adding new deeplink handlers to MetaMask Mobile. Fo ## Quick Reference +The **`deposit` deeplink** (`metamask://deposit`, `/deposit`) is **deprecated** and not handled; do not add handlers under Ramp Deposit `deeplink/` for it. + | File | Purpose | |------|---------| | `app/constants/deeplinks.ts` | Define action constants | diff --git a/.depcheckrc.yml b/.depcheckrc.yml index c89a37c15a9..6b4106aa80b 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -47,6 +47,13 @@ ignores: - '@metamask/perps-controller' # Used in scripts/repack for CI optimization - '@expo/repack-app' + + # ESLint plugins, resolvers, parsers, configuration, etc. + - 'eslint-config-prettier' + - 'eslint-import-resolver-typescript' + - 'eslint-plugin-prettier' + - 'eslint-plugin-react-native' + # Note: Everything below this line should be removed after investigation # TODO: Investigate each dependency to see whether it's used @@ -63,9 +70,6 @@ ignores: - 'babel-core' - 'babel-loader' - 'chromedriver' - - 'eslint-config-prettier' - - 'eslint-plugin-prettier' - - 'eslint-plugin-react-native' - 'execa' - 'jetifier' - 'metro-react-native-babel-preset' diff --git a/.eslintrc.js b/.eslintrc.js index 0a0b0be65b3..1cdc300210f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,4 @@ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ module.exports = { root: true, parser: '@typescript-eslint/parser', @@ -9,7 +9,7 @@ module.exports = { '@react-native', 'eslint:recommended', // '@metamask/eslint-config', // TODO: Enable when ready - 'plugin:import/warnings', + 'plugin:import-x/warnings', 'plugin:react/recommended', ], // ESLint can find the plugin without the `eslint-plugin-` prefix. Ex. `eslint-plugin-react-compiler` -> `react-compiler` @@ -51,6 +51,65 @@ module.exports = { allow: ['Text'], }, ], + + // These rule modifications are removing changes to our shared ESLint config made after + // version v9. This is a temporary measure to get us to ESLint v9 compatible versions, + // at which point we can restore the intended rules and use error suppression instead. + // + // TODO: Remove these modifications after the ESLint v9 update + '@typescript-eslint/await-thenable': 'off', + '@typescript-eslint/consistent-type-imports': 'off', + '@typescript-eslint/consistent-type-exports': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/naming-convention': 'off', + '@typescript-eslint/no-base-to-string': 'off', + '@typescript-eslint/no-duplicate-type-constituents': 'off', + '@typescript-eslint/no-empty-object-type': 'off', + '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/no-implied-eval': 'off', + '@typescript-eslint/no-misused-promises': 'off', + '@typescript-eslint/no-redundant-type-constituents': 'off', + '@typescript-eslint/no-throw-literal': 'off', + '@typescript-eslint/no-unnecessary-type-assertion': 'off', + '@typescript-eslint/no-unnecessary-type-arguments': 'off', + '@typescript-eslint/no-unsafe-enum-comparison': 'off', + '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-wrapper-object-types': 'off', + '@typescript-eslint/only-throw-error': 'off', + '@typescript-eslint/prefer-enum-initializers': 'off', + '@typescript-eslint/prefer-includes': 'off', + '@typescript-eslint/prefer-nullish-coalescing': 'off', + '@typescript-eslint/prefer-optional-chain': 'off', + '@typescript-eslint/prefer-promise-reject-errors': 'off', + '@typescript-eslint/prefer-readonly': 'off', + '@typescript-eslint/prefer-reduce-type-parameter': 'off', + '@typescript-eslint/prefer-string-starts-ends-with': 'off', + '@typescript-eslint/promise-function-async': 'off', + '@typescript-eslint/restrict-plus-operands': 'off', + '@typescript-eslint/restrict-template-expressions': 'off', + '@typescript-eslint/switch-exhaustiveness-check': 'off', + '@typescript-eslint/unbound-method': 'off', + 'no-restricted-syntax': [ + 'error', + { + selector: 'WithStatement', + message: 'With statements are not allowed', + }, + { + selector: 'SequenceExpression', + message: 'Sequence expressions are not allowed', + }, + // { + // selector: "BinaryExpression[operator='in']", + // message: 'The "in" operator is not allowed', + // }, + // { + // selector: + // "PropertyDefinition[accessibility='private'], MethodDefinition[accessibility='private'], TSParameterProperty[accessibility='private']", + // message: 'Use a hash name instead.', + // }, + ], }, }, { @@ -86,8 +145,8 @@ module.exports = { }, rules: { 'no-console': 'off', - 'import/no-commonjs': 'off', - 'import/no-nodejs-modules': 'off', + 'import-x/no-commonjs': 'off', + 'import-x/no-nodejs-modules': 'off', }, }, { @@ -98,8 +157,8 @@ module.exports = { ], rules: { 'no-console': 'off', - 'import/no-commonjs': 'off', - 'import/no-nodejs-modules': 'off', + 'import-x/no-commonjs': 'off', + 'import-x/no-nodejs-modules': 'off', }, }, { @@ -361,10 +420,13 @@ module.exports = { '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/restrict-template-expressions': 'error', - // === Import rules (using 'import' plugin, not 'import-x') === - 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], - 'import/no-named-as-default': 'error', - 'import/order': [ + // === Import rules === + 'import-x/consistent-type-specifier-style': [ + 'error', + 'prefer-top-level', + ], + 'import-x/no-named-as-default': 'error', + 'import-x/order': [ 'error', { groups: [ @@ -441,10 +503,10 @@ module.exports = { }, settings: { - 'import/resolver': { + 'import-x/resolver': { typescript: {}, // this loads /tsconfig.json to eslint }, - 'import/internal-regex': '^@metamask/perps-controller', + 'import-x/internal-regex': '^@metamask/perps-controller', }, rules: { @@ -466,7 +528,7 @@ module.exports = { 'no-bitwise': 'off', 'class-methods-use-this': 'off', 'eol-last': 'warn', - 'import/no-named-as-default': 'off', + 'import-x/no-named-as-default': 'off', 'no-invalid-this': 'off', 'no-new': 'off', 'react/jsx-handler-names': 'off', @@ -477,14 +539,14 @@ module.exports = { 'arrow-body-style': 'error', 'dot-notation': 'error', eqeqeq: 'error', - 'import/no-amd': 'error', - 'import/no-commonjs': 'error', - 'import/no-duplicates': 'error', - 'import/no-extraneous-dependencies': ['error', { packageDir: ['./'] }], - 'import/no-mutable-exports': 'error', - 'import/no-namespace': 'error', - 'import/no-nodejs-modules': 'error', - 'import/prefer-default-export': 'off', + 'import-x/no-amd': 'error', + 'import-x/no-commonjs': 'error', + 'import-x/no-duplicates': 'error', + 'import-x/no-extraneous-dependencies': ['error', { packageDir: ['./'] }], + 'import-x/no-mutable-exports': 'error', + 'import-x/no-namespace': 'error', + 'import-x/no-nodejs-modules': 'error', + 'import-x/prefer-default-export': 'off', 'no-alert': 'error', 'no-constant-condition': [ 'error', @@ -532,7 +594,7 @@ module.exports = { 'prefer-const': 'error', 'prefer-rest-params': 'error', 'prefer-spread': 'error', - 'import/no-unresolved': 'error', + 'import-x/no-unresolved': 'error', 'eslint-comments/no-unlimited-disable': 'off', 'eslint-comments/no-unused-disable': 'off', 'react-native/no-color-literals': 'error', @@ -559,6 +621,14 @@ module.exports = { 'react/prefer-es6-class': 'error', '@metamask/design-tokens/color-no-hex': 'warn', radix: 'off', + + // These rule modifications are removing changes to our shared ESLint config made after + // version v9. This is a temporary measure to get us to ESLint v9 compatible versions, + // at which point we can restore the intended rules and use error suppression instead. + // + // TODO: Remove these modifications after the ESLint v9 update + 'react-hooks/rules-of-hooks': 'off', + 'no-loss-of-precision': 'off', }, ignorePatterns: ['wdio.conf.js', 'app/util/termsOfUse/termsOfUseContent.ts'], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 944ef6887ad..38f093a99ac 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -47,6 +47,7 @@ scripts/build.sh @MetaMask/mobile-pla fingerprint.config.js @MetaMask/mobile-platform builds.yml @MetaMask/mobile-platform .github/workflows/push-eas-update.yml @MetaMask/mobile-admins +.github/workflows/upload-to-testflight.yml @MetaMask/mobile-admins scripts/update-expo-channel.js @MetaMask/mobile-admins certs/certificate.pem @MetaMask/mobile-admins ios/fastlane/ @MetaMask/mobile-admins @@ -121,8 +122,10 @@ app/components/Views/AccountSelector @MetaMask/accounts-e **/multichainAccounts/** @MetaMask/accounts-engineers # Swaps Team -app/components/UI/Swaps @MetaMask/swaps-engineers -app/components/UI/Bridge @MetaMask/swaps-engineers +app/components/UI/Swaps @MetaMask/swaps-engineers +app/components/UI/Bridge @MetaMask/swaps-engineers +app/core/Engine/controllers/bridge-controller @MetaMask/swaps-engineers +app/core/Engine/controllers/bridge-status-controller @MetaMask/swaps-engineers # Notifications Team app/components/Views/Notifications @MetaMask/notifications @@ -165,6 +168,8 @@ app/components/UI/Perps/ @MetaMask/perps app/components/UI/WalletAction/*perps* @MetaMask/perps app/core/Engine/controllers/perps-controller @MetaMask/perps app/core/Engine/messengers/perps-controller-messenger @MetaMask/perps +app/core/Engine/controllers/compliance/ @MetaMask/perps +app/core/Engine/messengers/compliance/ @MetaMask/perps app/core/DeeplinkManager/Handlers/handlePerpsUrl.ts @MetaMask/perps app/core/AgenticService/ @MetaMask/perps **/Perps/** @MetaMask/perps diff --git a/.github/actions/smart-e2e-selection/action.yml b/.github/actions/smart-e2e-selection/action.yml index 16803ae5be5..ac4243b68dd 100644 --- a/.github/actions/smart-e2e-selection/action.yml +++ b/.github/actions/smart-e2e-selection/action.yml @@ -30,9 +30,13 @@ inputs: required: false default: 'false' base-ref: - description: 'Base branch ref (must be main for analysis to run)' + description: 'Base branch ref (must be main or release/* for analysis to run)' required: false default: '' + is-draft: + description: 'Whether the PR is a draft' + required: false + default: 'false' outputs: ai_e2e_test_tags: @@ -41,6 +45,9 @@ outputs: ai_confidence: description: 'AI confidence score (0-100)' value: ${{ steps.final-outputs.outputs.ai_confidence }} + ai_risk_level: + description: 'Risk level of the PR (low, medium, high) — indicates testing need and bug introduction likelihood' + value: ${{ steps.final-outputs.outputs.ai_risk_level }} ai_performance_test_tags: description: 'Performance test tags to run (JSON array format, empty [] means no performance tests)' value: ${{ steps.final-outputs.outputs.ai_performance_test_tags }} @@ -77,14 +84,14 @@ runs: git sparse-checkout disable git checkout HEAD -- . - - name: Fetch main branch for comparison + - name: Fetch base branch for comparison if: steps.check-skip-label.outputs.SKIP != 'true' shell: bash run: | # Unshallow the repository first (if it's shallow) git fetch --unshallow 2>/dev/null || true - # Fetch main branch for diff comparison - git fetch origin main 2>/dev/null || true + # Fetch the base branch for diff comparison (main or release/*) + git fetch origin "${{ inputs.base-ref || 'main' }}" 2>/dev/null || true - name: Setup Node.js if: steps.check-skip-label.outputs.SKIP != 'true' @@ -128,6 +135,7 @@ runs: GITHUB_REPOSITORY: ${{ inputs.repository }} GITHUB_RUN_ID: ${{ github.run_id }} BASE_REF: ${{ inputs.base-ref }} + IS_DRAFT: ${{ inputs.is-draft }} run: | echo "ai_e2e_test_tags=[\"ALL\"]" >> "$GITHUB_OUTPUT" echo "ai_confidence=0" >> "$GITHUB_OUTPUT" @@ -139,14 +147,17 @@ runs: if [[ "$EVENT_NAME" != "pull_request" ]]; then SHOULD_SKIP=true SKIP_REASON="only runs on PRs" - elif [[ "$BASE_REF" != "main" ]]; then - SHOULD_SKIP=true - SKIP_REASON="base branch is not main (base: $BASE_REF)" elif [[ -n "${{ steps.check-skip-label.outputs.SKIP }}" ]] && [[ "${{ steps.check-skip-label.outputs.SKIP }}" == "true" ]]; then SHOULD_SKIP=true SKIP_REASON="skip-smart-e2e-selection label found" FORCE_RUN=true echo "ai_confidence=100" >> "$GITHUB_OUTPUT" + elif [[ "$IS_DRAFT" == "true" ]]; then + SHOULD_SKIP=true + SKIP_REASON="draft PR" + elif [[ "$BASE_REF" != "main" && "$BASE_REF" != release/* ]]; then + SHOULD_SKIP=true + SKIP_REASON="base branch is not main or a release branch (base: $BASE_REF)" fi # Export skip status, reason, and force run flag for downstream jobs @@ -188,6 +199,15 @@ runs: else echo "force_run=false" >> "$GITHUB_OUTPUT" fi + # Risk level: force_run → always high; otherwise use AI output + AI_RISK='${{ steps.ai-analysis.outputs.ai_risk_level }}' + if [[ "$FORCE_RUN" == "true" ]]; then + echo "ai_risk_level=high" >> "$GITHUB_OUTPUT" + elif [[ -n "$AI_RISK" ]]; then + echo "ai_risk_level=$AI_RISK" >> "$GITHUB_OUTPUT" + else + echo "ai_risk_level=" >> "$GITHUB_OUTPUT" + fi - name: Display AI Analysis Outputs if: always() @@ -197,6 +217,7 @@ runs: echo "================================" echo "ai_e2e_test_tags: ${{ steps.final-outputs.outputs.ai_e2e_test_tags }}" echo "ai_confidence: ${{ steps.final-outputs.outputs.ai_confidence }}" + echo "ai_risk_level: ${{ steps.final-outputs.outputs.ai_risk_level }}" echo "ai_performance_test_tags: ${{ steps.final-outputs.outputs.ai_performance_test_tags }}" echo "force_run: ${{ steps.final-outputs.outputs.force_run }}" echo "================================" @@ -231,6 +252,16 @@ runs: echo "📝 No Smart E2E selection comments found" fi + - name: Apply risk label to PR + if: inputs.pr-number != '' && inputs.github-token != '' && inputs.is-draft != 'true' && (inputs.base-ref == 'main' || startsWith(inputs.base-ref, 'release/')) + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + GITHUB_REPOSITORY: ${{ inputs.repository }} + PR_NUMBER: ${{ inputs.pr-number }} + RISK_LEVEL: ${{ steps.final-outputs.outputs.ai_risk_level }} + run: node .github/scripts/e2e-risk-label.mjs + - name: Create PR comment if: inputs.post-comment == 'true' && inputs.pr-number != '' && inputs.github-token != '' shell: bash diff --git a/.github/scripts/collect-qa-stats.mjs b/.github/scripts/collect-qa-stats.mjs index 58239909e11..1c87e076d04 100644 --- a/.github/scripts/collect-qa-stats.mjs +++ b/.github/scripts/collect-qa-stats.mjs @@ -1,28 +1,34 @@ #!/usr/bin/env node /** * - * Collects QA metrics from a CI run and writes qa-stats.json, key: value format. + * Collects QA metrics into a qa-stats.json file, key: value format. * Metrics that could not be collected (missing artifacts, tests did not run) - * are omitted from the output — they will never appear as zero. + * are omitted from the output, i.e., they will not appear in the output file. * * Required env vars: * GITHUB_TOKEN — GitHub Actions token for API access - * WORKFLOW_RUN_ID — ID of the main CI run that produced tests artifacts + * GITHUB_REPOSITORY — Repository in "owner/repo" format (set automatically in Actions) * * How to add a new metric: * 1. Add a collector function that returns a plain object * 2. Register it in the collectors array in main() * - * The only rule: never rename existing keys. The DB key is (project, run_id, namespace, metric_key). + * The only rule: never rename existing keys. The DB key used for storing the metrics is (project, run_id, namespace, metric_key). * Renaming a key in the JSON creates a new series in the DB while the old name stops getting new data, * which breaks the Grafana time series continuity. Adding and removing keys is fine. - * + * + * Artifact names used below are coupled to `name:` fields in ci.yml and run-e2e-workflow.yml — + * renaming either side silently drops that metric from the output. + * * Example output: * { - * "component_view": { "tests_count": 94 }, - * "unit": { "tests_count": 41957 }, - * "e2e": { "tests_count": 420, "main_tests_count": 276, "confirmations_tests_count": 62, "flask_tests_count": 144 }, - * "performance": { "tests_count": 21, "login_tests_count": 11, "onboarding_tests_count": 4, "mm_connect_tests_count": 6 } + * "unit": { "total_tests_run": 41957, "total_tests_skipped": 17, "bridge_tests_run": 5000, "other_tests_run": 1000 }, + * "component_view":{ "total_tests_run": 94, "total_tests_skipped": 0 }, + * "e2e": { "total_tests_run": 420, "total_tests_skipped": 27, + * "main_tests_run": 276, "main_android_tests_run": 276, "main_ios_tests_run": 276, + * "flask_tests_run": 144, "confirmations_tests_run": 62 }, + * "performance": { "total_tests_defined": 21, "total_tests_skipped": 1, + * "login_tests_defined": 11, "onboarding_tests_defined": 4, "mm_connect_tests_defined": 6 } * } */ @@ -31,18 +37,67 @@ import { execSync } from 'child_process'; import { join } from 'path'; const GITHUB_TOKEN = process.env.GITHUB_TOKEN; -const WORKFLOW_RUN_ID = process.env.WORKFLOW_RUN_ID; +const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY ?? 'MetaMask/metamask-mobile'; -if (!WORKFLOW_RUN_ID) throw new Error('Missing required WORKFLOW_RUN_ID env var'); if (!GITHUB_TOKEN) throw new Error('Missing required GITHUB_TOKEN env var'); +// --------------------------------------------------------------------------- +// Static-scan targets +// Update these (easy with AI) if the repository directory structure or file-naming conventions +// change — the collectors below rely on them for skip/defined counts. +// --------------------------------------------------------------------------- +const SCAN_APP_DIR = 'app'; +const SCAN_E2E_SMOKE_DIRS = ['tests/smoke']; +const SCAN_PERFORMANCE_DIR = 'tests/performance'; + +const PATTERN_CV_TEST_FILE = /\.view(?:\..+)?\.test\.[jt]sx?$/; +const PATTERN_UNIT_TEST_FILE = /\.test\.[jt]sx?$/; +const PATTERN_E2E_SPEC_FILE = /\.spec\.[jt]sx?$/; +const PATTERN_PERF_SPEC_FILE = /\.spec\.js$/; + + // --------------------------------------------------------------------------- // GitHub artifact helpers // --------------------------------------------------------------------------- +let _runId = null; let _artifactList = null; +/** + * Fetches the ID of the latest successful CI workflow run on `main`. + * + * @returns {Promise} + */ +async function getLatestCiRunId() { + const url = `https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?branch=main&status=success&per_page=1`; + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${GITHUB_TOKEN}`, + Accept: 'application/vnd.github+json', + }, + }); + + if (!res.ok) { + throw new Error(`Failed to fetch CI workflow runs: ${res.status} ${res.statusText}`); + } + + const data = await res.json(); + const run = data.workflow_runs?.[0]; + if (!run) { + throw new Error('No successful CI workflow runs found on main'); + } + + console.log(`[run] Using latest successful ci run #${run.run_number} (id=${run.id}, ${run.created_at})`); + return String(run.id); +} + +async function getRunId() { + if (_runId) return _runId; + _runId = await getLatestCiRunId(); + return _runId; +} + /** * Fetches (and caches) the list of artifact names for the triggering CI run. * First call fetches and stores, every subsequent call returns the cached value. @@ -52,11 +107,12 @@ let _artifactList = null; async function getArtifactList() { if (_artifactList) return _artifactList; + const runId = await getRunId(); const artifacts = []; let page = 1; while (true) { - const url = `https://api.github.com/repos/MetaMask/metamask-mobile/actions/runs/${WORKFLOW_RUN_ID}/artifacts?per_page=100&page=${page}`; + const url = `https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/runs/${runId}/artifacts?per_page=100&page=${page}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${GITHUB_TOKEN}`, @@ -89,10 +145,11 @@ async function getArtifactList() { async function downloadArtifact(artifactName) { const artifacts = await getArtifactList(); const artifact = artifacts.find((a) => a.name === artifactName); + const runId = await getRunId(); if (!artifact) { throw new Error( - `Artifact "${artifactName}" not found in run ${WORKFLOW_RUN_ID}`, + `Artifact "${artifactName}" not found in run ${runId}`, ); } @@ -131,6 +188,88 @@ async function downloadArtifact(artifactName) { // Collectors — one async function per metric source // --------------------------------------------------------------------------- +/** + * Counts the number of individual test cases that are skipped in a source string. + * + * Two categories: + * 1. Explicit: `it.skip()` / `test.skip()` outside any `describe.skip` block — 1 skipped test each. + * 2. Implicit: every `it()` / `test()` call (including `.skip` variants) inside a `describe.skip` + * block, because the whole block is skipped by the runner. + * + * `describe.skip` blocks are extracted via brace matching so their contents are + * not double-counted against the explicit-skip pass. + * + * @param {string} source + * @returns {number} + */ +function countSkips(source) { + // Find all describe.skip blocks using brace matching. + const describeBlocks = []; + const re = /\bdescribe\.skip\s*\(/g; + let m; + while ((m = re.exec(source)) !== null) { + const braceStart = source.indexOf('{', m.index + m[0].length); + if (braceStart === -1) continue; + let depth = 1, pos = braceStart + 1; + while (pos < source.length && depth > 0) { + if (source[pos] === '{') depth++; + else if (source[pos] === '}') depth--; + pos++; + } + describeBlocks.push({ start: m.index, end: pos, content: source.slice(braceStart + 1, pos - 1) }); + } + + // Strip describe.skip regions from source (reverse order to preserve indices). + let outside = source; + for (let i = describeBlocks.length - 1; i >= 0; i--) { + outside = outside.slice(0, describeBlocks[i].start) + outside.slice(describeBlocks[i].end); + } + + // Part 1: it.skip / test.skip outside describe.skip blocks. + const explicitSkips = (outside.match(/\b(?:it|test)\.skip\s*\(/g) ?? []).length; + + // Part 2: all it() / test() (including .skip) inside describe.skip blocks. + const implicitSkips = describeBlocks.reduce( + (sum, { content }) => sum + (content.match(/\b(?:it|test)(?:\.skip)?\s*\(/g) ?? []).length, + 0, + ); + + return explicitSkips + implicitSkips; +} + +/** + * Counts all individual test definitions in a source string — both active and + * skipped. Matches it(, it.skip(, test(, test.skip(. + * Excludes describe() which is a grouper, not an individual test. + * + * @param {string} source + * @returns {number} + */ +function countDefinedTests(source) { + return (source.match(/\b(?:it|test)(?:\.skip)?\s*\(/g) ?? []).length; +} + +/** + * Recursively collects file paths under `dir` that satisfy `predicate(filename)`. + * + * @param {string} dir + * @param {(name: string) => boolean} predicate + * @returns {Promise} + */ +async function walkFiles(dir, predicate) { + const results = []; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...(await walkFiles(fullPath, predicate))); + } else if (entry.isFile() && predicate(entry.name)) { + results.push(fullPath); + } + } + return results; +} + /** * Extracts a feature folder name from a Jest test file path. * @@ -197,12 +336,12 @@ async function collectShardCounts(artifactPattern, label, minFolderCount = 0) { } console.log(`[${label}] total: ${total}`); - const result = { tests_count: total }; + const result = { total_tests_run: total }; for (const [folder, count] of Object.entries(folderCounts)) { if (minFolderCount > 0 && count < minFolderCount) { - result.other_tests_count = (result.other_tests_count ?? 0) + count; + result.other_tests_run = (result.other_tests_run ?? 0) + count; } else { - result[`${folder}_tests_count`] = count; + result[`${folder}_tests_run`] = count; } } return result; @@ -210,14 +349,78 @@ async function collectShardCounts(artifactPattern, label, minFolderCount = 0) { async function collectComponentViewTestCount() { console.log('[component-view] collecting per-suite counts from shard artifacts...'); - return collectShardCounts(/^coverage-cv-\d+$/, 'component-view'); + const result = await collectShardCounts(/^coverage-cv-\d+$/, 'component-view'); + if (Object.keys(result).length === 0) return result; + + const isViewTestFile = (name) => PATTERN_CV_TEST_FILE.test(name); + const files = await walkFiles(SCAN_APP_DIR, isViewTestFile); + let defined = 0, skips = 0; + for (const f of files) { + const source = await readFile(f, 'utf8'); + defined += countDefinedTests(source); + skips += countSkips(source); + } + result.total_tests_defined = defined; + result.total_tests_skipped = skips; + + // Coverage from the pre-computed nyc json-summary report produced by + // the merge-unit-and-component-view-tests job in ci.yml. + try { + const destDir = await downloadArtifact('cv-test-coverage-summary'); + const summary = JSON.parse(await readFile(join(destDir, 'coverage-summary.json'), 'utf8')); + const { lines, statements, branches, functions } = summary.total; + result.coverage_line = Math.round(lines.pct * 10) / 10; + result.coverage_statement = Math.round(statements.pct * 10) / 10; + result.coverage_branch = Math.round(branches.pct * 10) / 10; + result.coverage_function = Math.round(functions.pct * 10) / 10; + console.log( + `[component-view] coverage — line: ${result.coverage_line}%, stmt: ${result.coverage_statement}%, branch: ${result.coverage_branch}%, fn: ${result.coverage_function}%`, + ); + } catch (err) { + console.warn(`[component-view] coverage summary not available, skipping: ${err.message}`); + } + + return result; } async function collectUnitTestCount() { console.log('[unit] collecting per-suite counts from shard artifacts...'); // minFolderCount=200: buckets individual component-level folders into `other`, // keeping only meaningful team-level categories (bridge, perps, confirmations, etc.) - return collectShardCounts(/^coverage-unit-\d+$/, 'unit', 200); + const result = await collectShardCounts(/^coverage-unit-\d+$/, 'unit', 200); + if (Object.keys(result).length === 0) return result; + + // Unit test files: *.test.{ts,tsx,js} excluding *.view[.*].test.* + const isUnitTestFile = (name) => + PATTERN_UNIT_TEST_FILE.test(name) && !PATTERN_CV_TEST_FILE.test(name); + const files = await walkFiles(SCAN_APP_DIR, isUnitTestFile); + let defined = 0, skips = 0; + for (const f of files) { + const source = await readFile(f, 'utf8'); + defined += countDefinedTests(source); + skips += countSkips(source); + } + result.total_tests_defined = defined; + result.total_tests_skipped = skips; + + // Coverage from the pre-computed nyc json-summary report produced by + // the merge-unit-and-component-view-tests job in ci.yml. + try { + const destDir = await downloadArtifact('unit-test-coverage-summary'); + const summary = JSON.parse(await readFile(join(destDir, 'coverage-summary.json'), 'utf8')); + const { lines, statements, branches, functions } = summary.total; + result.coverage_line = Math.round(lines.pct * 10) / 10; + result.coverage_statement = Math.round(statements.pct * 10) / 10; + result.coverage_branch = Math.round(branches.pct * 10) / 10; + result.coverage_function = Math.round(functions.pct * 10) / 10; + console.log( + `[unit] coverage — line: ${result.coverage_line}%, stmt: ${result.coverage_statement}%, branch: ${result.coverage_branch}%, fn: ${result.coverage_function}%`, + ); + } catch (err) { + console.warn(`[unit] coverage summary not available, skipping: ${err.message}`); + } + + return result; } /** @@ -313,20 +516,34 @@ async function collectE2ECounts() { // Canonical unique counts (Android as source of truth — same tests run on iOS) // A missing key means that channel did not run; present-but-zero means it ran and found nothing. if (androidMain > 0 || iosMain > 0) { - result.main_tests_count = androidMain; // unique count - result.main_android_tests_count = androidMain; // platform health signal - result.main_ios_tests_count = iosMain; // drops to 0 if iOS infrastructure is broken + result.main_tests_run = androidMain; // unique count + result.main_android_tests_run = androidMain; // platform health signal + result.main_ios_tests_run = iosMain; // drops to 0 if iOS infrastructure is broken } if (androidFlask > 0 || iosFlask > 0) { - result.flask_tests_count = androidFlask; // unique count - result.flask_android_tests_count = androidFlask; - result.flask_ios_tests_count = iosFlask; + result.flask_tests_run = androidFlask; // unique count + result.flask_android_tests_run = androidFlask; + result.flask_ios_tests_run = iosFlask; } - result.tests_count = androidMain + androidFlask; + result.total_tests_run = androidMain + androidFlask; for (const [tag, count] of Object.entries(suiteCounts)) { - result[`${tag}_tests_count`] = count; + result[`${tag}_tests_run`] = count; + } + + // Static scan — independent of which platform/channel ran + const isSpecTs = (name) => PATTERN_E2E_SPEC_FILE.test(name); + let defined = 0, skips = 0; + for (const dir of SCAN_E2E_SMOKE_DIRS) { + const files = await walkFiles(dir, isSpecTs); + for (const f of files) { + const source = await readFile(f, 'utf8'); + defined += countDefinedTests(source); + skips += countSkips(source); + } } + result.total_tests_defined = defined; + result.total_tests_skipped = skips; return result; } @@ -342,6 +559,7 @@ async function collectPerformanceTestCounts() { console.log('[performance] scanning tests/performance/ for scenarios...'); const categoryCounts = {}; + let totalSkips = 0; async function scanDir(dir, category) { const entries = await readdir(dir, { withFileTypes: true }); @@ -350,29 +568,30 @@ async function collectPerformanceTestCounts() { if (entry.isDirectory()) { // Top-level subdirectory determines the category await scanDir(fullPath, category ?? entry.name); - } else if (entry.isFile() && entry.name.endsWith('.spec.js')) { + } else if (entry.isFile() && PATTERN_PERF_SPEC_FILE.test(entry.name)) { const source = await readFile(fullPath, 'utf8'); - // Count test() calls, excluding test.skip() - const matches = source.match(/^\s*test\s*\(/gm) ?? []; + // Count all test() calls — including test.skip() — for total_tests_defined + const matches = source.match(/^\s*test(?:\.skip)?\s*\(/gm) ?? []; const count = matches.length; if (count > 0 && category) { const key = category.replace(/-/g, '_'); categoryCounts[key] = (categoryCounts[key] ?? 0) + count; } + totalSkips += countSkips(source); } } } - await scanDir('tests/performance', null); + await scanDir(SCAN_PERFORMANCE_DIR, null); const total = Object.values(categoryCounts).reduce((s, n) => s + n, 0); - const result = { tests_count: total }; + const result = { total_tests_defined: total, total_tests_skipped: totalSkips }; for (const [cat, count] of Object.entries(categoryCounts)) { - result[`${cat}_tests_count`] = count; + result[`${cat}_tests_defined`] = count; console.log(`[performance] ${cat}: ${count}`); } - console.log(`[performance] total: ${total}`); + console.log(`[performance] total: ${total}, skips: ${totalSkips}`); return result; } @@ -384,8 +603,8 @@ async function main() { const stats = {}; const collectors = [ - { namespace: 'component_view', collect: collectComponentViewTestCount }, { namespace: 'unit', collect: collectUnitTestCount }, + { namespace: 'component_view', collect: collectComponentViewTestCount }, { namespace: 'e2e', collect: collectE2ECounts }, { namespace: 'performance', collect: collectPerformanceTestCounts }, ]; diff --git a/.github/scripts/e2e-risk-label.mjs b/.github/scripts/e2e-risk-label.mjs new file mode 100644 index 00000000000..158f0a2325d --- /dev/null +++ b/.github/scripts/e2e-risk-label.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node + +/** + * Applies a risk label (risk-low / risk-medium / risk-high) to a PR based on + * the risk level output from the Smart E2E selection step. + * + * Required environment variables: + * RISK_LEVEL - 'low' | 'medium' | 'high' + * GH_TOKEN - GitHub token with pull-requests:write and issues:write + * GITHUB_REPOSITORY - owner/repo + * PR_NUMBER - pull request number + */ + +const RISK_LEVEL = process.env.RISK_LEVEL || ''; +const GH_TOKEN = process.env.GH_TOKEN || ''; +const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY || ''; +const PR_NUMBER = process.env.PR_NUMBER || ''; + +const RISK_LABELS = { + low: { + color: '0E8A16', + description: 'Low testing needed · Low bug introduction risk', + }, + medium: { + color: 'FBCA04', + description: 'Moderate testing recommended · Possible bug introduction risk', + }, + high: { + color: 'B60205', + description: 'Extensive testing required · High bug introduction risk', + }, +}; + +async function githubApi(path, options = {}) { + const res = await fetch(`https://api.github.com${path}`, { + ...options, + headers: { + Authorization: `Bearer ${GH_TOKEN}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + return res; +} + +async function main() { + if (!RISK_LEVEL) { + console.log('⏭️ No risk level provided, skipping label'); + return; + } + + if (!RISK_LABELS[RISK_LEVEL]) { + console.error(`❌ Unknown risk level: "${RISK_LEVEL}"`); + process.exit(1); + } + + if (!GH_TOKEN || !GITHUB_REPOSITORY || !PR_NUMBER) { + console.error('❌ Missing required env: GH_TOKEN, GITHUB_REPOSITORY, PR_NUMBER'); + process.exit(1); + } + + // Ensure all three risk labels exist on the repo (idempotent — 422 = already exists) + for (const [level, meta] of Object.entries(RISK_LABELS)) { + await githubApi(`/repos/${GITHUB_REPOSITORY}/labels`, { + method: 'POST', + body: JSON.stringify({ name: `risk-${level}`, color: meta.color, description: meta.description }), + }); + } + + // Fetch current PR labels + const labelsRes = await githubApi(`/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/labels`); + if (!labelsRes.ok) { + const body = await labelsRes.text(); + console.error(`❌ Failed to fetch PR labels: ${labelsRes.status} ${body}`); + process.exit(1); + } + const currentLabels = await labelsRes.json(); + + // Remove stale risk labels + for (const label of currentLabels) { + if (Object.keys(RISK_LABELS).map(l => `risk-${l}`).includes(label.name)) { + await githubApi(`/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/labels/${encodeURIComponent(label.name)}`, { + method: 'DELETE', + }); + console.log(`🗑️ Removed stale label: ${label.name}`); + } + } + + // Add the new risk label + const newLabel = `risk-${RISK_LEVEL}`; + const addRes = await githubApi(`/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/labels`, { + method: 'POST', + body: JSON.stringify({ labels: [newLabel] }), + }); + + if (!addRes.ok) { + const body = await addRes.text(); + console.error(`❌ Failed to add label "${newLabel}": ${addRes.status} ${body}`); + process.exit(1); + } + + console.log(`✅ Applied risk label: ${newLabel}`); +} + +main().catch(error => { + console.error('❌ Unexpected error:', error); + process.exit(1); +}); diff --git a/.github/scripts/e2e-smart-selection.mjs b/.github/scripts/e2e-smart-selection.mjs index 33c0b2bc205..933949fb4a3 100644 --- a/.github/scripts/e2e-smart-selection.mjs +++ b/.github/scripts/e2e-smart-selection.mjs @@ -11,6 +11,7 @@ const env = { PR_NUMBER: process.env.PR_NUMBER || '', GITHUB_OUTPUT: process.env.GITHUB_OUTPUT || '', GITHUB_STEP_SUMMARY: process.env.GITHUB_STEP_SUMMARY || '', + BASE_REF: process.env.BASE_REF || 'main', }; const PR_COMMENT_FILE = 'pr_comment.md'; @@ -62,9 +63,10 @@ function generatePRComment(summaryContent) { } function setGitHubOutputs(analysis) { - const { tags, confidence, performanceTests } = analysis; + const { tags, confidence, riskLevel, performanceTests } = analysis; setGithubOutputs('ai_e2e_test_tags', tags); setGithubOutputs('ai_confidence', confidence); + setGithubOutputs('ai_risk_level', riskLevel); // Performance test tags (empty array means no performance tests needed) setGithubOutputs('ai_performance_test_tags', JSON.stringify(performanceTests.selectedTags)); } @@ -76,9 +78,11 @@ async function main() { return; } - // Build command - always uses origin/main as base (job only runs on PRs targeting main) - const baseCmd = `node -r esbuild-register tests/tools/e2e-ai-analyzer --mode select-tags --pr ${env.PR_NUMBER}`; - console.log(`🎯 Analyzing PR against origin/main`); + // Build command - uses GitHub API (PR number) for changed files list; -b ensures + // file diffs are computed against the correct base branch (main or release/*) + const baseBranch = `origin/${env.BASE_REF}`; + const baseCmd = `node -r esbuild-register tests/tools/e2e-ai-analyzer --mode select-tags --pr ${env.PR_NUMBER} -b ${baseBranch}`; + console.log(`🎯 Analyzing PR #${env.PR_NUMBER} against base branch: ${baseBranch}`); try { execSync(baseCmd, { diff --git a/.github/scripts/validate-pr-commit.sh b/.github/scripts/validate-pr-commit.sh index a1632b8dfb9..05e7443d94c 100755 --- a/.github/scripts/validate-pr-commit.sh +++ b/.github/scripts/validate-pr-commit.sh @@ -3,27 +3,25 @@ # # validate-pr-commit.sh # -# Validates that a given commit hash is the HEAD (last commit) of a specified PR. +# Resolves the HEAD (last) commit of a PR and outputs it for the workflow. +# Caller only needs to provide the PR number; no commit hash input required. # # Environment Variables (required): -# COMMIT_HASH - The commit SHA to validate -# PR_NUMBER - The PR number to check against -# BASE_BRANCH - The base branch of the PR (for validation) -# GITHUB_TOKEN - GitHub API token for authentication +# PR_NUMBER - The PR number to resolve +# GITHUB_TOKEN - GitHub API token for authentication # GITHUB_REPOSITORY - Repository in format "owner/repo" # -# Outputs: -# Sets GITHUB_OUTPUT pr_number if validation succeeds -# Exits with code 0 on success, 1 on failure +# Outputs (to GITHUB_OUTPUT): +# pr_number - The PR number (for downstream steps) +# commit_sha - The HEAD commit SHA of the PR branch # +# Exits with code 0 on success, 1 on failure # set -euo pipefail # Ensure required environment variables are set -COMMIT_HASH="${COMMIT_HASH:?COMMIT_HASH environment variable must be set}" PR_NUMBER="${PR_NUMBER:?PR_NUMBER environment variable must be set}" -BASE_BRANCH="${BASE_BRANCH:?BASE_BRANCH environment variable must be set}" GITHUB_TOKEN="${GITHUB_TOKEN:?GITHUB_TOKEN environment variable must be set}" GITHUB_REPOSITORY="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY environment variable must be set}" @@ -32,7 +30,7 @@ REPO="${GITHUB_REPOSITORY#*/}" API_BASE="https://api.github.com/repos/${OWNER}/${REPO}" -echo "🔍 Validating that commit ${COMMIT_HASH} is the HEAD (last commit) of PR #${PR_NUMBER} (base: ${BASE_BRANCH})..." >&2 +echo "🔍 Resolving HEAD commit for PR #${PR_NUMBER}..." >&2 # Function to make GitHub API calls api_call() { @@ -49,11 +47,9 @@ pr_details=$(api_call "/pulls/${PR_NUMBER}") # Check if PR exists if echo "$pr_details" | jq -e '.id' > /dev/null 2>&1; then - pr_base_ref=$(echo "$pr_details" | jq -r '.base.ref') pr_head_sha=$(echo "$pr_details" | jq -r '.head.sha') pr_state=$(echo "$pr_details" | jq -r '.state') - - echo " PR base branch: ${pr_base_ref}" >&2 + echo " PR head SHA: ${pr_head_sha}" >&2 echo " PR state: ${pr_state}" >&2 else @@ -61,24 +57,9 @@ else exit 1 fi -# Validate base branch matches (security-critical) -if [ "$pr_base_ref" != "$BASE_BRANCH" ]; then - echo "❌ Validation failed: PR base branch (${pr_base_ref}) does not match provided base branch (${BASE_BRANCH})" >&2 - echo "💡 The base_branch input must exactly match the PR's actual base branch to prevent fingerprint comparison against the wrong target." >&2 - exit 1 -fi - -# Validate that commit is the PR head (last commit) -if [ "$pr_head_sha" = "$COMMIT_HASH" ]; then - echo "✅ Commit ${COMMIT_HASH} is the HEAD (last commit) of PR #${PR_NUMBER}" >&2 - echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" - exit 0 -else - echo "❌ Validation failed: Commit ${COMMIT_HASH} is not the HEAD of PR #${PR_NUMBER}" >&2 - echo "❌ PR HEAD commit is: ${pr_head_sha}" >&2 - echo "❌ Target commit is: ${COMMIT_HASH}" >&2 - echo "" >&2 - echo "💡 The commit hash must be the last commit in the PR branch." >&2 - echo "💡 Please use the HEAD commit SHA of the PR branch." >&2 - exit 1 -fi +echo "✅ Resolved PR #${PR_NUMBER} HEAD commit: ${pr_head_sha}" >&2 +{ + echo "pr_number=${PR_NUMBER}" + echo "commit_sha=${pr_head_sha}" +} >> "$GITHUB_OUTPUT" +exit 0 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 454a2c6cf17..dd694bdd9fc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,6 +13,16 @@ on: required: false type: boolean default: false + source_branch: + description: 'Branch, tag, or SHA to build' + required: false + type: string + default: '' + ref: + description: 'Git ref to checkout when skip_version_bump is true. Defaults to the triggering event ref.' + required: false + type: string + default: '' workflow_dispatch: inputs: build_name: @@ -55,7 +65,7 @@ jobs: contents: write id-token: write with: - base-branch: ${{ github.ref_name }} + base-branch: ${{ inputs.source_branch != '' && inputs.source_branch || github.ref_name }} secrets: PR_TOKEN: ${{ secrets.PR_TOKEN }} @@ -70,8 +80,12 @@ 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 }} + checkout_ref_for_setup: ${{ !inputs.skip_version_bump && needs.update-build-version.outputs.commit-hash || (inputs.source_branch != '' && 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 != '' && inputs.source_branch || github.ref_name) }} - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -99,17 +113,20 @@ jobs: setup-dependencies: name: Setup Dependencies (${{ matrix.platform }}) needs: [prepare] + if: ${{ always() && !failure() && !cancelled() }} strategy: matrix: platform: ${{ inputs.platform == 'both' && fromJSON('["android", "ios"]') || fromJSON(format('["{0}"]', inputs.platform)) }} uses: ./.github/workflows/setup-node-modules.yml with: + ref: ${{ needs.prepare.outputs.checkout_ref_for_setup }} + fetch-depth: 0 checkout-submodules: true platform: ${{ matrix.platform }} build_name: ${{ inputs.build_name }} use-tarball: true upload-artifact: true - artifact-name: node-modules-${{ matrix.platform }} + artifact-name: node-modules-${{ inputs.build_name }}-${{ matrix.platform }} artifact-retention-days: 1 # Build @@ -140,7 +157,13 @@ jobs: submodules: recursive - uses: actions/checkout@v4 - if: ${{ inputs.skip_version_bump }} + if: ${{ inputs.skip_version_bump && inputs.ref != '' }} + with: + ref: ${{ inputs.ref }} + submodules: recursive + + - uses: actions/checkout@v4 + if: ${{ inputs.skip_version_bump && inputs.ref == '' }} with: submodules: recursive @@ -153,7 +176,7 @@ jobs: - name: Download node_modules tarball uses: actions/download-artifact@v4 with: - name: node-modules-${{ matrix.platform }} + name: node-modules-${{ inputs.build_name }}-${{ matrix.platform }} - name: Extract tarball (preserves symlinks) run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7e34932157..aca85f8ad59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,6 +190,7 @@ jobs: with: name: ios-bundle path: ios/main.jsbundle + retention-days: 7 ship-js-bundle-size-check: runs-on: ubuntu-latest @@ -262,6 +263,7 @@ jobs: name: coverage-unit-${{ matrix.shard }} path: ./tests/coverage/ if-no-files-found: error + retention-days: 7 - name: Require clean working directory shell: bash run: | @@ -272,6 +274,8 @@ jobs: echo "No changes detected" fi + # 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 needs: [unit-tests, component-view-tests] @@ -321,6 +325,11 @@ jobs: [ -f "$file" ] && cp "$file" ./tests/coverage-cv-merged/ done + mkdir -p tests/coverage-unit-merged + for file in ./tests/coverage/coverage-unit-*/coverage-unit-*.json; do + [ -f "$file" ] && cp "$file" ./tests/coverage-unit-merged/ + done + find ./tests/coverage/coverage-* -name 'coverage-*.json' -exec mv {} ./tests/coverage/ \; - run: yarn test:merge-coverage - run: yarn test:validate-coverage @@ -329,23 +338,41 @@ jobs: name: lcov.info path: ./tests/merged-coverage/lcov.info if-no-files-found: error + retention-days: 7 - uses: actions/upload-artifact@v4 with: name: cv-test-stats path: ./cv-test-stats.json if-no-files-found: error + retention-days: 7 - uses: actions/upload-artifact@v4 with: name: unit-test-stats path: ./unit-test-stats.json if-no-files-found: error - - name: Generate CV test HTML coverage report - run: yarn nyc report --temp-dir ./tests/coverage-cv-merged --report-dir ./tests/coverage-cv-lcov --reporter html + retention-days: 7 + - name: Generate CV test coverage report + run: yarn nyc report --temp-dir ./tests/coverage-cv-merged --report-dir ./tests/coverage-cv-lcov --reporter html --reporter json-summary - uses: actions/upload-artifact@v4 with: name: cv-test-coverage-html path: ./tests/coverage-cv-lcov/ if-no-files-found: error + retention-days: 7 + - uses: actions/upload-artifact@v4 + with: + name: cv-test-coverage-summary + path: ./tests/coverage-cv-lcov/coverage-summary.json + if-no-files-found: error + retention-days: 7 + - name: Generate unit test coverage summary + run: yarn nyc report --temp-dir ./tests/coverage-unit-merged --report-dir ./tests/coverage-unit-lcov --reporter json-summary + - uses: actions/upload-artifact@v4 + with: + name: unit-test-coverage-summary + path: ./tests/coverage-unit-lcov/coverage-summary.json + if-no-files-found: error + retention-days: 7 - name: Require clean working directory shell: bash run: | @@ -397,6 +424,7 @@ jobs: name: coverage-cv-${{ matrix.shard }} path: ./tests/coverage/ if-no-files-found: error + retention-days: 7 needs_e2e_build: uses: ./.github/workflows/needs-e2e-build.yml @@ -408,6 +436,7 @@ jobs: continue-on-error: true permissions: contents: read + issues: write pull-requests: write outputs: ai_e2e_test_tags: ${{ steps.e2e-selection.outputs.ai_e2e_test_tags }} @@ -419,6 +448,7 @@ jobs: with: sparse-checkout: | .github/actions/smart-e2e-selection + .github/scripts/e2e-risk-label.mjs sparse-checkout-cone-mode: false fetch-depth: 1 @@ -435,6 +465,7 @@ jobs: repository: ${{ github.repository }} post-comment: 'true' base-ref: ${{ github.event.pull_request.base.ref }} + is-draft: ${{ github.event.pull_request.draft }} # Main E2E tests diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index b358b99881e..50fde16b047 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -6,7 +6,7 @@ on: types: [opened,closed,synchronize] merge_group: types: [checks_requested] - + jobs: CLABot: if: github.event_name == 'pull_request_target' || contains(github.event.comment.html_url, '/pull/') @@ -24,6 +24,6 @@ jobs: url-to-cladocument: 'https://metamask.io/cla.html' # This branch can't have protections, commits are made directly to the specified branch. branch: 'cla-signatures' - allowlist: 'dependabot[bot],metamaskbot,crowdin-bot,runway-github[bot],cursorbot,cursoragent' + allowlist: 'dependabot[bot],metamaskbot,metamaskbotv2[bot],crowdin-bot,runway-github[bot],cursorbot,cursoragent' allow-organization-members: true blockchain-storage-flag: false diff --git a/.github/workflows/expo-dev-build.yml b/.github/workflows/expo-dev-build.yml new file mode 100644 index 00000000000..0c818c4a479 --- /dev/null +++ b/.github/workflows/expo-dev-build.yml @@ -0,0 +1,34 @@ +############################################################################################## +# +# Expo Dev Build — replaces the Bitrise expo_dev_pipeline. +# +# Triggered on every push to main. Builds the main-dev configuration (Debug, simulator) +# 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. +# +# [skip ci] commits (e.g. version bumps) are automatically skipped by GitHub Actions. +# +############################################################################################## +name: Expo Dev Build + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + id-token: write + +jobs: + build-dev: + name: Expo dev build (main-dev) + uses: ./.github/workflows/build.yml + with: + build_name: main-dev + platform: both + skip_version_bump: true + secrets: inherit diff --git a/.github/workflows/generate-rc-test-plan.yml b/.github/workflows/generate-rc-test-plan.yml new file mode 100644 index 00000000000..3a8096ba9b8 --- /dev/null +++ b/.github/workflows/generate-rc-test-plan.yml @@ -0,0 +1,367 @@ +name: Generate RC Test Plan + +# Trigger when Bitrise posts "RC Builds Ready for Testing" comment +on: + issue_comment: + types: [created] + +jobs: + generate-test-plan: + name: Generate AI Test Plan + # Only run when: + # 1. Comment is on a PR (not an issue) + # 2. Comment contains "RC Builds Ready for Testing" + # 3. Comment is from github-actions bot (Bitrise posts via this) + if: | + github.event.issue.pull_request && + contains(github.event.comment.body, 'RC Builds Ready for Testing') && + github.event.comment.user.login == 'github-actions[bot]' + runs-on: ubuntu-latest + environment: release-ci + timeout-minutes: 15 + permissions: + contents: write + pull-requests: write + issues: write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + E2E_CLAUDE_API_KEY: ${{ secrets.E2E_CLAUDE_API_KEY }} + E2E_OPENAI_API_KEY: ${{ secrets.E2E_OPENAI_API_KEY }} + E2E_GEMINI_API_KEY: ${{ secrets.E2E_GEMINI_API_KEY }} + PR_NUMBER: ${{ github.event.issue.number }} + + steps: + - name: Check if release PR + id: check-release + uses: actions/github-script@v7 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: process.env.PR_NUMBER + }); + + const isRelease = pr.data.head.ref.startsWith('release/'); + console.log(`PR branch: ${pr.data.head.ref}, isRelease: ${isRelease}`); + + if (!isRelease) { + console.log('Not a release PR, skipping test plan generation'); + return; + } + + // Extract version from branch name (e.g., release/7.70.0 -> 7.70.0) + // Sanitize to prevent shell injection - only allow semver chars + const rawVersion = pr.data.head.ref.replace('release/', ''); + const version = rawVersion.replace(/[^0-9.]/g, ''); + if (!version || !/^\d+\.\d+\.\d+$/.test(version)) { + console.log(`Invalid version format: ${rawVersion}`); + return; + } + core.setOutput('version', version); + core.setOutput('is_release', 'true'); + core.setOutput('pr_title', pr.data.title); + + - name: Checkout repository + if: steps.check-release.outputs.is_release == 'true' + uses: actions/checkout@v4 + + - name: Setup Node.js + if: steps.check-release.outputs.is_release == 'true' + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'yarn' + + - name: Install dependencies + if: steps.check-release.outputs.is_release == 'true' + run: yarn install --frozen-lockfile + + - name: Extract build number from comment + if: steps.check-release.outputs.is_release == 'true' + id: extract-build + uses: actions/github-script@v7 + with: + script: | + const comment = context.payload.comment.body; + // Match build number from "RC X.Y.Z (BUILD)" pattern specifically + // e.g., "RC 7.65.0 (4025)" captures "4025" + const buildMatch = comment.match(/RC\s+\d+\.\d+\.\d+\s*\((\d+)\)/i); + if (buildMatch) { + const buildNumber = buildMatch[1]; + console.log(`Extracted build number: ${buildNumber}`); + core.setOutput('build_number', buildNumber); + } else { + console.log('Could not extract build number from comment'); + core.setOutput('build_number', ''); + } + + - name: Generate test plan + if: steps.check-release.outputs.is_release == 'true' + id: generate + run: | + VERSION="${{ steps.check-release.outputs.version }}" + RAW_BUILD="${{ steps.extract-build.outputs.build_number }}" + # Sanitize BUILD to only allow digits + BUILD=$(echo "$RAW_BUILD" | tr -cd '0-9') + + echo "Generating test plan for version: $VERSION, build: $BUILD" + + # Sanitize PR_NUMBER to only allow digits + PR_NUM=$(echo "${{ env.PR_NUMBER }}" | tr -cd '0-9') + + # Run the analyzer + if node -r esbuild-register tests/tools/e2e-ai-analyzer \ + --mode generate-test-plan \ + --pr "$PR_NUM" \ + --auto-ff \ + -v "$VERSION"; then + + echo "test_plan_generated=true" >> "${GITHUB_OUTPUT}" + else + echo "Warning: Test plan generation failed" + echo "test_plan_generated=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Generate HTML viewer + if: steps.generate.outputs.test_plan_generated == 'true' + run: | + VERSION="${{ steps.check-release.outputs.version }}" + BUILD="${{ steps.extract-build.outputs.build_number }}" + + # Create test-plans directory + mkdir -p test-plans + + # Move JSON + mv release-test-plan.json "test-plans/test-plan-${VERSION}.json" + + # Generate HTML viewer + node -e " + const fs = require('fs'); + const plan = JSON.parse(fs.readFileSync('test-plans/test-plan-${VERSION}.json', 'utf8')); + + // Escape HTML to prevent XSS from LLM-generated content + const esc = (s) => (s == null ? '' : String(s)).replace(/&/g, '&').replace(//g, '>').replace(/\"/g, '"'); + + const html = \` + + + + + RC \${esc(plan.version) || '${VERSION}'} Test Plan + + + +

🧪 RC \${esc(plan.version) || '${VERSION}'} Test Plan

+

Build: \${plan.buildNumber || '${BUILD}'} | Generated: \${new Date(plan.generatedAt).toLocaleString()}

+ + \${plan.executiveSummary ? \` +
+

📊 Executive Summary

+

\${esc(plan.executiveSummary.releaseFocus)}

+

Key Changes:

+
    \${plan.executiveSummary.keyChanges.map(c => '
  • ' + esc(c) + '
  • ').join('')}
+

Risk Level: \${esc(plan.executiveSummary.overallRisk).toUpperCase()}

+

Recommendation: \${esc(plan.executiveSummary.recommendation)}

+
+ \` : ''} + +
+

📈 Summary

+
+
\${plan.summary?.releaseRiskScore || '0/100'}
Risk Score
+
\${plan.summary?.totalFiles || plan.summary?.totalFilesChanged || 0}
Files Changed
+
\${plan.summary?.highImpactFiles || 0}
High Impact
+
\${plan.summary?.highRiskCount || plan.summary?.highRiskScenarios || 0}
High Risk
+
\${plan.summary?.mediumRiskCount || plan.summary?.mediumRiskScenarios || 0}
Medium Risk
+
+
+ + \${(plan.signOffs?.needsAttention?.length || plan.teamsNeedingSignOff?.length) ? \` +

👥 Teams Needing Sign-off

+
\${(plan.signOffs?.needsAttention || plan.teamsNeedingSignOff || []).map(t => '⏳ ' + esc(t) + '').join('')}
+ \` : ''} + + \${plan.testScenarios?.cherryPickScenarios?.length ? \` +

🍒 Cherry-Pick Scenarios

+ \${plan.testScenarios.cherryPickScenarios.map((s, i) => \` +
+

\${i + 1}. \${esc(s.area)} CHERRY-PICK

+

Why: \${esc(s.whyThisMatters)}

+
Test Steps:
    \${(s.testSteps || []).map(step => '
  1. ' + esc(step) + '
  2. ').join('')}
+
+ \`).join('')} + \` : ''} + +

🔴 High Risk Areas

+ \${(plan.scenarios || plan.testScenarios?.initialScenarios || []).filter(s => s.riskLevel === 'high').map((s, i) => \` +
+

\${i + 1}. \${esc(s.area)} HIGH

+

Why: \${esc(s.whyThisMatters)}

+ \${s.preconditions?.length ? '
Preconditions:
    ' + s.preconditions.map(p => '
  • ' + esc(p) + '
  • ').join('') + '
' : ''} +
Test Steps:
    \${(s.testSteps || []).map(step => '
  1. ' + esc(step) + '
  2. ').join('')}
+ \${s.expectedOutcomes?.length ? '
Expected Outcomes:
    ' + s.expectedOutcomes.map(o => '
  • ✓ ' + esc(o) + '
  • ').join('') + '
' : ''} +
+ \`).join('')} + +

🟡 Medium Risk Areas

+ \${(plan.scenarios || plan.testScenarios?.initialScenarios || []).filter(s => s.riskLevel === 'medium').map((s, i) => \` +
+

\${i + 1}. \${esc(s.area)} MEDIUM

+

Why: \${esc(s.whyThisMatters)}

+ \${s.preconditions?.length ? '
Preconditions:
    ' + s.preconditions.map(p => '
  • ' + esc(p) + '
  • ').join('') + '
' : ''} +
Test Steps:
    \${(s.testSteps || []).map(step => '
  1. ' + esc(step) + '
  2. ').join('')}
+ \${s.expectedOutcomes?.length ? '
Expected Outcomes:
    ' + s.expectedOutcomes.map(o => '
  • ✓ ' + esc(o) + '
  • ').join('') + '
' : ''} +
+ \`).join('')} + +
+

Generated by AI Test Plan Analyzer | MetaMask Mobile

+

Download JSON

+
+ + \`; + + fs.writeFileSync('test-plans/test-plan-${VERSION}.html', html); + console.log('Generated HTML viewer'); + " + + - name: Deploy to GitHub Pages + if: steps.generate.outputs.test_plan_generated == 'true' + run: | + VERSION="${{ steps.check-release.outputs.version }}" + + # Save generated files to temp before switching branches + cp test-plans/test-plan-${VERSION}.json /tmp/ + cp test-plans/test-plan-${VERSION}.html /tmp/ + + # Clean up to avoid conflicts when switching branches + rm -rf test-plans + + # Configure git + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Fetch gh-pages branch or create it + git fetch origin gh-pages:gh-pages 2>/dev/null || echo "gh-pages doesn't exist yet" + + # Switch to gh-pages (create orphan if it doesn't exist) + if git checkout gh-pages 2>/dev/null; then + echo "Switched to existing gh-pages branch" + else + # Create orphan branch and clear the index to avoid committing entire repo + git checkout --orphan gh-pages + git rm -rf . 2>/dev/null || true + git clean -fd 2>/dev/null || true + fi + + # Create test-plans directory + mkdir -p test-plans + + # Copy files from temp (overwrites if exists - handles re-runs) + cp /tmp/test-plan-${VERSION}.json test-plans/ + cp /tmp/test-plan-${VERSION}.html test-plans/ + + # Add and commit + git add test-plans/ + git commit -m "Add test plan for RC ${VERSION}" || echo "No changes to commit" + + # Push to gh-pages + git push origin gh-pages + + - name: Update build comment with test plan links + if: steps.generate.outputs.test_plan_generated == 'true' + uses: actions/github-script@v7 + with: + script: | + const version = '${{ steps.check-release.outputs.version }}'; + const buildNumber = '${{ steps.extract-build.outputs.build_number }}'; + const commentId = context.payload.comment.id; + + // Fetch latest comment body to avoid race conditions + const { data: comment } = await github.rest.issues.getComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId + }); + const currentBody = comment.body; + + const baseUrl = `https://metamask.github.io/metamask-mobile/test-plans`; + const htmlUrl = `${baseUrl}/test-plan-${version}.html`; + const jsonUrl = `${baseUrl}/test-plan-${version}.json`; + + // Add test plan row to the existing comment + const testPlanSection = ` + + --- + 🤖 **AI Test Plan:** [View](${htmlUrl}) | [JSON](${jsonUrl})`; + + // Update the comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: currentBody + testPlanSection + }); + + - name: Post failure notice + if: steps.check-release.outputs.is_release == 'true' && steps.generate.outputs.test_plan_generated != 'true' + uses: actions/github-script@v7 + with: + script: | + const version = '${{ steps.check-release.outputs.version }}'; + const commentId = context.payload.comment.id; + const runId = context.runId; + const logsUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; + + // Fetch latest comment body to avoid race conditions + const { data: comment } = await github.rest.issues.getComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId + }); + const currentBody = comment.body; + + const failureSection = ` + + --- + ⚠️ **AI Test Plan generation failed** - [View logs](${logsUrl})`; + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: currentBody + failureSection + }); diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 0f4c33157b2..846bad6f383 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -2,41 +2,212 @@ name: Nightly Build # Triggered by every push to chore/temp-nightly (which nightly-temp-branch-sync.yml # force-pushes daily at 4 AM UTC to match main). +# Temporarily now this is pointing to test/temp-nightly instead of chore/temp-nightly. # -# [skip ci] commits (e.g. version bumps pushed by Bitrise's bump_version_code job via -# update-latest-build-version.yml) are automatically skipped by GitHub Actions, so -# this workflow will NOT double-trigger on those commits. +# [skip ci] commits (e.g. version bumps pushed via update-latest-build-version.yml) +# are automatically skipped by GitHub Actions, so this workflow will NOT +# double-trigger on those commits. # -# skip_version_bump=true is passed to build.yml because Bitrise already owns the -# version bump for chore/temp-nightly during the parallel transition period. -# When Bitrise is deprecated, remove skip_version_bump: true and the version bump -# will be handled by build.yml as normal. +# Version strategy: exp and rc builds share the same bundle ID (MetaMask) so +# TestFlight requires unique CFBundleVersion per upload. We call the external +# version generator once (→ version N for exp), then locally increment to N+1 +# for the rc build. Both builds run in parallel after their respective bumps. on: push: branches: - - chore/temp-nightly + - test/temp-nightly workflow_dispatch: +# contents: write required by build.yml update-build-version job (version bump commit push) permissions: - contents: read + contents: write id-token: write jobs: + bump-version-exp: + name: Bump build version (exp) + uses: ./.github/workflows/update-latest-build-version.yml + permissions: + contents: write + id-token: write + with: + base-branch: ${{ github.ref_name }} + secrets: inherit + + bump-version-rc: + name: Bump build version (rc) + needs: [bump-version-exp] + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + commit-hash: ${{ steps.bump.outputs.commit-hash }} + build-version: ${{ steps.bump.outputs.build-version }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.bump-version-exp.outputs.commit-hash }} + fetch-depth: 0 + token: ${{ secrets.PR_TOKEN || github.token }} + + - name: Increment version for RC build + id: bump + env: + EXP_VERSION: ${{ needs.bump-version-exp.outputs.build-version }} + HEAD_REF: ${{ github.ref_name }} + run: | + RC_VERSION=$((EXP_VERSION + 1)) + echo "Exp version: $EXP_VERSION → RC version: $RC_VERSION" + ./scripts/set-build-version.sh "$RC_VERSION" + git config user.name metamaskbot + git config user.email metamaskbot@users.noreply.github.com + git add bitrise.yml package.json ios/MetaMask.xcodeproj/project.pbxproj android/app/build.gradle + git commit -m "[skip ci] Bump version number to ${RC_VERSION} (nightly rc)" + git push origin HEAD:"$HEAD_REF" --force-with-lease + echo "commit-hash=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "build-version=$RC_VERSION" >> "$GITHUB_OUTPUT" + build-exp: name: Nightly exp build (main-exp) + needs: [bump-version-exp] uses: ./.github/workflows/build.yml with: build_name: main-exp platform: both skip_version_bump: true + ref: ${{ needs.bump-version-exp.outputs.commit-hash }} secrets: inherit build-rc: name: Nightly RC build (main-rc) + needs: [bump-version-rc] uses: ./.github/workflows/build.yml with: build_name: main-rc platform: both skip_version_bump: true + ref: ${{ needs.bump-version-rc.outputs.commit-hash }} secrets: inherit + + upload-exp-testflight: + name: Upload exp to TestFlight + needs: [build-exp] + runs-on: ghcr.io/cirruslabs/macos-runner:sequoia-xl + environment: apple + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Ruby (iOS) + uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb #v1 + with: + ruby-version: '3.2.9' + working-directory: ios + bundler-cache: true + + - name: Download iOS build artifact + uses: actions/download-artifact@v4 + with: + name: ios-main-exp + + - name: Find IPA path + id: ipa + run: | + IPA=$(find . -name '*.ipa' -type f | head -1) + if [ -z "$IPA" ]; then + echo "::error::No .ipa file found in artifact" + exit 1 + fi + case "$IPA" in /*) ABS="$IPA" ;; *) ABS="$PWD/$IPA" ;; esac + echo "path=$ABS" >> "$GITHUB_OUTPUT" + + - name: Setup App Store Connect API Key + run: | + bash scripts/setup-app-store-connect-api-key.sh \ + "$APP_STORE_CONNECT_API_KEY_ISSUER_ID" \ + "$APP_STORE_CONNECT_API_KEY_KEY_ID" \ + "$APP_STORE_CONNECT_API_KEY_KEY_CONTENT" + env: + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} + APP_STORE_CONNECT_API_KEY_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_CONTENT }} + + - name: Upload to TestFlight + run: | + bash scripts/upload-to-testflight.sh \ + "github_actions_main-exp" \ + "${{ github.ref_name }}" \ + "${{ steps.ipa.outputs.path }}" \ + "MetaMask BETA & Release Candidates" \ + "false" + + - name: Cleanup API Key + if: always() + run: | + rm -f ios/AuthKey.p8 + echo "🧹 Cleaned up API key file" + + upload-rc-testflight: + name: Upload RC to TestFlight + needs: [build-rc] + runs-on: ghcr.io/cirruslabs/macos-runner:sequoia-xl + environment: apple + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Ruby (iOS) + uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb #v1 + with: + ruby-version: '3.2.9' + working-directory: ios + bundler-cache: true + + - name: Download iOS build artifact + uses: actions/download-artifact@v4 + with: + name: ios-main-rc + + - name: Find IPA path + id: ipa + run: | + IPA=$(find . -name '*.ipa' -type f | head -1) + if [ -z "$IPA" ]; then + echo "::error::No .ipa file found in artifact" + exit 1 + fi + case "$IPA" in /*) ABS="$IPA" ;; *) ABS="$PWD/$IPA" ;; esac + echo "path=$ABS" >> "$GITHUB_OUTPUT" + + - name: Setup App Store Connect API Key + run: | + bash scripts/setup-app-store-connect-api-key.sh \ + "$APP_STORE_CONNECT_API_KEY_ISSUER_ID" \ + "$APP_STORE_CONNECT_API_KEY_KEY_ID" \ + "$APP_STORE_CONNECT_API_KEY_KEY_CONTENT" + env: + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} + APP_STORE_CONNECT_API_KEY_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_CONTENT }} + + - name: Upload to TestFlight + run: | + # Group arg is required as positional placeholder for the 5th arg (distribute_external=false). + # With distribute_external=false the build is uploaded but NOT distributed to external testers. + bash scripts/upload-to-testflight.sh \ + "github_actions_main-rc" \ + "${{ github.ref_name }}" \ + "${{ steps.ipa.outputs.path }}" \ + "MetaMask BETA & Release Candidates" \ + "false" + + - name: Cleanup API Key + if: always() + run: | + rm -f ios/AuthKey.p8 + echo "🧹 Cleaned up API key file" diff --git a/.github/workflows/post-merge-validation.yml b/.github/workflows/post-merge-validation.yml index 31f1ef1f81d..6c146cfc008 100644 --- a/.github/workflows/post-merge-validation.yml +++ b/.github/workflows/post-merge-validation.yml @@ -4,6 +4,11 @@ on: schedule: - cron: '0 7 * * *' workflow_dispatch: + inputs: + lookback-days: + description: Number of days to look back for PRs + required: false + default: '1' jobs: post-merge-validation-tracker: @@ -14,5 +19,6 @@ jobs: with: repo: ${{ github.repository }} start-hour-utc: '7' + lookback-days: ${{ inputs.lookback-days || '1' }} github-token: ${{ secrets.GITHUB_TOKEN }} google-application-creds-base64: ${{ secrets.GCP_RLS_SHEET_ACCOUNT_BASE64 }} diff --git a/.github/workflows/push-eas-update.yml b/.github/workflows/push-eas-update.yml index 17126d1a23a..426db1b1615 100644 --- a/.github/workflows/push-eas-update.yml +++ b/.github/workflows/push-eas-update.yml @@ -3,12 +3,8 @@ name: Push OTA Update on: workflow_dispatch: inputs: - commit_hash: - description: 'Commit hash to publish' - required: true - type: string pr_number: - description: 'PR number associated with the commit' + description: 'PR number to publish (uses PR branch HEAD commit)' required: true type: string base_branch: @@ -43,7 +39,6 @@ permissions: id-token: write env: - TARGET_COMMIT_HASH: ${{ inputs.commit_hash }} TARGET_PR_NUMBER: ${{ inputs.pr_number }} BASE_BRANCH_REF: ${{ inputs.base_branch }} UPDATE_MESSAGE: ${{ inputs.message }} @@ -51,8 +46,30 @@ env: OTA_PUSH_PLATFORM: ${{ inputs.platform }} jobs: + validate-pr: + name: Resolve PR HEAD commit + runs-on: ubuntu-latest + outputs: + pr_number: ${{ steps.resolve-pr.outputs.pr_number }} + commit_sha: ${{ steps.resolve-pr.outputs.commit_sha }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve PR HEAD commit + id: resolve-pr + env: + PR_NUMBER: ${{ env.TARGET_PR_NUMBER }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + .github/scripts/validate-pr-commit.sh + ota-summary: name: OTA Update Summary + needs: validate-pr runs-on: ubuntu-latest steps: - name: Display OTA Summary @@ -65,7 +82,7 @@ jobs: echo "| **Update version** | ${UPDATE_MESSAGE} |" echo "| **Target version** | ${BASE_BRANCH_REF} |" echo "| **Environment** | ${TARGET_CHANNEL} |" - echo "| **Target commit** | ${TARGET_COMMIT_HASH} |" + echo "| **Target commit** | ${{ needs.validate-pr.outputs.commit_sha }} |" } >> "$GITHUB_STEP_SUMMARY" setup-dependencies: @@ -74,7 +91,7 @@ jobs: - validate-pr uses: ./.github/workflows/setup-node-modules.yml with: - ref: ${{ inputs.commit_hash }} + ref: ${{ needs.validate-pr.outputs.commit_sha }} fetch-depth: 0 upload-artifact: true artifact-name: node-modules-eas-update-pr @@ -95,6 +112,7 @@ jobs: fingerprint-comparison: name: Compare Expo Fingerprints needs: + - validate-pr - setup-dependencies - setup-dependencies-base runs-on: ubuntu-latest @@ -109,7 +127,7 @@ jobs: - name: Checkout target commit uses: actions/checkout@v4 with: - ref: ${{ env.TARGET_COMMIT_HASH }} + ref: ${{ needs.validate-pr.outputs.commit_sha }} fetch-depth: 0 - name: Checkout base branch snapshot @@ -214,7 +232,7 @@ jobs: BRANCH_FP: ${{ steps.branch_fingerprint.outputs.fingerprint }} MAIN_FP: ${{ steps.main_fingerprint.outputs.fingerprint }} MATCHES: ${{ steps.compare.outputs.equal }} - TARGET_COMMIT_HASH: ${{ env.TARGET_COMMIT_HASH }} + TARGET_COMMIT_HASH: ${{ needs.validate-pr.outputs.commit_sha }} BASE_BRANCH_REF: ${{ env.BASE_BRANCH_REF }} run: | { @@ -225,28 +243,6 @@ jobs: echo "- Match: \`$MATCHES\`" } >> "$GITHUB_STEP_SUMMARY" - validate-pr: - name: Validate PR and Commit - runs-on: ubuntu-latest - outputs: - pr_number: ${{ steps.validate-pr.outputs.pr_number }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Validate PR contains commit - id: validate-pr - env: - COMMIT_HASH: ${{ env.TARGET_COMMIT_HASH }} - PR_NUMBER: ${{ env.TARGET_PR_NUMBER }} - BASE_BRANCH: ${{ env.BASE_BRANCH_REF }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: | - .github/scripts/validate-pr-commit.sh - approval: name: Require OTA Update Approval needs: @@ -275,12 +271,15 @@ jobs: needs.fingerprint-comparison.outputs.fingerprints_equal == 'true' && needs.approval.result == 'success' env: + TARGET_COMMIT_HASH: ${{ needs.validate-pr.outputs.commit_sha }} ARTIFACT_NAME: ${{ needs.setup-dependencies.outputs.artifact-name }} EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} EXPO_PROJECT_ID: ${{ secrets.EXPO_PROJECT_ID }} EXPO_CHANNEL: ${{ vars.EXPO_CHANNEL }} GIT_BRANCH: ${{ github.ref_name }} - RAMP_INTERNAL_BUILD: 'true' + RAMP_DEV_BUILD: ${{ secrets.RAMP_DEV_BUILD || 'false' }} + RAMP_INTERNAL_BUILD: ${{ secrets.RAMP_INTERNAL_BUILD || 'false' }} + RAMPS_ENVIRONMENT: ${{ secrets.RAMPS_ENVIRONMENT || 'production' }} MM_MUSD_CONVERSION_FLOW_ENABLED: 'false' MM_NETWORK_UI_REDESIGN_ENABLED: 'false' MM_NOTIFICATIONS_UI_ENABLED: 'true' diff --git a/.github/workflows/qa-stats.yml b/.github/workflows/qa-stats.yml index 7eeb9b21dc1..4d8051e7b1c 100644 --- a/.github/workflows/qa-stats.yml +++ b/.github/workflows/qa-stats.yml @@ -1,19 +1,13 @@ name: QA Stats on: - workflow_run: - workflows: - - ci - types: - - completed - branches: - - main + schedule: + - cron: '0 4 * * *' jobs: collect-qa-stats: name: Collect QA Stats runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_repository.full_name == github.repository }} steps: - uses: actions/checkout@v6 @@ -26,7 +20,7 @@ jobs: run: node .github/scripts/collect-qa-stats.mjs env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }} + GITHUB_REPOSITORY: ${{ github.repository }} - name: Upload QA stats artifact uses: actions/upload-artifact@v6 diff --git a/.github/workflows/run-e2e-smoke-tests-android-flask.yml b/.github/workflows/run-e2e-smoke-tests-android-flask.yml index 68c68cdb5d4..47925476bf7 100644 --- a/.github/workflows/run-e2e-smoke-tests-android-flask.yml +++ b/.github/workflows/run-e2e-smoke-tests-android-flask.yml @@ -205,3 +205,4 @@ jobs: with: name: e2e-smoke-android-flask-all-test-artifacts path: all-test-artifacts/ + retention-days: 7 diff --git a/.github/workflows/run-e2e-smoke-tests-android.yml b/.github/workflows/run-e2e-smoke-tests-android.yml index 8a21808c8ee..4ff58cfb717 100644 --- a/.github/workflows/run-e2e-smoke-tests-android.yml +++ b/.github/workflows/run-e2e-smoke-tests-android.yml @@ -259,6 +259,7 @@ jobs: with: name: e2e-smoke-android-all-test-artifacts path: all-test-artifacts/ + retention-days: 7 - name: Create mobile JSON test report id: create-json-report @@ -290,3 +291,4 @@ jobs: with: name: test-e2e-android-json-report path: test/test-results/test-runs-android.json + retention-days: 7 diff --git a/.github/workflows/run-e2e-smoke-tests-ios-flask.yml b/.github/workflows/run-e2e-smoke-tests-ios-flask.yml index 269371640a7..878475da903 100644 --- a/.github/workflows/run-e2e-smoke-tests-ios-flask.yml +++ b/.github/workflows/run-e2e-smoke-tests-ios-flask.yml @@ -181,3 +181,4 @@ jobs: with: name: e2e-smoke-ios-flask-all-test-artifacts path: all-test-artifacts/ + retention-days: 7 diff --git a/.github/workflows/run-e2e-smoke-tests-ios.yml b/.github/workflows/run-e2e-smoke-tests-ios.yml index f9fba4f7be1..b4f03e15b70 100644 --- a/.github/workflows/run-e2e-smoke-tests-ios.yml +++ b/.github/workflows/run-e2e-smoke-tests-ios.yml @@ -283,6 +283,7 @@ jobs: with: name: e2e-smoke-ios-all-test-artifacts path: all-test-artifacts/ + retention-days: 7 - name: Create mobile JSON test report id: create-json-report @@ -314,3 +315,4 @@ jobs: with: name: test-e2e-ios-json-report path: test/test-results/test-runs-ios.json + retention-days: 7 diff --git a/.github/workflows/upload-to-testflight.yml b/.github/workflows/upload-to-testflight.yml index 85957973d20..a6fa8fab90d 100644 --- a/.github/workflows/upload-to-testflight.yml +++ b/.github/workflows/upload-to-testflight.yml @@ -6,6 +6,11 @@ name: Upload to TestFlight on: workflow_dispatch: inputs: + source_branch: + description: 'Branch, tag, or SHA to build' + required: true + type: string + default: 'main' environment: description: 'Build environment / track' required: true @@ -38,6 +43,7 @@ jobs: build_name: main-${{ inputs.environment || 'rc' }} platform: ios skip_version_bump: false + source_branch: ${{ inputs.source_branch }} secrets: inherit testflight-upload-summary: @@ -48,6 +54,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ inputs.source_branch }} - name: Display TestFlight upload summary run: | BUILD_VERSION=$(node -p "require('./package.json').version") @@ -56,18 +63,19 @@ jobs: echo "" echo "| Field | Value |" echo "| --- | --- |" + echo "| **Workflow ref** | ${{ github.ref_name }} (required for AWS) |" + echo "| **Source branch** | ${{ inputs.source_branch }} |" echo "| **Build name** | main-${{ inputs.environment || 'rc' }} |" echo "| **Build version** | ${BUILD_VERSION} |" echo "| **TestFlight group** | ${{ inputs.testflight_group || 'MetaMask BETA & Release Candidates' }} |" - echo "| **Branch** | ${{ github.ref_name }} |" } >> "$GITHUB_STEP_SUMMARY" - # Uses GitHub Environment "apple" for App Store Connect API secrets. + # Pulls App Store Connect API keys from AWS Secrets Manager (OIDC). + # Workflow must run from main; build uses inputs.source_branch. upload-ios-testflight: name: Upload iOS to TestFlight needs: [build, testflight-upload-summary] runs-on: ghcr.io/cirruslabs/macos-runner:sequoia-xl - environment: apple steps: - name: Checkout repository uses: actions/checkout@v4 @@ -97,22 +105,63 @@ jobs: case "$IPA" in /*) ABS="$IPA" ;; *) ABS="$PWD/$IPA" ;; esac echo "path=$ABS" >> "$GITHUB_OUTPUT" + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_APPLE_TESTFLIGHT }} + aws-region: 'us-east-2' + + - name: Fetch Apple API keys from AWS Secrets Manager + run: | + echo "🔐 Fetching App Store Connect API keys from Secrets Manager..." + secret_id="metamask-mobile-main-apple-api-keys" + secret_json=$(aws secretsmanager get-secret-value \ + --region 'us-east-2' \ + --secret-id "$secret_id" \ + --query SecretString \ + --output text) + + for key in APP_STORE_CONNECT_API_KEY_ISSUER_ID APP_STORE_CONNECT_API_KEY_KEY_ID; do + value=$(echo "$secret_json" | jq -r --arg k "$key" '.[$k] // empty') + if [ -z "$value" ]; then + echo "::error::Missing key in secret: $key" + exit 1 + fi + echo "::add-mask::$value" + echo "${key}=${value}" >> "$GITHUB_ENV" + done + + key=APP_STORE_CONNECT_API_KEY_KEY_CONTENT + value=$(echo "$secret_json" | jq -r --arg k "$key" '.[$k] // empty') + if [ -z "$value" ]; then + echo "::error::Missing key in secret: $key" + exit 1 + fi + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] && echo "::add-mask::$line" + done <<< "$(printf '%s\n' "$value")" + + delim="APPLEP8$(openssl rand -hex 16)" + { + printf '%s<<%s\n' "$key" "$delim" + printf '%s\n' "$value" + printf '%s\n' "$delim" + } >> "$GITHUB_ENV" + + echo "✅ Apple API keys loaded from AWS" + - name: Setup App Store Connect API Key run: | bash scripts/setup-app-store-connect-api-key.sh \ "$APP_STORE_CONNECT_API_KEY_ISSUER_ID" \ "$APP_STORE_CONNECT_API_KEY_KEY_ID" \ "$APP_STORE_CONNECT_API_KEY_KEY_CONTENT" - env: - APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} - APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} - APP_STORE_CONNECT_API_KEY_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_CONTENT }} - name: Upload to TestFlight run: | bash scripts/upload-to-testflight.sh \ "github_actions_main-${{ inputs.environment || 'rc' }}" \ - "${{ github.ref_name }}" \ + "${{ inputs.source_branch }}" \ "${{ steps.ipa.outputs.path }}" \ "${{ inputs.testflight_group || 'MetaMask BETA & Release Candidates' }}" diff --git a/.gitignore b/.gitignore index 1c5d326b37f..24a1b06d348 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# typescript incremental build cache +.tsbuildinfo + # osx .DS_Store # don't save asdf tools-version config as nvm is prioritized. @@ -182,3 +185,8 @@ temp/ tests/coverage-systems/ runway-artifacts/ + +# E2E AI Analyzer output files +release-test-plan.json +release-delta.json +release-signoffs.json diff --git a/.js.env.example b/.js.env.example index 4b384bcdcd6..36407f4771d 100644 --- a/.js.env.example +++ b/.js.env.example @@ -208,4 +208,7 @@ export MM_PREDICT_ENABLED="true" export MM_CARD_BAANX_API_CLIENT_KEY_DEV="" ## PNA25 (Privacy Notice) -export MM_EXTENSION_UX_PNA25="" \ No newline at end of file +export MM_EXTENSION_UX_PNA25="" + +## Metro +export METRO_RESET_CACHE="true" diff --git a/.yarn/patches/@metamask-assets-controllers-npm-100.2.1-4f7a0c8320.patch b/.yarn/patches/@metamask-assets-controllers-npm-100.2.1-4f7a0c8320.patch deleted file mode 100644 index b4f5cc118e8..00000000000 --- a/.yarn/patches/@metamask-assets-controllers-npm-100.2.1-4f7a0c8320.patch +++ /dev/null @@ -1,48 +0,0 @@ -diff --git a/dist/TokenBalancesController.cjs b/dist/TokenBalancesController.cjs -index 8df3058aea9bd28aaabe1d09f8c3b38fa5949600..39468505fed9c6776a193917fed0cea73de65c23 100644 ---- a/dist/TokenBalancesController.cjs -+++ b/dist/TokenBalancesController.cjs -@@ -805,18 +805,30 @@ async function _TokenBalancesController_importUntrackedTokens(balances) { - if (!changes.length) { - return; - } -- const chainConfigs = {}; -- for (const [chainId, status] of changes) { -- const hexChainId = (0, exports.caipChainIdToHex)(chainId); -- chainConfigs[hexChainId] = -- status === 'down' -- ? { interval: __classPrivateFieldGet(this, _TokenBalancesController_defaultInterval, "f") } -- : { interval: __classPrivateFieldGet(this, _TokenBalancesController_websocketActivePollingInterval, "f") }; -- } -- const jitterDelay = Math.random() * __classPrivateFieldGet(this, _TokenBalancesController_defaultInterval, "f"); -- setTimeout(() => { -- this.updateChainPollingConfigs(chainConfigs, { immediateUpdate: true }); -- }, jitterDelay); -+ try { -+ const chainConfigs = {}; -+ for (const [chainId, status] of changes) { -+ if ((0, utils_1.isCaipChainId)(chainId) && -+ (0, utils_1.parseCaipChainId)(chainId).namespace !== 'eip155') { -+ continue; -+ } -+ const hexChainId = (0, exports.caipChainIdToHex)(chainId); -+ chainConfigs[hexChainId] = -+ status === 'down' -+ ? { interval: __classPrivateFieldGet(this, _TokenBalancesController_defaultInterval, "f") } -+ : { interval: __classPrivateFieldGet(this, _TokenBalancesController_websocketActivePollingInterval, "f") }; -+ } -+ if (Object.keys(chainConfigs).length === 0) { -+ return; -+ } -+ const jitterDelay = Math.random() * __classPrivateFieldGet(this, _TokenBalancesController_defaultInterval, "f"); -+ setTimeout(() => { -+ this.updateChainPollingConfigs(chainConfigs, { immediateUpdate: true }); -+ }, jitterDelay); -+ } -+ catch (error) { -+ console.warn('Error processing accumulated status changes:', error); -+ } - }; - exports.default = TokenBalancesController; - //# sourceMappingURL=TokenBalancesController.cjs.map -\ No newline at end of file diff --git a/.yarn/patches/@metamask-bridge-controller-npm-66.2.0-8ed148964f.patch b/.yarn/patches/@metamask-bridge-controller-npm-66.2.0-8ed148964f.patch deleted file mode 100644 index 81600648e17..00000000000 --- a/.yarn/patches/@metamask-bridge-controller-npm-66.2.0-8ed148964f.patch +++ /dev/null @@ -1,21 +0,0 @@ -diff --git a/dist/utils/metrics/types.d.cts b/dist/utils/metrics/types.d.cts -index 7710230d39c86041c7e0fecd7caa3e27a8130b16..9e1e6978d432b9703aaafdd5b8f238cd5f302d2c 100644 ---- a/dist/utils/metrics/types.d.cts -+++ b/dist/utils/metrics/types.d.cts -@@ -151,6 +151,7 @@ type RequiredEventContextFromClientBase = { - export type RequiredEventContextFromClient = { - [K in keyof RequiredEventContextFromClientBase]: RequiredEventContextFromClientBase[K] & { - location?: MetaMetricsSwapsEventSource; -+ ab_tests?: Record; - }; - }; - /** -@@ -196,6 +197,7 @@ export type EventPropertiesFromControllerState = { - export type CrossChainSwapsEventProperties = { - action_type: MetricsActionType; - location: MetaMetricsSwapsEventSource; -+ ab_tests?: Record; - } | Pick[T] | Pick[T]; - export {}; - //# sourceMappingURL=types.d.cts.map -\ No newline at end of file diff --git a/.yarn/patches/@metamask-bridge-controller-npm-67.2.0-32d3aafe1f.patch b/.yarn/patches/@metamask-bridge-controller-npm-67.2.0-32d3aafe1f.patch deleted file mode 100644 index a44ffb3f131..00000000000 --- a/.yarn/patches/@metamask-bridge-controller-npm-67.2.0-32d3aafe1f.patch +++ /dev/null @@ -1,20 +0,0 @@ -diff --git a/dist/utils/metrics/types.d.cts b/dist/utils/metrics/types.d.cts -index b8f94476d4447cb5895a0f39e7beb1eb5e3b4e16..90cbc588e9ebbc84a761bbeaf3770572aad4d6f8 100644 ---- a/dist/utils/metrics/types.d.cts -+++ b/dist/utils/metrics/types.d.cts -@@ -153,6 +153,7 @@ type RequiredEventContextFromClientBase = { - export type RequiredEventContextFromClient = { - [K in keyof RequiredEventContextFromClientBase]: RequiredEventContextFromClientBase[K] & { - location?: MetaMetricsSwapsEventSource; -+ ab_tests?: Record; - }; - }; - /** -@@ -198,6 +199,7 @@ export type EventPropertiesFromControllerState = { - export type CrossChainSwapsEventProperties = { - action_type: MetricsActionType; - location: MetaMetricsSwapsEventSource; -+ ab_tests?: Record; - } | Pick[T] | Pick[T]; - export {}; - //# sourceMappingURL=types.d.cts.map diff --git a/.yarn/patches/@metamask-bridge-status-controller-npm-66.1.0-a2a478ef94.patch b/.yarn/patches/@metamask-bridge-status-controller-npm-66.1.0-a2a478ef94.patch deleted file mode 100644 index 7428514b798..00000000000 --- a/.yarn/patches/@metamask-bridge-status-controller-npm-66.1.0-a2a478ef94.patch +++ /dev/null @@ -1,182 +0,0 @@ -diff --git a/dist/bridge-status-controller.cjs b/dist/bridge-status-controller.cjs -index 45003640c463ecf21f4a6ddd57d3d4244d46be94..28bb117e186c4a81bbd63966c2547b0c590f9b1f 100644 ---- a/dist/bridge-status-controller.cjs -+++ b/dist/bridge-status-controller.cjs -@@ -207,7 +207,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - }); - }); - _BridgeStatusController_addTxToHistory.set(this, (startPollingForBridgeTxStatusArgs, actionId) => { -- const { bridgeTxMeta, statusRequest, quoteResponse, startTime, slippagePercentage, initialDestAssetBalance, targetContractAddress, approvalTxId, isStxEnabled, location, accountAddress: selectedAddress, } = startPollingForBridgeTxStatusArgs; -+ const { bridgeTxMeta, statusRequest, quoteResponse, startTime, slippagePercentage, initialDestAssetBalance, targetContractAddress, approvalTxId, isStxEnabled, location, abTests, accountAddress: selectedAddress, } = startPollingForBridgeTxStatusArgs; - // Determine the key for this history item: - // - For pre-submission (non-batch EVM): use actionId - // - For post-submission or other cases: use bridgeTxMeta.id -@@ -248,6 +248,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - isStxEnabled: isStxEnabled ?? false, - featureId: quoteResponse.featureId, - location, -+ ...(abTests && { abTests }), - }; - this.update((state) => { - // Use actionId as key for pre-submission, or txMeta.id for post-submission -@@ -704,9 +705,10 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - * @param isStxEnabledOnClient - Whether smart transactions are enabled on the client, for example the getSmartTransactionsEnabled selector value from the extension - * @param quotesReceivedContext - The context for the QuotesReceived event - * @param location - The entry point from which the user initiated the swap or bridge (e.g. Main View, Token View, Trending Explore) -+ * @param abTests - A/B test context to attribute events to specific experiments - * @returns The transaction meta - */ -- this.submitTx = async (accountAddress, quoteResponse, isStxEnabledOnClient, quotesReceivedContext, location = bridge_controller_1.MetaMetricsSwapsEventSource.MainView) => { -+ this.submitTx = async (accountAddress, quoteResponse, isStxEnabledOnClient, quotesReceivedContext, location = bridge_controller_1.MetaMetricsSwapsEventSource.MainView, abTests) => { - this.messenger.call('BridgeController:stopPollingForQuotes', bridge_controller_1.AbortReason.TransactionSubmitted, - // If trade is submitted before all quotes are loaded, the QuotesReceived event is published - // If the trade has a featureId, it means it was submitted outside of the Unified Swap and Bridge experience, so no QuotesReceived event is published -@@ -716,7 +718,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - throw new Error('Failed to submit cross-chain swap transaction: undefined multichain account'); - } - const isHardwareAccount = (0, bridge_controller_1.isHardwareWallet)(selectedAccount); -- const preConfirmationProperties = (0, metrics_1.getPreConfirmationPropertiesFromQuote)(quoteResponse, isStxEnabledOnClient, isHardwareAccount, location); -+ const preConfirmationProperties = (0, metrics_1.getPreConfirmationPropertiesFromQuote)(quoteResponse, isStxEnabledOnClient, isHardwareAccount, location, abTests); - // Emit Submitted event after submit button is clicked - !quoteResponse.featureId && - __classPrivateFieldGet(this, _BridgeStatusController_trackUnifiedSwapBridgeEvent, "f").call(this, bridge_controller_1.UnifiedSwapBridgeEventName.Submitted, undefined, preConfirmationProperties); -@@ -838,6 +840,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - startTime, - approvalTxId, - location, -+ abTests, - }, actionId); - // Pass txFee when gasIncluded is true to use the quote's gas fees - // instead of re-estimating (which would fail for max native token swaps) -@@ -878,6 +881,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - startTime, - approvalTxId, - location, -+ abTests, - }); - } - if ((0, bridge_controller_1.isNonEvmChainId)(quoteResponse.quote.srcChainId)) { -@@ -903,15 +907,16 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - * @param params.signature - Hex signature produced by eth_signTypedData_v4 - * @param params.accountAddress - The EOA submitting the order - * @param params.location - The entry point from which the user initiated the swap or bridge -+ * @param params.abTests - A/B test context to attribute events to specific experiments - * @returns A lightweight TransactionMeta-like object for history linking - */ - this.submitIntent = async (params) => { -- const { quoteResponse, signature, accountAddress, location } = params; -+ const { quoteResponse, signature, accountAddress, location, abTests } = params; - this.messenger.call('BridgeController:stopPollingForQuotes', bridge_controller_1.AbortReason.TransactionSubmitted); - // Build pre-confirmation properties for error tracking parity with submitTx - const account = __classPrivateFieldGet(this, _BridgeStatusController_instances, "m", _BridgeStatusController_getMultichainSelectedAccount).call(this, accountAddress); - const isHardwareAccount = Boolean(account) && (0, bridge_controller_1.isHardwareWallet)(account); -- const preConfirmationProperties = (0, metrics_1.getPreConfirmationPropertiesFromQuote)(quoteResponse, false, isHardwareAccount, location); -+ const preConfirmationProperties = (0, metrics_1.getPreConfirmationPropertiesFromQuote)(quoteResponse, false, isHardwareAccount, location, abTests); - try { - const intent = (0, transaction_1.getIntentFromQuote)(quoteResponse); - // If backend provided an approval tx for this intent quote, submit it first (on-chain), -@@ -1007,6 +1012,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - approvalTxId, - startTime, - location, -+ abTests - }); - // Start polling using the orderId key to route to intent manager - __classPrivateFieldGet(this, _BridgeStatusController_startPollingForTxId, "f").call(this, bridgeHistoryKey); -@@ -1033,12 +1039,20 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - * @param eventProperties - The properties for the event - */ - _BridgeStatusController_trackUnifiedSwapBridgeEvent.set(this, (eventName, txMetaId, eventProperties) => { -+ const historyAbTests = txMetaId -+ ? this.state.txHistory?.[txMetaId]?.abTests -+ : undefined; -+ const resolvedAbTests = eventProperties?.ab_tests ?? historyAbTests ?? undefined; - const baseProperties = { - action_type: bridge_controller_1.MetricsActionType.SWAPBRIDGE_V1, - location: eventProperties?.location ?? - (txMetaId ? this.state.txHistory?.[txMetaId]?.location : undefined) ?? - bridge_controller_1.MetaMetricsSwapsEventSource.MainView, - ...(eventProperties ?? {}), -+ ...(resolvedAbTests && -+ Object.keys(resolvedAbTests).length > 0 && { -+ ab_tests: resolvedAbTests, -+ }), - }; - // This will publish events for PERPS dropped tx failures as well - if (!txMetaId) { -diff --git a/dist/bridge-status-controller.d.cts b/dist/bridge-status-controller.d.cts -index a2b3f4c..b5e8d1a 100644 ---- a/dist/bridge-status-controller.d.cts -+++ b/dist/bridge-status-controller.d.cts -@@ -85,9 +85,10 @@ export declare class BridgeStatusController extends BridgeStatusController_base< - * @param isStxEnabledOnClient - Whether smart transactions are enabled on the client, for example the getSmartTransactionsEnabled selector value from the extension - * @param quotesReceivedContext - The context for the QuotesReceived event - * @param location - The entry point from which the user initiated the swap or bridge (e.g. Main View, Token View, Trending Explore) -+ * @param abTests - A/B test context to attribute events to specific experiments - * @returns The transaction meta - */ -- submitTx: (accountAddress: string, quoteResponse: QuoteResponse & QuoteMetadata, isStxEnabledOnClient: boolean, quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], location?: MetaMetricsSwapsEventSource) => Promise>; -+ submitTx: (accountAddress: string, quoteResponse: QuoteResponse & QuoteMetadata, isStxEnabledOnClient: boolean, quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], location?: MetaMetricsSwapsEventSource, abTests?: Record) => Promise>; - /** - * UI-signed intent submission (fast path): the UI generates the EIP-712 signature and calls this with the raw signature. - * Here we submit the order to the intent provider and create a synthetic history entry for UX. -@@ -97,12 +98,14 @@ export declare class BridgeStatusController extends BridgeStatusController_base< - * @param params.signature - Hex signature produced by eth_signTypedData_v4 - * @param params.accountAddress - The EOA submitting the order - * @param params.location - The entry point from which the user initiated the swap or bridge -+ * @param params.abTests - A/B test context to attribute events to specific experiments - * @returns A lightweight TransactionMeta-like object for history linking - */ - submitIntent: (params: { - quoteResponse: QuoteResponse & QuoteMetadata; - signature: string; - accountAddress: string; - location?: MetaMetricsSwapsEventSource; -+ abTests?: Record; - }) => Promise>; - } -diff --git a/dist/types.d.cts b/dist/types.d.cts -index a77021907623ca60681a979c0ef0bfeee5ca3150..0175527dc5c6e7b48bb0dfdf95f43e66cc5c8b2f 100644 ---- a/dist/types.d.cts -+++ b/dist/types.d.cts -@@ -95,6 +95,11 @@ export type BridgeHistoryItem = { - * Used to attribute swaps to specific flows (e.g. Trending Explore). - */ - location?: MetaMetricsSwapsEventSource; -+ /** -+ * A/B test context to attribute swap/bridge events to specific experiments. -+ * Keys are test names, values are variant names (e.g. { token_details_layout: 'treatment' }). -+ */ -+ abTests?: Record; - /** - * Attempts tracking for exponential backoff on failed fetches. - * We track the number of attempts and the last attempt time for each txMetaId that has failed at least once -@@ -160,6 +165,7 @@ export type StartPollingForBridgeTxStatusArgs = { - approvalTxId?: BridgeHistoryItem['approvalTxId']; - isStxEnabled?: BridgeHistoryItem['isStxEnabled']; - location?: BridgeHistoryItem['location']; -+ abTests?: BridgeHistoryItem['abTests']; - accountAddress: string; - }; - /** -diff --git a/dist/utils/metrics.cjs b/dist/utils/metrics.cjs -index 775367bc08c8a46c19a78913903573a295d1f677..51778fae2ab2c5f08eb66ee18667451002f62296 100644 ---- a/dist/utils/metrics.cjs -+++ b/dist/utils/metrics.cjs -@@ -109,7 +109,7 @@ exports.getPriceImpactFromQuote = getPriceImpactFromQuote; - * @param location - The entry point from which the user initiated the swap or bridge (e.g. Main View, Token View, Trending Explore) - * @returns The properties for the pre-confirmation event - */ --const getPreConfirmationPropertiesFromQuote = (quoteResponse, isStxEnabledOnClient, isHardwareAccount, location = bridge_controller_1.MetaMetricsSwapsEventSource.MainView) => { -+const getPreConfirmationPropertiesFromQuote = (quoteResponse, isStxEnabledOnClient, isHardwareAccount, location = bridge_controller_1.MetaMetricsSwapsEventSource.MainView, abTests) => { - const { quote } = quoteResponse; - return { - ...(0, exports.getPriceImpactFromQuote)(quote), -@@ -125,6 +125,7 @@ const getPreConfirmationPropertiesFromQuote = (quoteResponse, isStxEnabledOnClie - action_type: bridge_controller_1.MetricsActionType.SWAPBRIDGE_V1, - custom_slippage: false, // TODO detect whether the user changed the default slippage - location, -+ ...(abTests && Object.keys(abTests).length > 0 && { ab_tests: abTests }), - }; - }; - exports.getPreConfirmationPropertiesFromQuote = getPreConfirmationPropertiesFromQuote; diff --git a/.yarn/patches/@metamask-bridge-status-controller-npm-67.0.1-d8a41d9c02.patch b/.yarn/patches/@metamask-bridge-status-controller-npm-67.0.1-d8a41d9c02.patch deleted file mode 100644 index c2335cc19b0..00000000000 --- a/.yarn/patches/@metamask-bridge-status-controller-npm-67.0.1-d8a41d9c02.patch +++ /dev/null @@ -1,177 +0,0 @@ -diff --git a/dist/bridge-status-controller.cjs b/dist/bridge-status-controller.cjs -index c89f7e4c600ea6e710a532fc6887ed525b80f333..c4c20566a27b77dfa8b88ff56a038ad5344e0efb 100644 ---- a/dist/bridge-status-controller.cjs -+++ b/dist/bridge-status-controller.cjs -@@ -207,7 +207,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - }); - }); - _BridgeStatusController_addTxToHistory.set(this, (startPollingForBridgeTxStatusArgs, actionId) => { -- const { bridgeTxMeta, statusRequest, quoteResponse, startTime, slippagePercentage, initialDestAssetBalance, targetContractAddress, approvalTxId, isStxEnabled, location, accountAddress: selectedAddress, } = startPollingForBridgeTxStatusArgs; -+ const { bridgeTxMeta, statusRequest, quoteResponse, startTime, slippagePercentage, initialDestAssetBalance, targetContractAddress, approvalTxId, isStxEnabled, location, abTests, accountAddress: selectedAddress, } = startPollingForBridgeTxStatusArgs; - // Determine the key for this history item: - // - For pre-submission (non-batch EVM): use actionId - // - For post-submission or other cases: use bridgeTxMeta.id -@@ -248,6 +248,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - isStxEnabled: isStxEnabled ?? false, - featureId: quoteResponse.featureId, - location, -+ ...(abTests && { abTests }), - }; - this.update((state) => { - // Use actionId as key for pre-submission, or txMeta.id for post-submission -@@ -716,8 +717,8 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - * @param location - The entry point from which the user initiated the swap or bridge (e.g. Main View, Token View, Trending Explore) - * @returns The transaction meta - */ -- this.submitTx = async (accountAddress, quoteResponse, isStxEnabledOnClient, quotesReceivedContext, location = bridge_controller_1.MetaMetricsSwapsEventSource.MainView) => { -- this.messenger.call('BridgeController:stopPollingForQuotes', bridge_controller_1.AbortReason.TransactionSubmitted, -+ this.submitTx = async (accountAddress, quoteResponse, isStxEnabledOnClient, quotesReceivedContext, location = bridge_controller_1.MetaMetricsSwapsEventSource.MainView, abTests) => { -+ this.messenger.call('BridgeController:stopPollingForQuotes', bridge_controller_1.AbortReason.TransactionSubmitted, - // If trade is submitted before all quotes are loaded, the QuotesReceived event is published - // If the trade has a featureId, it means it was submitted outside of the Unified Swap and Bridge experience, so no QuotesReceived event is published - quoteResponse.featureId ? undefined : quotesReceivedContext); -@@ -726,7 +727,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - throw new Error('Failed to submit cross-chain swap transaction: undefined multichain account'); - } - const isHardwareAccount = (0, bridge_controller_1.isHardwareWallet)(selectedAccount); -- const preConfirmationProperties = (0, metrics_1.getPreConfirmationPropertiesFromQuote)(quoteResponse, isStxEnabledOnClient, isHardwareAccount, location); -+ const preConfirmationProperties = (0, metrics_1.getPreConfirmationPropertiesFromQuote)(quoteResponse, isStxEnabledOnClient, isHardwareAccount, location, abTests); - // Emit Submitted event after submit button is clicked - !quoteResponse.featureId && - __classPrivateFieldGet(this, _BridgeStatusController_trackUnifiedSwapBridgeEvent, "f").call(this, bridge_controller_1.UnifiedSwapBridgeEventName.Submitted, undefined, preConfirmationProperties); -@@ -848,6 +849,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - startTime, - approvalTxId, - location, -+ abTests, - }, actionId); - // Pass txFee when gasIncluded is true to use the quote's gas fees - // instead of re-estimating (which would fail for max native token swaps) -@@ -888,6 +890,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - startTime, - approvalTxId, - location, -+ abTests, - }); - } - if ((0, bridge_controller_1.isNonEvmChainId)(quoteResponse.quote.srcChainId)) { -@@ -916,12 +919,12 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - * @returns A lightweight TransactionMeta-like object for history linking - */ - this.submitIntent = async (params) => { -- const { quoteResponse, signature, accountAddress, location } = params; -+ const { quoteResponse, signature, accountAddress, location, abTests } = params; - this.messenger.call('BridgeController:stopPollingForQuotes', bridge_controller_1.AbortReason.TransactionSubmitted); - // Build pre-confirmation properties for error tracking parity with submitTx - const account = __classPrivateFieldGet(this, _BridgeStatusController_instances, "m", _BridgeStatusController_getMultichainSelectedAccount).call(this, accountAddress); - const isHardwareAccount = Boolean(account) && (0, bridge_controller_1.isHardwareWallet)(account); -- const preConfirmationProperties = (0, metrics_1.getPreConfirmationPropertiesFromQuote)(quoteResponse, false, isHardwareAccount, location); -+ const preConfirmationProperties = (0, metrics_1.getPreConfirmationPropertiesFromQuote)(quoteResponse, false, isHardwareAccount, location, abTests); - try { - const intent = (0, transaction_1.getIntentFromQuote)(quoteResponse); - // If backend provided an approval tx for this intent quote, submit it first (on-chain), -@@ -932,7 +935,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - // Handle approval silently for better UX in intent flows - const approvalTxMeta = await __classPrivateFieldGet(this, _BridgeStatusController_handleApprovalTx, "f").call(this, isBridgeTx, quoteResponse.quote.srcChainId, quoteResponse.approval && (0, bridge_controller_1.isEvmTxData)(quoteResponse.approval) - ? quoteResponse.approval -- : undefined, quoteResponse.resetApproval, -+ : undefined, quoteResponse.resetApproval, - /* requireApproval */ false); - approvalTxId = approvalTxMeta?.id; - if (approvalTxId) { -@@ -1017,6 +1020,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - approvalTxId, - startTime, - location, -+ abTests, - }); - // Start polling using the orderId key to route to intent manager - __classPrivateFieldGet(this, _BridgeStatusController_startPollingForTxId, "f").call(this, bridgeHistoryKey); -@@ -1043,12 +1047,21 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll - * @param eventProperties - The properties for the event - */ - _BridgeStatusController_trackUnifiedSwapBridgeEvent.set(this, (eventName, txMetaId, eventProperties) => { -+ const historyAbTests = txMetaId -+ ? this.state.txHistory?.[txMetaId]?.abTests -+ : undefined; -+ const resolvedAbTests = eventProperties?.ab_tests ?? historyAbTests ?? undefined; -+ - const baseProperties = { - action_type: bridge_controller_1.MetricsActionType.SWAPBRIDGE_V1, - location: eventProperties?.location ?? - (txMetaId ? this.state.txHistory?.[txMetaId]?.location : undefined) ?? - bridge_controller_1.MetaMetricsSwapsEventSource.MainView, - ...(eventProperties ?? {}), -+ ...(resolvedAbTests && -+ Object.keys(resolvedAbTests).length > 0 && { -+ ab_tests: resolvedAbTests, -+ }), - }; - // This will publish events for PERPS dropped tx failures as well - if (!txMetaId) { -diff --git a/dist/bridge-status-controller.d.cts b/dist/bridge-status-controller.d.cts -index a71266a9c15070d9fd9242148b16ad0e454184e9..f5dc880b383ecef194a44539e6c570fbc0fa7b7a 100644 ---- a/dist/bridge-status-controller.d.cts -+++ b/dist/bridge-status-controller.d.cts -@@ -88,7 +88,7 @@ export declare class BridgeStatusController extends BridgeStatusController_base< - * @param location - The entry point from which the user initiated the swap or bridge (e.g. Main View, Token View, Trending Explore) - * @returns The transaction meta - */ -- submitTx: (accountAddress: string, quoteResponse: QuoteResponse & QuoteMetadata, isStxEnabledOnClient: boolean, quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], location?: MetaMetricsSwapsEventSource) => Promise>; -+ submitTx: (accountAddress: string, quoteResponse: QuoteResponse & QuoteMetadata, isStxEnabledOnClient: boolean, quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], location?: MetaMetricsSwapsEventSource, abTests?: Record) => Promise>; - /** - * UI-signed intent submission (fast path): the UI generates the EIP-712 signature and calls this with the raw signature. - * Here we submit the order to the intent provider and create a synthetic history entry for UX. -@@ -105,6 +105,7 @@ export declare class BridgeStatusController extends BridgeStatusController_base< - signature: string; - accountAddress: string; - location?: MetaMetricsSwapsEventSource; -+ abTests?: Record; - }) => Promise>; - } - export {}; -diff --git a/dist/types.d.cts b/dist/types.d.cts -index d0492cd8b8159bc63121174e6395f53c49aba59b..c5402866e8f13e37e535d1196d697756d2330023 100644 ---- a/dist/types.d.cts -+++ b/dist/types.d.cts -@@ -96,6 +96,11 @@ export type BridgeHistoryItem = { - * Used to attribute swaps to specific flows (e.g. Trending Explore). - */ - location?: MetaMetricsSwapsEventSource; -+ /** -+ * A/B test context to attribute swap/bridge events to specific experiments. -+ * Keys are test names, values are variant names (e.g. { token_details_layout: 'treatment' }). -+ */ -+ abTests?: Record; - /** - * Attempts tracking for exponential backoff on failed fetches. - * We track the number of attempts and the last attempt time for each txMetaId that has failed at least once -@@ -161,6 +166,7 @@ export type StartPollingForBridgeTxStatusArgs = { - approvalTxId?: BridgeHistoryItem['approvalTxId']; - isStxEnabled?: BridgeHistoryItem['isStxEnabled']; - location?: BridgeHistoryItem['location']; -+ abTests?: BridgeHistoryItem['abTests']; - accountAddress: string; - }; - /** -diff --git a/dist/utils/metrics.cjs b/dist/utils/metrics.cjs -index 775367bc08c8a46c19a78913903573a295d1f677..ef00bf331d866e60a23204475f18a017614fdd57 100644 ---- a/dist/utils/metrics.cjs -+++ b/dist/utils/metrics.cjs -@@ -109,7 +109,7 @@ exports.getPriceImpactFromQuote = getPriceImpactFromQuote; - * @param location - The entry point from which the user initiated the swap or bridge (e.g. Main View, Token View, Trending Explore) - * @returns The properties for the pre-confirmation event - */ --const getPreConfirmationPropertiesFromQuote = (quoteResponse, isStxEnabledOnClient, isHardwareAccount, location = bridge_controller_1.MetaMetricsSwapsEventSource.MainView) => { -+const getPreConfirmationPropertiesFromQuote = (quoteResponse, isStxEnabledOnClient, isHardwareAccount, location = bridge_controller_1.MetaMetricsSwapsEventSource.MainView, abTests) => { - const { quote } = quoteResponse; - return { - ...(0, exports.getPriceImpactFromQuote)(quote), -@@ -125,6 +125,7 @@ const getPreConfirmationPropertiesFromQuote = (quoteResponse, isStxEnabledOnClie - action_type: bridge_controller_1.MetricsActionType.SWAPBRIDGE_V1, - custom_slippage: false, // TODO detect whether the user changed the default slippage - location, -+ ...(abTests && Object.keys(abTests).length > 0 && { ab_tests: abTests }), - }; - }; - exports.getPreConfirmationPropertiesFromQuote = getPreConfirmationPropertiesFromQuote; diff --git a/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch b/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch new file mode 100644 index 00000000000..d841b6a6b85 --- /dev/null +++ b/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch @@ -0,0 +1,38 @@ +diff --git a/dist/bridge-status-controller.cjs b/dist/bridge-status-controller.cjs +index b787174f2c8c448ed1ad9c8884204c5c8b6858be..af058623871badb4891564c003693ea19d0aa676 100644 +--- a/dist/bridge-status-controller.cjs ++++ b/dist/bridge-status-controller.cjs +@@ -834,7 +834,13 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = (0, transaction_1.generateActionId)().toString(); + // Add pre-submission history keyed by actionId +diff --git a/dist/bridge-status-controller.mjs b/dist/bridge-status-controller.mjs +index 2fe71bdd2caf4d62f7946e9466b31367d360cd7c..fb9c0bc45abf88873452667b85ef2ea0cdfd929c 100644 +--- a/dist/bridge-status-controller.mjs ++++ b/dist/bridge-status-controller.mjs +@@ -831,7 +831,13 @@ export class BridgeStatusController extends StaticIntervalPollingController() { + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await handleMobileHardwareWalletDelay(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = generateActionId().toString(); + // Add pre-submission history keyed by actionId diff --git a/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch b/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch new file mode 100644 index 00000000000..d308173b6b2 --- /dev/null +++ b/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch @@ -0,0 +1,38 @@ +diff --git a/dist/bridge-status-controller.cjs b/dist/bridge-status-controller.cjs +index ec19aeeecfa32a3cdf955ccc1152829ee4ddfd8f..d9b427f9f0f4b05238d79c731fc81566634a7c25 100644 +--- a/dist/bridge-status-controller.cjs ++++ b/dist/bridge-status-controller.cjs +@@ -855,7 +855,13 @@ + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = (0, transaction_1.generateActionId)().toString(); + // Add pre-submission history keyed by actionId +diff --git a/dist/bridge-status-controller.mjs b/dist/bridge-status-controller.mjs +index a5661d63c35b5ad3526c1804936dc0e189c90c29..86efc019968599662466e643dae7002ebf5f5014 100644 +--- a/dist/bridge-status-controller.mjs ++++ b/dist/bridge-status-controller.mjs +@@ -852,7 +852,13 @@ + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await handleMobileHardwareWalletDelay(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = generateActionId().toString(); + // Add pre-submission history keyed by actionId diff --git a/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch b/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch new file mode 100644 index 00000000000..94024b5585b --- /dev/null +++ b/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch @@ -0,0 +1,50 @@ +diff --git a/ios/WebAuthSession.swift b/ios/WebAuthSession.swift +index 0d8101b01d7c6cd803acf6a359ceaa026993bdd0..c1beeabd962e561bf48392d58c084272247a95cc 100644 +--- a/ios/WebAuthSession.swift ++++ b/ios/WebAuthSession.swift +@@ -20,17 +20,34 @@ final internal class WebAuthSession { + private var presentationContextProvider = PresentationContextProvider() + + init(authUrl: URL, redirectUrl: URL?, options: AuthSessionOptions) { +- self.authSession = ASWebAuthenticationSession( +- url: authUrl, +- callbackURLScheme: redirectUrl?.scheme, +- completionHandler: { callbackUrl, error in +- self.finish(with: [ +- "type": callbackUrl != nil ? "success" : "cancel", +- "url": callbackUrl?.absoluteString, +- "error": error?.localizedDescription +- ]) +- } +- ) ++ let completionHandler: (URL?, Error?) -> Void = { callbackUrl, error in ++ self.finish(with: [ ++ "type": callbackUrl != nil ? "success" : "cancel", ++ "url": callbackUrl?.absoluteString, ++ "error": error?.localizedDescription ++ ]) ++ } ++ ++ // iOS 17.4+/macOS 14.4+ supports HTTPS callbacks with host/path matching ++ if #available(iOS 17.4, macOS 14.4, *), ++ let redirectUrl, ++ redirectUrl.scheme?.lowercased() == "https", ++ let host = redirectUrl.host(percentEncoded: false), ++ !host.isEmpty { ++ let rawPath = redirectUrl.path ++ let path = (rawPath.isEmpty || rawPath == "/") ? "" : rawPath ++ self.authSession = ASWebAuthenticationSession( ++ url: authUrl, ++ callback: .https(host: host, path: path), ++ completionHandler: completionHandler ++ ) ++ } else { ++ self.authSession = ASWebAuthenticationSession( ++ url: authUrl, ++ callbackURLScheme: redirectUrl?.scheme, ++ completionHandler: completionHandler ++ ) ++ } + self.authSession?.prefersEphemeralWebBrowserSession = options.preferEphemeralSession + } + diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a67c3c648..54895ba1644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.71.0] + +### Added + +- Added backend-provided intent typedData for signing intent swap txs (#25913) +- Added Security & Trust section to Token Details page showing risk level, contract security features, buy/sell tax, token distribution, and official links powered by Blockaid (#27073) +- Added a "Withdraw" button to the unstaked TRX banner so users can claim TRX that has completed the lock period (#27076) +- Added handling for aggregated balance on the new home page (#27172) +- Added LD flags to consume price impact threshold (#27196) +- Added Segment event tracking for mUSD Quick Convert flow and enriched generic Transaction\* events for mUSD conversion transactions (#27305) +- Improved bridge/swap quote expiry experience; expired quotes now remain visible inline with a prompt to refresh, replacing a separate modal flow (#27340) +- Added support for ramps providers such as PayPal, Robinhood & Coinbase that use a different checkout browser (#27364) +- Added authentication for transaction submission to sentinel and transaction API (#27410) +- Added skeleton loading indicator to NFT grid items while images are loading (#27413) +- Embedded the metal card checkout flow into the Card onboarding/sign-up flow (#27420) +- Added attention badge on Card button (#27425) +- Added a new tab for users to see their NFTs and fixed NFT flicker on that view (#27437) +- Added press opacity feedback to NFT grid items (#27488) +- Applied a minimum $0.01 threshold for showing the "Claim bonus" CTA for Merkl rewards so that amounts below the threshold show the 3% bonus label instead (#27522) +- Updated Predict withdraw to default to the user’s last used destination token before falling back to the remote preferred token (#27532) +- Enabled campaigns view under feature flag (#27556) +- Redirected buy deeplinks to the new Ramps Buy flow when Ramps Unified V2 is enabled; deprecated cash deposit deeplinks (#27557) +- Restored mUSD claimable bonus claim section on asset overview screen (#27567) +- Added campaign opt-in flow with details and mechanics screens in the Rewards section (#27619) +- Updated Ramp buy flow modal headers and typography to use shared compact header and design system components (#27627) +- Migrated Card authentication to CardController with new `useCardAuth` hook for controller-based auth flow (#27656) +- Extracted Card supported-country check into `selectIsUserInSupportedCardCountry` selector (#27695) +- Updated mUSD aggregated balance row to redirect to the Cash tokens list when the user holds mUSD on any network (#27703) + +### Changed + +- Removed deprecated payment request (#27519) +- Updated earn balance row layout (logo size, badge size, balance/percentage placement) and added privacy mode support for StakingBalance and EarnLendingBalance (#27457) +- Refactored Card onboarding to use the `useRegions` hook instead of Redux `selectedCountry` for region/country data (#27539) +- Adjusted spacing in homepage (#27637) + +### Fixed + +- Fixed a bug where closing the "Token not available" modal left the user in a stuck state instead of navigating back to the token selection screen (#27277) +- Fixed false "Token Not Available" errors during Buy flow when payment methods are still loading after provider change; fixed missing "Token Not Available" modal in home buy flow; fixed crash when navigating back from "Token Not Available" modal in token info buy flow (#27448) +- Fixed token row display on homepage to show price and variation separated by a dot for consistency with token list items (#27449) +- Fixed stop loss banner rendering issue (#27458) +- Fixed Order Details screen displaying excessive decimal places for crypto amounts after ramp purchases (#27469) +- Fixed remove network confirmation header casing to sentence case (#27480) +- Fixed the custom network header trash icon color to match other trash icons in the app (#27481) +- Fixed a bug where the RPC URL field in network details could appear focused after blur and had inconsistent typography between states (#27482) +- Fixed RAMP_INTERNAL_BUILD default for OTA push (#27507) +- Fixed a bug where Perps activity could appear blank after reopening the Activity screen from Perps home (#27509) +- Fixed universal link handling for redirect-oauth (#27511) +- Fixed Network Details so network name is required and no longer labeled optional (#27541) +- Fixed onboarding import button text being invisible in dark mode; ensured both CTAs have proper contrast in dark mode (#27550) +- Removed a stale feature-flag gate so the Networks menu item is always available (#27591) +- Fixed MegaETH explorer button to display "View on Megaeth Explorer" instead of "View on Megaeth" (#27592) +- Fixed padding in security screen header (#27621) +- Fixed TokenList crash when switching networks (#27655) +- Fixed miscategorization of BRENTOIL and other non-crypto instruments appearing in the "Explore Crypto" section on Perps Home (#27699) + ## [7.70.1] ### Fixed @@ -11015,7 +11072,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.70.1...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.71.0...HEAD +[7.71.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.1...v7.71.0 [7.70.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.0...v7.70.1 [7.70.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.1...v7.70.0 [7.69.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.0...v7.69.1 diff --git a/android/app/build.gradle b/android/app/build.gradle index c0b1c20531f..52a78ec36ff 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.70.0" - versionCode 4130 + versionName "7.71.0" + versionCode 4208 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/__mocks__/@metamask/compliance-controller.ts b/app/__mocks__/@metamask/compliance-controller.ts new file mode 100644 index 00000000000..9b8afdbd6ed --- /dev/null +++ b/app/__mocks__/@metamask/compliance-controller.ts @@ -0,0 +1,82 @@ +/** + * Manual mock for @metamask/compliance-controller. + * + * The npm package is published but dist/ artifacts may not yet be available. + * This mock provides the public API surface needed for tests and TypeScript + * compilation within the mobile repo. + */ + +export class ComplianceService { + readonly name = 'ComplianceService'; +} + +export class ComplianceController { + readonly name = 'ComplianceController'; + state: Record; + + constructor(args: Record) { + this.state = (args.state ?? {}) as Record; + } + + async init(): Promise { + // noop + } + + async checkWalletCompliance( + address: string, + ): Promise<{ address: string; blocked: boolean; checkedAt: string }> { + return { address, blocked: false, checkedAt: new Date().toISOString() }; + } + + async checkWalletsCompliance( + addresses: string[], + ): Promise<{ address: string; blocked: boolean; checkedAt: string }[]> { + return addresses.map((a) => ({ + address: a, + blocked: false, + checkedAt: new Date().toISOString(), + })); + } + + async updateBlockedWallets(): Promise<{ + addresses: string[]; + sources: { ofac: number; remote: number }; + lastUpdated: string; + fetchedAt: string; + }> { + return { + addresses: [], + sources: { ofac: 0, remote: 0 }, + lastUpdated: new Date().toISOString(), + fetchedAt: new Date().toISOString(), + }; + } + + clearComplianceState(): void { + // noop + } +} + +export function getDefaultComplianceControllerState() { + return { + walletComplianceStatusMap: {}, + blockedWallets: null, + blockedWalletsLastFetched: 0, + lastCheckedAt: null, + }; +} + +export function selectIsWalletBlocked(address: string) { + return (state: { + blockedWallets?: { addresses: string[] } | null; + walletComplianceStatusMap?: Record< + string, + { blocked: boolean } | undefined + >; + }): boolean => { + if (state.blockedWallets?.addresses.includes(address)) { + return true; + } + return state.walletComplianceStatusMap?.[address]?.blocked ?? false; + }; +} diff --git a/app/__mocks__/@metamask/native-utils.js b/app/__mocks__/@metamask/native-utils.js index 0fb68eb792c..1fec3202d4f 100644 --- a/app/__mocks__/@metamask/native-utils.js +++ b/app/__mocks__/@metamask/native-utils.js @@ -1,5 +1,5 @@ -/* eslint-disable import/no-extraneous-dependencies */ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-extraneous-dependencies */ +/* eslint-disable import-x/no-commonjs */ /** * Mock for @metamask/native-utils * diff --git a/app/__mocks__/mp4Mock.js b/app/__mocks__/mp4Mock.js index 86b87e1d9c2..7677d067ead 100644 --- a/app/__mocks__/mp4Mock.js +++ b/app/__mocks__/mp4Mock.js @@ -1,3 +1,3 @@ // When required, assets in React Native returns a number -// eslint-disable-next-line import/no-commonjs +// eslint-disable-next-line import-x/no-commonjs module.exports = 1; diff --git a/app/__mocks__/pngMock.js b/app/__mocks__/pngMock.js index 86b87e1d9c2..7677d067ead 100644 --- a/app/__mocks__/pngMock.js +++ b/app/__mocks__/pngMock.js @@ -1,3 +1,3 @@ // When required, assets in React Native returns a number -// eslint-disable-next-line import/no-commonjs +// eslint-disable-next-line import-x/no-commonjs module.exports = 1; diff --git a/app/__mocks__/react-native-i18n.ts b/app/__mocks__/react-native-i18n.ts index 95f9fe03dbb..67632872b69 100644 --- a/app/__mocks__/react-native-i18n.ts +++ b/app/__mocks__/react-native-i18n.ts @@ -1,4 +1,4 @@ -// eslint-disable-next-line import/no-extraneous-dependencies +// eslint-disable-next-line import-x/no-extraneous-dependencies import I18nJs from 'i18n-js'; I18nJs.locale = 'en'; // a locale from your available translations diff --git a/app/__mocks__/spinnerMock.js b/app/__mocks__/spinnerMock.js index f5180f503dd..2a703545d15 100644 --- a/app/__mocks__/spinnerMock.js +++ b/app/__mocks__/spinnerMock.js @@ -1,4 +1,4 @@ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ module.exports = { Spinner: () => null, diff --git a/app/actions/navigation/index.ts b/app/actions/navigation/index.ts index b4c82b9a98e..54ec2477824 100644 --- a/app/actions/navigation/index.ts +++ b/app/actions/navigation/index.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { type OnNavigationReadyAction, type SetCurrentRouteAction, diff --git a/app/actions/onboarding/index.test.ts b/app/actions/onboarding/index.test.ts index 2a3cbeae16d..0cc5bdc96a2 100644 --- a/app/actions/onboarding/index.test.ts +++ b/app/actions/onboarding/index.test.ts @@ -46,16 +46,32 @@ describe('Onboarding actions', () => { describe('setAccountType', () => { it('creates an action to set accountType', () => { - expect(setAccountType(AccountType.Metamask)).toEqual({ + const onboardingVersion = '7.0.0 (1234)'; + + expect( + setAccountType({ + accountType: AccountType.Metamask, + onboardingVersion, + }), + ).toEqual({ type: SET_ACCOUNT_TYPE, accountType: AccountType.Metamask, + onboardingVersion, }); }); it('creates an action with social login account type', () => { - expect(setAccountType(AccountType.MetamaskGoogle)).toEqual({ + const onboardingVersion = '7.0.0 (1234)'; + + expect( + setAccountType({ + accountType: AccountType.MetamaskGoogle, + onboardingVersion, + }), + ).toEqual({ type: SET_ACCOUNT_TYPE, accountType: AccountType.MetamaskGoogle, + onboardingVersion, }); }); }); diff --git a/app/actions/onboarding/index.ts b/app/actions/onboarding/index.ts index a2f9cb7a71e..38d47746778 100644 --- a/app/actions/onboarding/index.ts +++ b/app/actions/onboarding/index.ts @@ -24,6 +24,7 @@ export interface SetCompletedOnboardingAction { interface SetAccountTypeAction { type: typeof SET_ACCOUNT_TYPE; accountType: AccountType; + onboardingVersion: string; } interface ClearAccountTypeAction { @@ -61,10 +62,14 @@ export function setCompletedOnboarding( }; } -export function setAccountType(accountType: AccountType): SetAccountTypeAction { +export function setAccountType(params: { + accountType: AccountType; + onboardingVersion: string; +}): SetAccountTypeAction { return { type: SET_ACCOUNT_TYPE, - accountType, + accountType: params.accountType, + onboardingVersion: params.onboardingVersion, }; } diff --git a/app/actions/security/index.ts b/app/actions/security/index.ts index 8d6252df358..27bcfc0da26 100644 --- a/app/actions/security/index.ts +++ b/app/actions/security/index.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import type { Action as ReduxAction } from 'redux'; export enum ActionType { diff --git a/app/component-library/base-components/TagBase/TagBase.constants.tsx b/app/component-library/base-components/TagBase/TagBase.constants.tsx index 364c82dd513..37195707032 100644 --- a/app/component-library/base-components/TagBase/TagBase.constants.tsx +++ b/app/component-library/base-components/TagBase/TagBase.constants.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third library dependencies. import React from 'react'; diff --git a/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.tsx b/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.tsx index 6cb8f3c55eb..a0178b3cdd8 100644 --- a/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.tsx +++ b/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.tsx @@ -47,22 +47,31 @@ const HeaderStandardAnimated: React.FC = ({ const content = title ? ( - - {title} - - {subtitle && ( + {typeof title === 'string' ? ( - {subtitle} + {title} + ) : ( + title + )} + {subtitle && ( + + {typeof subtitle === 'string' ? ( + + {subtitle} + + ) : ( + subtitle + )} + )} ) : null; diff --git a/app/component-library/components-temp/MainActionButton/MainActionButton.constants.ts b/app/component-library/components-temp/MainActionButton/MainActionButton.constants.ts index 56784d277a7..032e5ce60a6 100644 --- a/app/component-library/components-temp/MainActionButton/MainActionButton.constants.ts +++ b/app/component-library/components-temp/MainActionButton/MainActionButton.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Test IDs export const MAINACTIONBUTTON_TEST_ID = 'main-action-button'; diff --git a/app/component-library/components-temp/MainActionButton/MainActionButton.styles.ts b/app/component-library/components-temp/MainActionButton/MainActionButton.styles.ts index da96ca51c5c..c3fad497e10 100644 --- a/app/component-library/components-temp/MainActionButton/MainActionButton.styles.ts +++ b/app/component-library/components-temp/MainActionButton/MainActionButton.styles.ts @@ -34,7 +34,7 @@ const styleSheet = (params: { backgroundColor, borderRadius: 12, paddingHorizontal: 4, - paddingVertical: 16, + paddingVertical: 12, justifyContent: 'center', alignItems: 'center', opacity: isDisabled ? 0.5 : 1, diff --git a/app/component-library/components-temp/MainActionButton/__snapshots__/MainActionButton.test.tsx.snap b/app/component-library/components-temp/MainActionButton/__snapshots__/MainActionButton.test.tsx.snap index d67b7c1d51b..33f3679622b 100644 --- a/app/component-library/components-temp/MainActionButton/__snapshots__/MainActionButton.test.tsx.snap +++ b/app/component-library/components-temp/MainActionButton/__snapshots__/MainActionButton.test.tsx.snap @@ -52,7 +52,7 @@ exports[`MainActionButton should render correctly 1`] = ` "justifyContent": "center", "opacity": 1, "paddingHorizontal": 4, - "paddingVertical": 16, + "paddingVertical": 12, }, false, ] diff --git a/app/component-library/components-temp/Price/AggregatedPercentage/NonEvmAggregatedPercentage.test.tsx b/app/component-library/components-temp/Price/AggregatedPercentage/NonEvmAggregatedPercentage.test.tsx index 9a9f7155262..9ce68779164 100644 --- a/app/component-library/components-temp/Price/AggregatedPercentage/NonEvmAggregatedPercentage.test.tsx +++ b/app/component-library/components-temp/Price/AggregatedPercentage/NonEvmAggregatedPercentage.test.tsx @@ -8,7 +8,7 @@ import { FORMATTED_PERCENTAGE_TEST_ID, } from './AggregatedPercentage.constants'; import NonEvmAggregatedPercentage from './NonEvmAggregatedPercentage'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as multichain from '../../../../selectors/multichain/multichain'; import { selectMultichainAssetsRates } from '../../../../selectors/multichain/multichain'; diff --git a/app/component-library/components-temp/SegmentedControl/SegmentedControl.constants.ts b/app/component-library/components-temp/SegmentedControl/SegmentedControl.constants.ts index a15a4dd1742..908eccfa41c 100644 --- a/app/component-library/components-temp/SegmentedControl/SegmentedControl.constants.ts +++ b/app/component-library/components-temp/SegmentedControl/SegmentedControl.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { ButtonSize } from '../../components/Buttons/Button/Button.types'; diff --git a/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx b/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx index 35efd2dba34..70283d2d1ce 100644 --- a/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx +++ b/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx @@ -1,6 +1,6 @@ // Third party dependencies. import React from 'react'; -import { render, fireEvent, act, waitFor } from '@testing-library/react-native'; +import { render, fireEvent, act } from '@testing-library/react-native'; import { View, InteractionManager } from 'react-native'; // External dependencies. @@ -71,7 +71,7 @@ describe('TabsList', () => { , ); - // Assert - Active tab loads via InteractionManager + // Assert - active tab is loaded immediately expect(getByText('Tokens Content')).toBeOnTheScreen(); // Other tabs should not be loaded yet (on-demand loading) @@ -82,10 +82,7 @@ describe('TabsList', () => { fireEvent.press(getAllByText('NFTs')[0]); }); - // Wait for the deferred loading to complete - await waitFor(() => { - expect(getByText('NFTs Content')).toBeOnTheScreen(); - }); + expect(getByText('NFTs Content')).toBeOnTheScreen(); }); it('switches tab content when tab is pressed', () => { @@ -198,6 +195,68 @@ describe('TabsList', () => { expect(getByText('Tab 2 Content')).toBeOnTheScreen(); }); + it('goToTabIndex loads target tab immediately', async () => { + const ref = React.createRef(); + const tabs = ['Tab 1', 'Tab 2']; + + const { getByText } = render( + + {tabs.map((label, index) => ( + + {label} Content + + ))} + , + ); + + // Act + await act(async () => { + ref.current?.goToTabIndex(1); + }); + + // Assert + expect(getByText('Tab 2 Content')).toBeOnTheScreen(); + }); + + it('renders initial active tab immediately', () => { + // Act + const { getByText } = render( + + + Content 1 + + + Content 2 + + , + ); + + // Assert + expect(getByText('Content 1')).toBeOnTheScreen(); + }); + + it('loads target tab on user press immediately', async () => { + // Arrange + const { getAllByText, getByText } = render( + + + Content 1 + + + Content 2 + + , + ); + + // Act + await act(async () => { + fireEvent.press(getAllByText('Tab 2')[0]); + }); + + // Assert + expect(getByText('Content 2')).toBeOnTheScreen(); + }); + it('exposes getCurrentIndex method via ref', () => { // Arrange const ref = React.createRef(); @@ -454,17 +513,8 @@ describe('TabsList', () => { // even when the tab was temporarily removed and re-added }); - describe('Deferred Content Loading', () => { - it('loads active tab content via InteractionManager', () => { - // Arrange - const mockRunAfterInteractions = jest.fn((callback) => { - callback(); - return { cancel: jest.fn() }; - }); - (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( - mockRunAfterInteractions, - ); - + describe('Content Loading', () => { + it('loads active tab content via InteractionManager scheduling', () => { // Act const { getByText } = render( @@ -477,9 +527,9 @@ describe('TabsList', () => { , ); - // Assert - InteractionManager used for initial tab load - expect(mockRunAfterInteractions).toHaveBeenCalled(); + // Assert expect(getByText('Content 1')).toBeOnTheScreen(); + expect(InteractionManager.runAfterInteractions).toHaveBeenCalled(); }); it('defers loading of inactive tabs until switched to', () => { @@ -499,17 +549,8 @@ describe('TabsList', () => { expect(queryByText('Content 2')).toBeNull(); }); - it('cancels pending content load when switching tabs quickly', async () => { + it('switches quickly while keeping loads scheduled and stable', async () => { // Arrange - const mockCancel = jest.fn(); - let capturedCallback: (() => void) | null = null; - (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( - (callback: () => void) => { - capturedCallback = callback; - return { cancel: mockCancel }; - }, - ); - const { getAllByText } = render( @@ -524,29 +565,20 @@ describe('TabsList', () => { , ); - // Act - Switch tabs quickly before interaction completes + // Act await act(async () => { fireEvent.press(getAllByText('Tab 2')[0]); fireEvent.press(getAllByText('Tab 3')[0]); - if (capturedCallback) { - capturedCallback(); - } }); - // Assert - Previous interaction was cancelled - expect(mockCancel).toHaveBeenCalled(); + // Assert + expect(InteractionManager.runAfterInteractions).toHaveBeenCalled(); }); - it('loads already-loaded tabs immediately without InteractionManager delay', async () => { + it('does not re-schedule loading for already-loaded tabs', async () => { // Arrange - const mockRunAfterInteractions = jest.fn((callback) => { - callback(); - return { cancel: jest.fn() }; - }); - (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( - mockRunAfterInteractions, - ); - + const mockRunAfterInteractions = + InteractionManager.runAfterInteractions as jest.Mock; const { getAllByText, getByText } = render( @@ -563,7 +595,7 @@ describe('TabsList', () => { fireEvent.press(getAllByText('Tab 2')[0]); }); - const callCountAfterFirstSwitch = + const callCountAfterFirstLoad = mockRunAfterInteractions.mock.calls.length; // Act - Switch back to Tab 1 (already loaded) @@ -571,11 +603,144 @@ describe('TabsList', () => { fireEvent.press(getAllByText('Tab 1')[0]); }); - // Assert - Already loaded tab displays immediately without new InteractionManager call + // Assert expect(getByText('Content 1')).toBeOnTheScreen(); expect(mockRunAfterInteractions).toHaveBeenCalledTimes( - callCountAfterFirstSwitch, + callCountAfterFirstLoad, + ); + }); + + it('stale callback from previous tab does not cancel current tab load', async () => { + jest.useFakeTimers(); + + // Capture callbacks so we can fire them manually + let capturedCallbackA: (() => void) | null = null; + + (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( + (cb: () => void) => { + // Capture only the first callback (Tab 1); don't auto-invoke any + if (!capturedCallbackA) capturedCallbackA = cb; + return { cancel: jest.fn() }; + }, ); + + try { + const { getAllByText, getByText, queryByText } = render( + + + Content 1 + + + Content 2 + + , + ); + + // Tab 1 scheduled but not yet loaded (InteractionManager not fired) + expect(queryByText('Content 1')).toBeNull(); + + // Switch to Tab 2 before Tab 1's callback fires + await act(async () => { + fireEvent.press(getAllByText('Tab 2')[0]); + }); + + // Now fire Tab 1's stale callback — this must NOT cancel Tab 2's fallback + await act(async () => { + capturedCallbackA?.(); + }); + + // Tab 2 must still load via its fallback timeout + await act(async () => { + jest.advanceTimersByTime(250); + }); + expect(getByText('Content 2')).toBeOnTheScreen(); + } finally { + jest.useRealTimers(); + } + }); + + it('uses fallback timeout if InteractionManager callback does not run', async () => { + jest.useFakeTimers(); + (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( + () => ({ cancel: jest.fn() }), + ); + + try { + const { getAllByText, getByText, queryByText } = render( + + + Content 1 + + + Content 2 + + , + ); + + expect(queryByText('Content 1')).toBeNull(); + expect(queryByText('Content 2')).toBeNull(); + + await act(async () => { + jest.advanceTimersByTime(250); + }); + expect(getByText('Content 1')).toBeOnTheScreen(); + + await act(async () => { + fireEvent.press(getAllByText('Tab 2')[0]); + }); + expect(queryByText('Content 2')).toBeNull(); + + await act(async () => { + jest.advanceTimersByTime(250); + }); + expect(getByText('Content 2')).toBeOnTheScreen(); + } finally { + jest.useRealTimers(); + } + }); + + it('does not lose scheduled load across a rerender', async () => { + jest.useFakeTimers(); + (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( + () => ({ cancel: jest.fn() }), + ); + + try { + const renderTabs = () => ( + + + Content 1 + + + Content 2 + + + ); + + const { getAllByText, getByText, queryByText, rerender } = + render(renderTabs()); + + expect(queryByText('Content 1')).toBeNull(); + + await act(async () => { + jest.advanceTimersByTime(250); + }); + expect(getByText('Content 1')).toBeOnTheScreen(); + + await act(async () => { + fireEvent.press(getAllByText('Tab 2')[0]); + }); + expect(queryByText('Content 2')).toBeNull(); + + rerender(renderTabs()); + + await act(async () => { + jest.advanceTimersByTime(250); + }); + expect(getByText('Content 2')).toBeOnTheScreen(); + } finally { + jest.useRealTimers(); + } }); }); diff --git a/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx b/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx index 737c3f21783..63de753e4c7 100644 --- a/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx +++ b/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx @@ -16,6 +16,8 @@ import { InteractionManager } from 'react-native'; import TabsBar from '../TabsBar'; import { TabsListProps, TabsListRef, TabItem } from './TabsList.types'; +const TAB_LOAD_FALLBACK_TIMEOUT_MS = 250; + const TabsList = forwardRef( ( { @@ -29,10 +31,6 @@ const TabsList = forwardRef( }, ref, ) => { - const [activeIndex, setActiveIndex] = useState(initialActiveIndex); - const [loadedTabs, setLoadedTabs] = useState>(new Set()); - const interactionHandleRef = useRef<{ cancel: () => void } | null>(null); - const tabs: TabItem[] = useMemo( () => React.Children.toArray(children) @@ -56,40 +54,83 @@ const TabsList = forwardRef( [children], ); - // Cache only the actively viewed tab (no preloading of adjacent tabs) - // Use InteractionManager to defer content loading until after animations complete - useEffect(() => { - if (activeIndex >= 0 && activeIndex < tabs.length) { - if (interactionHandleRef.current) { - interactionHandleRef.current.cancel(); + const normalizeTabIndex = useCallback( + (tabIndex: number) => { + if ( + tabIndex >= 0 && + tabIndex < tabs.length && + !tabs[tabIndex]?.isDisabled + ) { + return tabIndex; } + const firstEnabled = tabs.findIndex((tab) => !tab.isDisabled); + return firstEnabled >= 0 ? firstEnabled : -1; + }, + [tabs], + ); + + const [activeIndex, setActiveIndex] = useState(() => + normalizeTabIndex(initialActiveIndex), + ); + const [loadedTabs, setLoadedTabs] = useState>(new Set()); + const interactionHandleRef = useRef<{ cancel?: () => void } | null>(null); + const fallbackTimeoutRef = useRef | null>( + null, + ); - const isAlreadyLoaded = loadedTabs.has(activeIndex); + // Cancel any pending InteractionManager + fallback timeout + const cancelPendingLoad = useCallback(() => { + if (interactionHandleRef.current) { + interactionHandleRef.current.cancel?.(); + interactionHandleRef.current = null; + } + if (fallbackTimeoutRef.current) { + clearTimeout(fallbackTimeoutRef.current); + fallbackTimeoutRef.current = null; + } + }, []); - if (isAlreadyLoaded) { + // Schedule tab content loading via InteractionManager with a fallback timeout + // in case the InteractionManager callback never fires (observed in repeated + // Perps Home -> Activity navigation) + useEffect(() => { + if (activeIndex >= 0 && activeIndex < tabs.length) { + cancelPendingLoad(); + + if (loadedTabs.has(activeIndex)) { return; } - const handle = InteractionManager.runAfterInteractions(() => { + const markLoaded = () => { setLoadedTabs((prev) => { - const newLoadedTabs = new Set(prev); - newLoadedTabs.add(activeIndex); - return newLoadedTabs.size !== prev.size ? newLoadedTabs : prev; + if (prev.has(activeIndex)) return prev; + const next = new Set(prev); + next.add(activeIndex); + return next; }); - }); + }; + + interactionHandleRef.current = + InteractionManager.runAfterInteractions(markLoaded); - interactionHandleRef.current = handle; + fallbackTimeoutRef.current = setTimeout( + markLoaded, + TAB_LOAD_FALLBACK_TIMEOUT_MS, + ); } return () => { - if (interactionHandleRef.current) { - interactionHandleRef.current.cancel(); - } + cancelPendingLoad(); }; - }, [activeIndex, tabs.length, loadedTabs]); + }, [activeIndex, tabs.length, loadedTabs, cancelPendingLoad]); useEffect(() => { - const currentActiveTabKey = tabs[activeIndex]?.key; + const currentActiveTabKey = + activeIndex >= 0 && activeIndex < tabs.length + ? tabs[activeIndex]?.key + : undefined; + + let nextIndex = -1; if (currentActiveTabKey && tabs.length > 0) { const newIndexForCurrentTab = tabs.findIndex( @@ -97,30 +138,20 @@ const TabsList = forwardRef( ); if ( newIndexForCurrentTab >= 0 && - !tabs[newIndexForCurrentTab].isDisabled && - newIndexForCurrentTab !== activeIndex + !tabs[newIndexForCurrentTab].isDisabled ) { - setActiveIndex(newIndexForCurrentTab); - return; + nextIndex = newIndexForCurrentTab; } } - if ( - activeIndex >= 0 && - activeIndex < tabs.length && - !tabs[activeIndex]?.isDisabled - ) { - return; + if (nextIndex === -1) { + nextIndex = normalizeTabIndex(initialActiveIndex); } - const targetTab = tabs[initialActiveIndex]; - if (targetTab && !targetTab.isDisabled) { - setActiveIndex(initialActiveIndex); - } else { - const firstEnabledIndex = tabs.findIndex((tab) => !tab.isDisabled); - setActiveIndex(firstEnabledIndex >= 0 ? firstEnabledIndex : -1); + if (nextIndex !== activeIndex) { + setActiveIndex(nextIndex); } - }, [initialActiveIndex, tabs, activeIndex]); + }, [activeIndex, initialActiveIndex, normalizeTabIndex, tabs]); const handleTabPress = useCallback( (tabIndex: number) => { @@ -136,13 +167,6 @@ const TabsList = forwardRef( setActiveIndex(tabIndex); - if ( - (process.env.JEST_WORKER_ID || process.env.E2E) && - !loadedTabs.has(tabIndex) - ) { - setLoadedTabs((prev) => new Set(prev).add(tabIndex)); - } - if (onChangeTab && tabChanged) { onChangeTab({ i: tabIndex, @@ -150,7 +174,7 @@ const TabsList = forwardRef( }); } }, - [activeIndex, tabs, onChangeTab, loadedTabs], + [activeIndex, tabs, onChangeTab], ); const goToPreviousTab = useCallback(() => { diff --git a/app/component-library/components-temp/TagColored/TagColored.constants.ts b/app/component-library/components-temp/TagColored/TagColored.constants.ts index f4468459326..e3099810112 100644 --- a/app/component-library/components-temp/TagColored/TagColored.constants.ts +++ b/app/component-library/components-temp/TagColored/TagColored.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies import { TextVariant } from '../../components/Texts/Text'; diff --git a/app/component-library/components/Avatars/Avatar/Avatar.constants.ts b/app/component-library/components/Avatars/Avatar/Avatar.constants.ts index b42b1c27b55..b0b01e357a2 100644 --- a/app/component-library/components/Avatars/Avatar/Avatar.constants.ts +++ b/app/component-library/components/Avatars/Avatar/Avatar.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { IconSize } from '../../Icons/Icon'; import { SAMPLE_AVATARACCOUNT_PROPS } from './variants/AvatarAccount/AvatarAccount.constants'; diff --git a/app/component-library/components/Avatars/Avatar/foundation/AvatarBase/AvatarBase.constants.ts b/app/component-library/components/Avatars/Avatar/foundation/AvatarBase/AvatarBase.constants.ts index 33921324e38..a81553c2d67 100644 --- a/app/component-library/components/Avatars/Avatar/foundation/AvatarBase/AvatarBase.constants.ts +++ b/app/component-library/components/Avatars/Avatar/foundation/AvatarBase/AvatarBase.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third party dependencies. import { ImageSourcePropType } from 'react-native'; diff --git a/app/component-library/components/Avatars/Avatar/variants/AvatarAccount/AvatarAccount.constants.ts b/app/component-library/components/Avatars/Avatar/variants/AvatarAccount/AvatarAccount.constants.ts index 8e4e9739d17..39a186be8a2 100644 --- a/app/component-library/components/Avatars/Avatar/variants/AvatarAccount/AvatarAccount.constants.ts +++ b/app/component-library/components/Avatars/Avatar/variants/AvatarAccount/AvatarAccount.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { AvatarSize } from '../../Avatar.types'; diff --git a/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.constants.ts b/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.constants.ts index 0e6c3c5068a..6607fee14ef 100644 --- a/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.constants.ts +++ b/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third party dependencies. import { ImageSourcePropType } from 'react-native'; diff --git a/app/component-library/components/Avatars/Avatar/variants/AvatarIcon/AvatarIcon.constants.ts b/app/component-library/components/Avatars/Avatar/variants/AvatarIcon/AvatarIcon.constants.ts index 554a030c6eb..5eba8a1be11 100644 --- a/app/component-library/components/Avatars/Avatar/variants/AvatarIcon/AvatarIcon.constants.ts +++ b/app/component-library/components/Avatars/Avatar/variants/AvatarIcon/AvatarIcon.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { mockTheme } from '../../../../../../util/theme'; import { AvatarSize } from '../../Avatar.types'; diff --git a/app/component-library/components/Avatars/Avatar/variants/AvatarNetwork/AvatarNetwork.constants.ts b/app/component-library/components/Avatars/Avatar/variants/AvatarNetwork/AvatarNetwork.constants.ts index 18572d1f561..271a41f7349 100644 --- a/app/component-library/components/Avatars/Avatar/variants/AvatarNetwork/AvatarNetwork.constants.ts +++ b/app/component-library/components/Avatars/Avatar/variants/AvatarNetwork/AvatarNetwork.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third party dependencies. import { ImageSourcePropType, Platform } from 'react-native'; diff --git a/app/component-library/components/Avatars/Avatar/variants/AvatarToken/AvatarToken.constants.ts b/app/component-library/components/Avatars/Avatar/variants/AvatarToken/AvatarToken.constants.ts index 88a16614c98..e04cc8aa67b 100644 --- a/app/component-library/components/Avatars/Avatar/variants/AvatarToken/AvatarToken.constants.ts +++ b/app/component-library/components/Avatars/Avatar/variants/AvatarToken/AvatarToken.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third party dependences. import { ImageSourcePropType } from 'react-native'; diff --git a/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.constants.ts b/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.constants.ts index b463d1b907c..237f9a717a4 100644 --- a/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.constants.ts +++ b/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies import { AvatarSize, AvatarProps, AvatarVariant } from '../Avatar/Avatar.types'; diff --git a/app/component-library/components/Badges/Badge/variants/BadgeNetwork/BadgeNetwork.constants.ts b/app/component-library/components/Badges/Badge/variants/BadgeNetwork/BadgeNetwork.constants.ts index 2eb4cf0af34..913f05cab82 100644 --- a/app/component-library/components/Badges/Badge/variants/BadgeNetwork/BadgeNetwork.constants.ts +++ b/app/component-library/components/Badges/Badge/variants/BadgeNetwork/BadgeNetwork.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { AvatarSize } from '../../../../Avatars/Avatar'; diff --git a/app/component-library/components/Badges/Badge/variants/BadgeStatus/BadgeStatus.constants.ts b/app/component-library/components/Badges/Badge/variants/BadgeStatus/BadgeStatus.constants.ts index 1a3eb7d4415..c7886a6bc1e 100644 --- a/app/component-library/components/Badges/Badge/variants/BadgeStatus/BadgeStatus.constants.ts +++ b/app/component-library/components/Badges/Badge/variants/BadgeStatus/BadgeStatus.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { BadgeStatusState, BadgeStatusProps } from './BadgeStatus.types'; diff --git a/app/component-library/components/Badges/BadgeWrapper/BadgeWrapper.constants.tsx b/app/component-library/components/Badges/BadgeWrapper/BadgeWrapper.constants.tsx index fb68f4be8b2..16feae3665c 100644 --- a/app/component-library/components/Badges/BadgeWrapper/BadgeWrapper.constants.tsx +++ b/app/component-library/components/Badges/BadgeWrapper/BadgeWrapper.constants.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import React from 'react'; import { View } from 'react-native'; diff --git a/app/component-library/components/Banners/Banner/Banner.constants.ts b/app/component-library/components/Banners/Banner/Banner.constants.ts index 83822488ad6..67f8756179c 100644 --- a/app/component-library/components/Banners/Banner/Banner.constants.ts +++ b/app/component-library/components/Banners/Banner/Banner.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { DEFAULT_BANNERALERT_SEVERITY } from './variants/BannerAlert/BannerAlert.constants'; import { ButtonVariants } from '../../Buttons/Button'; diff --git a/app/component-library/components/Banners/Banner/foundation/BannerBase/BannerBase.constants.tsx b/app/component-library/components/Banners/Banner/foundation/BannerBase/BannerBase.constants.tsx index fd9f5bdd09f..3cced4d840a 100644 --- a/app/component-library/components/Banners/Banner/foundation/BannerBase/BannerBase.constants.tsx +++ b/app/component-library/components/Banners/Banner/foundation/BannerBase/BannerBase.constants.tsx @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third library dependencies. import React from 'react'; diff --git a/app/component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.constants.ts b/app/component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.constants.ts index 3bc44b02212..7b4de9caf1f 100644 --- a/app/component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.constants.ts +++ b/app/component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { ButtonVariants } from '../../../../Buttons/Button'; diff --git a/app/component-library/components/Banners/Banner/variants/BannerTip/BannerTip.constants.ts b/app/component-library/components/Banners/Banner/variants/BannerTip/BannerTip.constants.ts index 5bf215f91e6..61496f8adca 100644 --- a/app/component-library/components/Banners/Banner/variants/BannerTip/BannerTip.constants.ts +++ b/app/component-library/components/Banners/Banner/variants/BannerTip/BannerTip.constants.ts @@ -1,8 +1,8 @@ /* eslint-disable no-console */ /* eslint-disable @typescript-eslint/no-require-imports */ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ /* eslint-disable @typescript-eslint/no-var-requires */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { ButtonVariants } from '../../../../Buttons/Button'; diff --git a/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.constants.ts b/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.constants.ts index 7e7bade3f07..fe2c0cb5acf 100644 --- a/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.constants.ts +++ b/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { AnimationDuration } from '../../../../../constants/animation.constants'; diff --git a/app/component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter.constants.ts b/app/component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter.constants.ts index 8b9e586d2ef..5d951d72de4 100644 --- a/app/component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter.constants.ts +++ b/app/component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { ButtonVariants } from '../../Buttons/Button'; diff --git a/app/component-library/components/Buttons/Button/Button.constants.ts b/app/component-library/components/Buttons/Button/Button.constants.ts index 45d811f0c3a..56d740ae46b 100644 --- a/app/component-library/components/Buttons/Button/Button.constants.ts +++ b/app/component-library/components/Buttons/Button/Button.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { SAMPLE_BUTTONSECONDARY_PROPS } from './variants/ButtonSecondary/ButtonSecondary.constants'; diff --git a/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.constants.ts b/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.constants.ts index c62398b478a..9f0f65e9361 100644 --- a/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.constants.ts +++ b/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { ButtonBaseProps } from './ButtonBase.types'; import { IconName, IconSize } from '../../../../Icons/Icon'; diff --git a/app/component-library/components/Buttons/Button/variants/ButtonLink/ButtonLink.constants.ts b/app/component-library/components/Buttons/Button/variants/ButtonLink/ButtonLink.constants.ts index e3e50054634..f8f51ef7615 100644 --- a/app/component-library/components/Buttons/Button/variants/ButtonLink/ButtonLink.constants.ts +++ b/app/component-library/components/Buttons/Button/variants/ButtonLink/ButtonLink.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { ButtonSize } from '../../Button.types'; diff --git a/app/component-library/components/Buttons/Button/variants/ButtonPrimary/ButtonPrimary.constants.ts b/app/component-library/components/Buttons/Button/variants/ButtonPrimary/ButtonPrimary.constants.ts index 73fb3ca6317..742dec6c978 100644 --- a/app/component-library/components/Buttons/Button/variants/ButtonPrimary/ButtonPrimary.constants.ts +++ b/app/component-library/components/Buttons/Button/variants/ButtonPrimary/ButtonPrimary.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies import { SAMPLE_BUTTONBASE_PROPS } from '../../foundation/ButtonBase/ButtonBase.constants'; diff --git a/app/component-library/components/Buttons/Button/variants/ButtonSecondary/ButtonSecondary.constants.ts b/app/component-library/components/Buttons/Button/variants/ButtonSecondary/ButtonSecondary.constants.ts index a4ddaf31f02..cc760d3e1dd 100644 --- a/app/component-library/components/Buttons/Button/variants/ButtonSecondary/ButtonSecondary.constants.ts +++ b/app/component-library/components/Buttons/Button/variants/ButtonSecondary/ButtonSecondary.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { SAMPLE_BUTTONBASE_PROPS } from '../../foundation/ButtonBase/ButtonBase.constants'; diff --git a/app/component-library/components/Buttons/ButtonIcon/ButtonIcon.constants.ts b/app/component-library/components/Buttons/ButtonIcon/ButtonIcon.constants.ts index e2c8813aa78..300385a2d7f 100644 --- a/app/component-library/components/Buttons/ButtonIcon/ButtonIcon.constants.ts +++ b/app/component-library/components/Buttons/ButtonIcon/ButtonIcon.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { IconSize, IconName, IconColor } from '../../Icons/Icon'; diff --git a/app/component-library/components/Cells/Cell/Cell.constants.ts b/app/component-library/components/Cells/Cell/Cell.constants.ts index 5a6ef93b3f4..c642c631d98 100644 --- a/app/component-library/components/Cells/Cell/Cell.constants.ts +++ b/app/component-library/components/Cells/Cell/Cell.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { AvatarVariant, AvatarAccountType } from '../../Avatars/Avatar'; import { AvatarProps } from '../../Avatars/Avatar/Avatar.types'; diff --git a/app/component-library/components/Checkbox/Checkbox.constants.ts b/app/component-library/components/Checkbox/Checkbox.constants.ts index 590defd5c7f..66d38d632de 100644 --- a/app/component-library/components/Checkbox/Checkbox.constants.ts +++ b/app/component-library/components/Checkbox/Checkbox.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { IconName, IconSize } from '../Icons/Icon'; diff --git a/app/component-library/components/Form/HelpText/HelpText.constants.ts b/app/component-library/components/Form/HelpText/HelpText.constants.ts index 82f898e92a0..be8d2d077b8 100644 --- a/app/component-library/components/Form/HelpText/HelpText.constants.ts +++ b/app/component-library/components/Form/HelpText/HelpText.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { TextColor, TextVariant } from '../../Texts/Text'; diff --git a/app/component-library/components/Form/Label/Label.constants.ts b/app/component-library/components/Form/Label/Label.constants.ts index c8b831884c7..735d98db099 100644 --- a/app/component-library/components/Form/Label/Label.constants.ts +++ b/app/component-library/components/Form/Label/Label.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { TextVariant } from '../../Texts/Text'; diff --git a/app/component-library/components/Form/TextField/foundation/Input/Input.constants.ts b/app/component-library/components/Form/TextField/foundation/Input/Input.constants.ts index 5e02b0bf256..16f7dca9be3 100644 --- a/app/component-library/components/Form/TextField/foundation/Input/Input.constants.ts +++ b/app/component-library/components/Form/TextField/foundation/Input/Input.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { DEFAULT_TEXT_VARIANT } from '../../../../Texts/Text/Text.constants'; diff --git a/app/component-library/components/Icons/Icon/Icon.assets.ts b/app/component-library/components/Icons/Icon/Icon.assets.ts index d9ef73bea5e..a6eae2d7aeb 100644 --- a/app/component-library/components/Icons/Icon/Icon.assets.ts +++ b/app/component-library/components/Icons/Icon/Icon.assets.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ /////////////////////////////////////////////////////// // This is a generated file // DO NOT EDIT - Use generate-assets.js diff --git a/app/component-library/components/Icons/Icon/Icon.constants.ts b/app/component-library/components/Icons/Icon/Icon.constants.ts index 8f8ed52556f..5a7231ccfb4 100644 --- a/app/component-library/components/Icons/Icon/Icon.constants.ts +++ b/app/component-library/components/Icons/Icon/Icon.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { IconName, IconProps, IconSize, IconColor } from './Icon.types'; diff --git a/app/component-library/components/Icons/Icon/scripts/generate-assets.ts b/app/component-library/components/Icons/Icon/scripts/generate-assets.ts index 22c90fe0523..3a9699a214d 100644 --- a/app/component-library/components/Icons/Icon/scripts/generate-assets.ts +++ b/app/component-library/components/Icons/Icon/scripts/generate-assets.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -/* eslint-disable import/no-commonjs, import/no-nodejs-modules, import/no-nodejs-modules, no-console */ +/* eslint-disable import-x/no-commonjs, import-x/no-nodejs-modules, import-x/no-nodejs-modules, no-console */ import fs from 'fs'; import path from 'path'; @@ -41,7 +41,7 @@ const main = async () => { fs.appendFileSync( assetsModulePath, - `/* eslint-disable import/prefer-default-export */`, + `/* eslint-disable import-x/prefer-default-export */`, ); fs.appendFileSync( diff --git a/app/component-library/components/List/ListItem/ListItem.constants.ts b/app/component-library/components/List/ListItem/ListItem.constants.ts index 8ee1fb524fd..8a1a615d962 100644 --- a/app/component-library/components/List/ListItem/ListItem.constants.ts +++ b/app/component-library/components/List/ListItem/ListItem.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { VerticalAlignment, ListItemProps } from './ListItem.types'; diff --git a/app/component-library/components/List/ListItemColumn/ListItemColumn.constants.ts b/app/component-library/components/List/ListItemColumn/ListItemColumn.constants.ts index 4bb8ea1daf9..6ec017c8726 100644 --- a/app/component-library/components/List/ListItemColumn/ListItemColumn.constants.ts +++ b/app/component-library/components/List/ListItemColumn/ListItemColumn.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { ListItemColumnProps, WidthType } from './ListItemColumn.types'; diff --git a/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.constants.ts b/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.constants.ts index c7e2493b897..f405bbb3e76 100644 --- a/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.constants.ts +++ b/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { SAMPLE_LISTITEM_PROPS } from '../../List/ListItem/ListItem.constants'; diff --git a/app/component-library/components/List/ListItemSelect/ListItemSelect.constants.ts b/app/component-library/components/List/ListItemSelect/ListItemSelect.constants.ts index 6ffca867f4d..cae6123f4a0 100644 --- a/app/component-library/components/List/ListItemSelect/ListItemSelect.constants.ts +++ b/app/component-library/components/List/ListItemSelect/ListItemSelect.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { SAMPLE_LISTITEM_PROPS } from '../ListItem/ListItem.constants'; diff --git a/app/component-library/components/Modals/ModalConfirmation/ModalConfirmation.constants.ts b/app/component-library/components/Modals/ModalConfirmation/ModalConfirmation.constants.ts index 8f2a4335258..dac3e624b38 100644 --- a/app/component-library/components/Modals/ModalConfirmation/ModalConfirmation.constants.ts +++ b/app/component-library/components/Modals/ModalConfirmation/ModalConfirmation.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { ModalConfirmationRoute, diff --git a/app/component-library/components/Navigation/TabBar/TabBar.constants.ts b/app/component-library/components/Navigation/TabBar/TabBar.constants.ts index 2c9e1731917..596f860e929 100644 --- a/app/component-library/components/Navigation/TabBar/TabBar.constants.ts +++ b/app/component-library/components/Navigation/TabBar/TabBar.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third party dependencies. import { IconName } from '../../Icons/Icon'; diff --git a/app/component-library/components/Navigation/TabBarItem/TabBarItem.constants.ts b/app/component-library/components/Navigation/TabBarItem/TabBarItem.constants.ts index 761600e1a3c..131f2f016a6 100644 --- a/app/component-library/components/Navigation/TabBarItem/TabBarItem.constants.ts +++ b/app/component-library/components/Navigation/TabBarItem/TabBarItem.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ /* eslint-disable no-console */ // External dependencies. import { IconName } from '../../Icons/Icon'; diff --git a/app/component-library/components/Overlay/Overlay.constants.ts b/app/component-library/components/Overlay/Overlay.constants.ts index 8346ff19926..4662d38c599 100644 --- a/app/component-library/components/Overlay/Overlay.constants.ts +++ b/app/component-library/components/Overlay/Overlay.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { AnimationDuration } from '../../constants/animation.constants'; diff --git a/app/component-library/components/Pickers/PickerAccount/PickerAccount.constants.ts b/app/component-library/components/Pickers/PickerAccount/PickerAccount.constants.ts index 27d744cb8a5..f31d2c00130 100644 --- a/app/component-library/components/Pickers/PickerAccount/PickerAccount.constants.ts +++ b/app/component-library/components/Pickers/PickerAccount/PickerAccount.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { PickerAccountProps } from './PickerAccount.types'; diff --git a/app/component-library/components/Pickers/PickerNetwork/PickerNetwork.constants.ts b/app/component-library/components/Pickers/PickerNetwork/PickerNetwork.constants.ts index 8e9539e0436..29c94ab6979 100644 --- a/app/component-library/components/Pickers/PickerNetwork/PickerNetwork.constants.ts +++ b/app/component-library/components/Pickers/PickerNetwork/PickerNetwork.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { PickerNetworkProps } from './PickerNetwork.types'; diff --git a/app/component-library/components/RadioButton/RadioButton.constants.ts b/app/component-library/components/RadioButton/RadioButton.constants.ts index 16aaaa7f606..d37aec6e83a 100644 --- a/app/component-library/components/RadioButton/RadioButton.constants.ts +++ b/app/component-library/components/RadioButton/RadioButton.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { TextVariant, TextColor } from '../Texts/Text'; diff --git a/app/component-library/components/Select/SelectButton/SelectButton.constants.ts b/app/component-library/components/Select/SelectButton/SelectButton.constants.ts index 7944cf22527..8dfd9a495af 100644 --- a/app/component-library/components/Select/SelectButton/SelectButton.constants.ts +++ b/app/component-library/components/Select/SelectButton/SelectButton.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { TextVariant, TextColor } from '../../Texts/Text'; import { IconName, IconColor, IconSize } from '../../Icons/Icon'; diff --git a/app/component-library/components/Select/SelectButton/foundation/SelectButtonBase.constants.tsx b/app/component-library/components/Select/SelectButton/foundation/SelectButtonBase.constants.tsx index aeaf590617f..9b9ada3e81a 100644 --- a/app/component-library/components/Select/SelectButton/foundation/SelectButtonBase.constants.tsx +++ b/app/component-library/components/Select/SelectButton/foundation/SelectButtonBase.constants.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third party dependencies. import React from 'react'; diff --git a/app/component-library/components/Select/SelectOption/SelectOption.constants.ts b/app/component-library/components/Select/SelectOption/SelectOption.constants.ts index b4f6fd1dbbb..d6816e2361b 100644 --- a/app/component-library/components/Select/SelectOption/SelectOption.constants.ts +++ b/app/component-library/components/Select/SelectOption/SelectOption.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { SAMPLE_AVATAR_PROPS } from '../../Avatars/Avatar/Avatar.constants'; diff --git a/app/component-library/components/Select/SelectValue/SelectValue.constants.ts b/app/component-library/components/Select/SelectValue/SelectValue.constants.ts index e935ad3fe72..cdc2ac0ed70 100644 --- a/app/component-library/components/Select/SelectValue/SelectValue.constants.ts +++ b/app/component-library/components/Select/SelectValue/SelectValue.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { TextVariant, TextColor } from '../../Texts/Text'; import { SAMPLE_AVATAR_PROPS } from '../../Avatars/Avatar/Avatar.constants'; diff --git a/app/component-library/components/Tags/TagUrl/TagUrl.constants.ts b/app/component-library/components/Tags/TagUrl/TagUrl.constants.ts index d5a186f1c2a..c7850483900 100644 --- a/app/component-library/components/Tags/TagUrl/TagUrl.constants.ts +++ b/app/component-library/components/Tags/TagUrl/TagUrl.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { TagUrlProps } from './TagUrl.types'; diff --git a/app/component-library/components/Texts/Text/Text.constants.ts b/app/component-library/components/Texts/Text/Text.constants.ts index ceed074a307..cceac48de29 100644 --- a/app/component-library/components/Texts/Text/Text.constants.ts +++ b/app/component-library/components/Texts/Text/Text.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { TextColor, TextVariant, TextProps } from './Text.types'; diff --git a/app/component-library/components/Texts/TextWithPrefixIcon/TextWithPrefixIcon.constants.ts b/app/component-library/components/Texts/TextWithPrefixIcon/TextWithPrefixIcon.constants.ts index 8943612ce00..52d44783833 100644 --- a/app/component-library/components/Texts/TextWithPrefixIcon/TextWithPrefixIcon.constants.ts +++ b/app/component-library/components/Texts/TextWithPrefixIcon/TextWithPrefixIcon.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { TextColor, TextVariant } from '../Text/Text.types'; import { IconName, IconSize, IconColor } from '../../Icons/Icon'; diff --git a/app/component-library/components/Toast/Toast.constants.ts b/app/component-library/components/Toast/Toast.constants.ts index 586545288e6..c87097e7921 100644 --- a/app/component-library/components/Toast/Toast.constants.ts +++ b/app/component-library/components/Toast/Toast.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { AvatarAccountType } from '../Avatars/Avatar/variants/AvatarAccount'; diff --git a/app/component-library/constants/animation.constants.ts b/app/component-library/constants/animation.constants.ts index f0015dac3a6..3e89d40c635 100644 --- a/app/component-library/constants/animation.constants.ts +++ b/app/component-library/constants/animation.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ /** * Animation Tokens in miliseconds. diff --git a/app/component-library/hooks/index.ts b/app/component-library/hooks/index.ts index 580203e91fe..f1ec4238b8c 100644 --- a/app/component-library/hooks/index.ts +++ b/app/component-library/hooks/index.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ export { useStyles } from './useStyles'; export { useComponentSize } from './useComponentSize'; export { diff --git a/app/component-library/hooks/useStyles.ts b/app/component-library/hooks/useStyles.ts index 2f53b4338f4..a056aeee034 100644 --- a/app/component-library/hooks/useStyles.ts +++ b/app/component-library/hooks/useStyles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { useMemo } from 'react'; import { useAppThemeFromContext } from '../../util/theme'; import { Theme } from '../../util/theme/models'; diff --git a/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/index.ts b/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/index.ts index 8b9e4ed3f2c..4d64d33cad4 100644 --- a/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/index.ts +++ b/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import InstallSnapConnectionRequest from './InstallSnapConnectionRequest'; export { InstallSnapConnectionRequest }; diff --git a/app/components/Approvals/InstallSnapApproval/components/InstallSnapError/index.ts b/app/components/Approvals/InstallSnapApproval/components/InstallSnapError/index.ts index 0cd446d0392..a05b61881ea 100644 --- a/app/components/Approvals/InstallSnapApproval/components/InstallSnapError/index.ts +++ b/app/components/Approvals/InstallSnapApproval/components/InstallSnapError/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import InstallSnapError from './InstallSnapError'; export { InstallSnapError }; diff --git a/app/components/Approvals/InstallSnapApproval/components/InstallSnapPermissionsRequest/index.ts b/app/components/Approvals/InstallSnapApproval/components/InstallSnapPermissionsRequest/index.ts index a7070c8e222..d539af3d383 100644 --- a/app/components/Approvals/InstallSnapApproval/components/InstallSnapPermissionsRequest/index.ts +++ b/app/components/Approvals/InstallSnapApproval/components/InstallSnapPermissionsRequest/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import InstallSnapPermissionsRequest from './InstallSnapPermissionsRequest'; export { InstallSnapPermissionsRequest }; diff --git a/app/components/Approvals/InstallSnapApproval/components/InstallSnapSuccess/index.ts b/app/components/Approvals/InstallSnapApproval/components/InstallSnapSuccess/index.ts index 0f09c54d7b2..bbef9d69f0d 100644 --- a/app/components/Approvals/InstallSnapApproval/components/InstallSnapSuccess/index.ts +++ b/app/components/Approvals/InstallSnapApproval/components/InstallSnapSuccess/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import InstallSnapSuccess from './InstallSnapSuccess'; export { InstallSnapSuccess }; diff --git a/app/components/Approvals/InstallSnapApproval/components/index.ts b/app/components/Approvals/InstallSnapApproval/components/index.ts index 94543d785e1..a6223470ba5 100644 --- a/app/components/Approvals/InstallSnapApproval/components/index.ts +++ b/app/components/Approvals/InstallSnapApproval/components/index.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps) import { InstallSnapConnectionRequest } from './InstallSnapConnectionRequest'; ///: END:ONLY_INCLUDE_IF diff --git a/app/components/Base/RemoteImage/index.test.tsx b/app/components/Base/RemoteImage/index.test.tsx index 19bf70b8194..c827f270b39 100644 --- a/app/components/Base/RemoteImage/index.test.tsx +++ b/app/components/Base/RemoteImage/index.test.tsx @@ -227,6 +227,28 @@ describe('RemoteImage', () => { }); }); + describe('onLoad callback', () => { + it('calls onLoad prop when image loads successfully', async () => { + const mockOnLoad = jest.fn(); + + const { UNSAFE_getByType } = render( + , + ); + + await act(async () => { + const image = UNSAFE_getByType(Image); + image.props.onLoad({ source: { width: 100, height: 100 } }); + }); + + await waitFor(() => { + expect(mockOnLoad).toHaveBeenCalledTimes(1); + }); + }); + }); + describe('Error Handling', () => { it('renders Identicon when image fails to load and address is provided', async () => { const { UNSAFE_getByType, findByTestId } = render( diff --git a/app/components/Base/RemoteImage/index.tsx b/app/components/Base/RemoteImage/index.tsx index 1ec20a2d68f..a9e74450ac0 100644 --- a/app/components/Base/RemoteImage/index.tsx +++ b/app/components/Base/RemoteImage/index.tsx @@ -27,6 +27,7 @@ interface RemoteImageProps { style?: StyleProp; placeholderStyle?: StyleProp; onError?: () => void; + onLoad?: () => void; isUrl?: boolean; address?: string; isTokenImage?: boolean; @@ -52,6 +53,7 @@ const RemoteImage: React.FC = (props) => { const source = resolveAssetSource(props.source); const ipfsGateway = useIpfsGateway(); const [resolvedIpfsUrl, setResolvedIpfsUrl] = useState(); + const { onLoad: onLoadProp } = props; const uri = resolvedIpfsUrl || @@ -134,8 +136,9 @@ const RemoteImage: React.FC = (props) => { return { width: calculatedWidth, height: calculatedHeight }; }); } + onLoadProp?.(); }, - [calculateImageDimensions], + [calculateImageDimensions, onLoadProp], ); if (error && props.address) { @@ -147,7 +150,13 @@ const RemoteImage: React.FC = (props) => { } const defaultImage = ( - + ); if (props.fadeIn) { @@ -164,6 +173,7 @@ const RemoteImage: React.FC = (props) => { {showFullRatioImage ? ( = (props) => { style={styles.imageStyle} {...restProps} source={{ uri }} + recyclingKey={uri} onLoad={onImageLoad} onError={onError} /> diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index 690420af7e9..7b13a9c52a0 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -71,6 +71,7 @@ import FiatOnTestnetsFriction from '../../../components/Views/Settings/AdvancedS import WalletActions from '../../Views/WalletActions'; import FundActionMenu from '../../UI/FundActionMenu'; import MoreTokenActionsMenu from '../../UI/TokenDetails/components/MoreTokenActionsMenu'; +import SecurityBadgeBottomSheet from '../../UI/TokenDetails/components/SecurityBadgeBottomSheet'; import NetworkSelector from '../../../components/Views/NetworkSelector'; import ReturnToAppNotification from '../../Views/ReturnToAppNotification'; import EditAccountName from '../../Views/EditAccountName/EditAccountName'; @@ -380,6 +381,10 @@ const RootModalFlow = (props: RootModalFlowProps) => ( name={Routes.MODAL.MORE_TOKEN_ACTIONS_MENU} component={MoreTokenActionsMenu} /> + ( component={AssetDetails} initialParams={{ address: props.route.params?.address }} /> + ( ); -const PaymentRequestView = () => ( - - - - -); - /* eslint-disable react/prop-types */ const NotificationsModeView = (props) => ( @@ -948,8 +937,13 @@ const MainNavigator = () => { () => predictEnabledFlag, [predictEnabledFlag], ); - // Get feature flag state for conditional Market Insights screen registration + // Get feature flag state for conditional Market Insights screen registration. + // The screen must be registered when either the token or perps insights flag is + // on — both entry points navigate to the same screen. const isMarketInsightsEnabled = useSelector(selectMarketInsightsEnabled); + const isMarketInsightsPerpsEnabled = useSelector( + selectMarketInsightsPerpsEnabled, + ); return ( { component={NftFullView} options={{ headerShown: false, ...slideFromRightAnimation }} /> - { /> )} - {isMarketInsightsEnabled && ( + {(isMarketInsightsEnabled || isMarketInsightsPerpsEnabled) && ( - - - { expect(callbacks.autoSign).not.toHaveBeenCalled(); }); + it('skips processing when origin is MetaMask Mobile Card (MMM_CARD)', () => { + const callbacks = mockCallbacks(); + const txMeta = buildSwapTxMeta({ origin: 'MetaMask Mobile Card' }); + + onUnapprovedTransaction(txMeta, callbacks); + + expect(callbacks.autoSign).not.toHaveBeenCalled(); + }); + it('calls autoSign for hardware wallet swap', () => { isHardwareAccountMock.mockReturnValue(true); const callbacks = mockCallbacks(); diff --git a/app/components/Nav/Main/onUnapprovedTransaction.ts b/app/components/Nav/Main/onUnapprovedTransaction.ts index c7d93d4be1f..a1e4f5558df 100644 --- a/app/components/Nav/Main/onUnapprovedTransaction.ts +++ b/app/components/Nav/Main/onUnapprovedTransaction.ts @@ -19,7 +19,11 @@ export function onUnapprovedTransaction( ) { const transactionMeta = cloneDeep(transactionMetaOriginal); - if (transactionMeta.origin === TransactionTypes.MMM) return; + if ( + transactionMeta.origin === TransactionTypes.MMM || + transactionMeta.origin === TransactionTypes.MMM_CARD + ) + return; const to = transactionMeta.txParams.to?.toLowerCase(); const data = transactionMeta.txParams.data as string; diff --git a/app/components/Snaps/SnapUIAddressInput/SnapUIAddressInput.tsx b/app/components/Snaps/SnapUIAddressInput/SnapUIAddressInput.tsx index e2b63bae6e6..94d559db42b 100644 --- a/app/components/Snaps/SnapUIAddressInput/SnapUIAddressInput.tsx +++ b/app/components/Snaps/SnapUIAddressInput/SnapUIAddressInput.tsx @@ -12,7 +12,7 @@ import { Box } from '../../UI/Box/Box'; import Text, { TextVariant, } from '../../../component-library/components/Texts/Text'; -import Label from '../../../component-library/components/Form/Label'; +import { Label, FontWeight } from '@metamask/design-system-react-native'; import TextField from '../../../component-library/components/Form/TextField'; import HelpText, { HelpTextSeverity, @@ -82,7 +82,7 @@ const MatchedAccountInfo = ({ }); return ( - {label && } + {label && } - {label && } + {label && } = ({ return ( - {fieldLabel && ( - - )} + {fieldLabel && } - {label && } + {label && } - {label && } + {label && } = ({ return ( - {label && } + {label && } {options.map((option) => ( My Input diff --git a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/account-selector.test.ts.snap b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/account-selector.test.ts.snap index 6cb5e675da5..7960fcca259 100644 --- a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/account-selector.test.ts.snap +++ b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/account-selector.test.ts.snap @@ -189,15 +189,18 @@ exports[`SnapUIAccountSelector renders inside a field 1`] = ` Account Selector diff --git a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/date-time-picker.test.tsx.snap b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/date-time-picker.test.tsx.snap index 26e10e36b3c..30cd1f5627d 100644 --- a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/date-time-picker.test.tsx.snap +++ b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/date-time-picker.test.tsx.snap @@ -67,15 +67,18 @@ exports[`SnapUIDateTimePicker can show an error 1`] = ` Select date and time @@ -749,15 +752,18 @@ exports[`SnapUIDateTimePicker renders inside a field 1`] = ` Select date and time diff --git a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/form.test.ts.snap b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/form.test.ts.snap index 37f69a42a68..128b313bdce 100644 --- a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/form.test.ts.snap +++ b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/form.test.ts.snap @@ -282,15 +282,18 @@ exports[`SnapUIForm will render with fields 1`] = ` My Input @@ -398,15 +401,18 @@ exports[`SnapUIForm will render with fields 1`] = ` My Checkbox @@ -490,15 +496,18 @@ exports[`SnapUIForm will render with fields 1`] = ` My Radio Group @@ -631,15 +640,18 @@ exports[`SnapUIForm will render with fields 1`] = ` My Dropdown @@ -1331,15 +1343,18 @@ exports[`SnapUIForm will render with fields 1`] = ` My Selector diff --git a/app/components/Snaps/SnapUISelector/SnapUISelector.tsx b/app/components/Snaps/SnapUISelector/SnapUISelector.tsx index 06e28b85732..7d000a7ec9d 100644 --- a/app/components/Snaps/SnapUISelector/SnapUISelector.tsx +++ b/app/components/Snaps/SnapUISelector/SnapUISelector.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useSnapInterfaceContext } from '../SnapInterfaceContext'; -import Label from '../../../component-library/components/Form/Label'; +import { Label, FontWeight } from '@metamask/design-system-react-native'; import HelpText, { HelpTextSeverity, } from '../../../component-library/components/Form/HelpText'; @@ -14,7 +14,6 @@ import stylesheet from './SnapUISelector.styles'; import { View, ScrollView, ViewStyle } from 'react-native'; import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; import ApprovalModal from '../../Approvals/ApprovalModal'; -import { TextVariant } from '../../../component-library/components/Texts/Text'; import { State } from '@metamask/snaps-sdk'; import { isObject } from '@metamask/utils'; @@ -139,7 +138,7 @@ export const SnapUISelector: React.FunctionComponent = ({ return ( <> - {label && } + {label && } ({ ...jest.requireActual('../PriceChart/PriceChart'), @@ -17,35 +14,12 @@ jest.mock('../PriceChart/PriceChart', () => ({ default: jest.fn().mockImplementation(() => null), })); -jest.mock('../../Bridge/hooks/useRWAToken', () => ({ - useRWAToken: () => ({ - isStockToken: jest.fn().mockReturnValue(false), - isTokenTradingOpen: jest.fn().mockResolvedValue(true), - }), -})); - -const mockAsset: TokenI = { - name: 'Ethereum', - ticker: 'ETH', - symbol: 'Ethereum', - address: '0x0', - aggregators: [], - decimals: 18, - image: '', - balance: '100', - balanceFiat: '$100', - logo: '', - isETH: true, - isNative: true, -}; - const mockPrices: TokenPrice[] = [ ['1736761237983', 100], ['1736761237986', 105], ]; const mockProps: { - asset: TokenI; prices: TokenPrice[]; priceDiff: number; currentPrice: number; @@ -54,7 +28,6 @@ const mockProps: { isLoading: boolean; timePeriod: TimePeriod; } = { - asset: mockAsset, prices: mockPrices, priceDiff: 5, currentPrice: 105, @@ -65,51 +38,6 @@ const mockProps: { }; describe('Price Component', () => { - describe('Header', () => { - it('renders header correctly when asset name and symbol are provided', () => { - const props = { - ...mockProps, - asset: { - ...mockProps.asset, - ticker: '', - }, - }; - - const { getByText } = render(); - - // Name and symbol are rendered together when ticker is not provided - // Format: "name (symbol)" - expect( - getByText(`${mockProps.asset.name} (${mockProps.asset.symbol})`), - ).toBeTruthy(); - }); - - it('renders header correctly when name not provided and symbol is provided', () => { - const props = { - ...mockProps, - asset: { - ...mockProps.asset, - name: '', - ticker: '', - }, - }; - - const { getByText } = render(); - - expect(getByText(`${mockProps.asset.symbol}`)).toBeTruthy(); - }); - - it('renders header correctly when name and ticker are provided', () => { - const { getByText } = render(); - - // Name and ticker are rendered together - // Format: "name (ticker)" - expect( - getByText(`${mockProps.asset.name} (${mockProps.asset.ticker})`), - ).toBeTruthy(); - }); - }); - it('shows loading state when isLoading is true', () => { const { getByTestId } = render( , @@ -119,16 +47,15 @@ describe('Price Component', () => { }); it('renders price at selected date', async () => { - jest - .mocked(PriceChart) - .mockImplementation(({ onChartIndexChange }) => ( - + )); const { getByTestId } = render(); diff --git a/app/components/UI/AssetOverview/Price/Price.tsx b/app/components/UI/AssetOverview/Price/Price.tsx index 000398ed09d..451adfadedb 100644 --- a/app/components/UI/AssetOverview/Price/Price.tsx +++ b/app/components/UI/AssetOverview/Price/Price.tsx @@ -19,13 +19,8 @@ import PriceChart from '../PriceChart/PriceChart'; import { distributeDataPoints } from '../PriceChart/utils'; import styleSheet from './Price.styles'; import { TokenOverviewSelectorsIDs } from '../TokenOverview.testIds'; -import { TokenI } from '../../Tokens/types'; -import StockBadge from '../../shared/StockBadge/StockBadge'; -import { BridgeToken } from '../../Bridge/types'; -import { useRWAToken } from '../../Bridge/hooks/useRWAToken'; interface PriceProps { - asset: TokenI; prices: TokenPrice[]; priceDiff: number; currentPrice: number; @@ -36,7 +31,6 @@ interface PriceProps { } const Price = ({ - asset, prices, priceDiff, currentPrice, @@ -46,7 +40,6 @@ const Price = ({ timePeriod, }: PriceProps) => { const [activeChartIndex, setActiveChartIndex] = useState(-1); - const { isStockToken } = useRWAToken(); const distributedPriceData = useMemo(() => { if (prices.length > 0) { @@ -89,47 +82,10 @@ const Price = ({ : priceDiff; const { styles, theme } = useStyles(styleSheet, { priceDiff: diff }); - const ticker = asset.ticker || asset.symbol; - const stockTokenBadge = isStockToken(asset as BridgeToken) && ( - - ); return ( <> - {asset.name ? ( - stockTokenBadge ? ( - - - {asset.name} - - - - {ticker} - - {stockTokenBadge} - - - ) : ( - - {asset.name} ({ticker}) - - ) - ) : ( - - {ticker} - {stockTokenBadge} - - )} {!isNaN(price) && ( 0 - ? theme.colors.primary.default + ? theme.themeAppearance === 'dark' + ? theme.brandColors.blue300 + : theme.colors.primary.default : priceDiff < 0 - ? theme.colors.primary.default + ? theme.themeAppearance === 'dark' + ? theme.brandColors.blue300 + : theme.colors.primary.default : theme.colors.text.alternative; const apx = (size = 0) => { diff --git a/app/components/UI/AssetOverview/TokenDetails/MarketDetailsList/MarketDetailsList.test.tsx b/app/components/UI/AssetOverview/TokenDetails/MarketDetailsList/MarketDetailsList.test.tsx index 21335cd837b..3a09ce1f03b 100644 --- a/app/components/UI/AssetOverview/TokenDetails/MarketDetailsList/MarketDetailsList.test.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/MarketDetailsList/MarketDetailsList.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react-native'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as reactRedux from 'react-redux'; import MarketDetailsList from '.'; diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx index d77f6d04331..87916f9cd5a 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx @@ -14,7 +14,7 @@ import { selectContractExchangeRates } from '../../../../selectors/tokenRatesCon import { backgroundState } from '../../../../util/test/initial-root-state'; import renderWithProvider from '../../../../util/test/renderWithProvider'; import TokenDetails from './'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as reactRedux from 'react-redux'; import { selectMultichainAssetsRates } from '../../../../selectors/multichain'; import { selectIsEvmNetworkSelected } from '../../../../selectors/multichainNetworkController'; diff --git a/app/components/UI/AssetOverview/TokenOverview.testIds.ts b/app/components/UI/AssetOverview/TokenOverview.testIds.ts index 8feaf17005d..9faea5f10f1 100644 --- a/app/components/UI/AssetOverview/TokenOverview.testIds.ts +++ b/app/components/UI/AssetOverview/TokenOverview.testIds.ts @@ -20,6 +20,7 @@ export const TokenOverviewSelectorsIDs = { export const TokenOverviewSelectorsText = { STAKED_BALANCE: enContent.stake.staked_balance, + TODAYS_CHANGE_SUFFIX: '%) Today', NO_CHART_DATA: enContent.asset_overview.no_chart_data.title, '1d': enContent.asset_overview.chart_time_period_navigation['1d'], '1w': enContent.asset_overview.chart_time_period_navigation['1w'], diff --git a/app/components/UI/Box/box.types.ts b/app/components/UI/Box/box.types.ts index 4a1aab53d59..ff388aab921 100644 --- a/app/components/UI/Box/box.types.ts +++ b/app/components/UI/Box/box.types.ts @@ -427,14 +427,12 @@ type PropsToOmit = keyof (AsProp & P); */ type PolymorphicComponentProp< C extends React.ElementType, - // eslint-disable-next-line @typescript-eslint/ban-types Props = {}, > = React.PropsWithChildren> & Omit, PropsToOmit>; export type PolymorphicComponentPropWithRef< C extends React.ElementType, - // eslint-disable-next-line @typescript-eslint/ban-types Props = {}, > = PolymorphicComponentProp & { ref?: PolymorphicRef }; diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx index c413f537475..3270963ef80 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx @@ -8,7 +8,6 @@ import Routes from '../../../../../constants/navigation/Routes'; import { setDestToken, setSourceToken, - selectNoFeeAssets, } from '../../../../../core/redux/slices/bridge'; import { Hex } from '@metamask/utils'; import BridgeView from '.'; @@ -25,7 +24,6 @@ import { mockUseBridgeQuoteData } from '../../_mocks_/useBridgeQuoteData.mock'; import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData'; import { useRWAToken } from '../../hooks/useRWAToken'; import { strings } from '../../../../../../locales/i18n'; -import { isHardwareAccount } from '../../../../../util/address'; import { BridgeViewSelectorsIDs } from './BridgeView.testIds'; import { MOCK_ENTROPY_SOURCE as mockEntropySource } from '../../../../../util/test/keyringControllerTestUtils'; import { RootState } from '../../../../../reducers'; @@ -208,7 +206,6 @@ jest.mock('../../../../../core/redux/slices/bridge', () => { default: actualBridgeSlice.default, setSourceToken: jest.fn(actualBridgeSlice.setSourceToken), setDestToken: jest.fn(actualBridgeSlice.setDestToken), - selectNoFeeAssets: jest.fn(actualBridgeSlice.selectNoFeeAssets), }; }); @@ -262,11 +259,6 @@ jest.mock('../../hooks/useLatestBalance', () => ({ }), })); -// Mock Skeleton component to prevent animation -jest.mock('../../../../../component-library/components/Skeleton', () => ({ - Skeleton: () => null, -})); - jest.mock('../../hooks/useBridgeQuoteData', () => ({ useBridgeQuoteData: jest .fn() @@ -1038,14 +1030,17 @@ describe('BridgeView', () => { expect(queryByTestId('edit-slippage-button')).toBeNull(); }); - it('navigates to QuoteExpiredModal when quote expires without refresh', async () => { + it('does not navigate to QuoteExpiredModal when quote expires without refresh', async () => { + // useRenderQuoteExpireModal was removed; the expired-quote modal no longer + // exists. Instead, the cached quote stays visible and "Get new quote" + // appears in the footer. jest .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ ...mockUseBridgeQuoteData, isExpired: true, willRefresh: false, - activeQuote: undefined, // activeQuote is undefined when quote expires without refresh + needsNewQuote: true, })); renderScreen( @@ -1057,9 +1052,12 @@ describe('BridgeView', () => { ); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - }); + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.BRIDGE.MODALS.ROOT, + { + screen: Routes.BRIDGE.BRIDGE_VIEW, + }, + ); }); }); @@ -1084,7 +1082,7 @@ describe('BridgeView', () => { expect(mockNavigate).not.toHaveBeenCalledWith( Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, + screen: Routes.BRIDGE.BRIDGE_VIEW, }, ); }); @@ -1111,7 +1109,7 @@ describe('BridgeView', () => { expect(mockNavigate).not.toHaveBeenCalledWith( Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, + screen: Routes.BRIDGE.BRIDGE_VIEW, }, ); }); @@ -1147,13 +1145,15 @@ describe('BridgeView', () => { expect(mockNavigate).not.toHaveBeenCalledWith( Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, + screen: Routes.BRIDGE.BRIDGE_VIEW, }, ); }); }); - it('navigates to QuoteExpiredModal when quote expires and leaves quote content hidden', async () => { + it('shows cached quote content when quote expires', async () => { + // When quotes expire the cached quote (still in Redux) is shown in the + // QuoteDetailsCard. The slippage button must remain visible. jest .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ @@ -1161,57 +1161,17 @@ describe('BridgeView', () => { isExpired: true, willRefresh: false, isLoading: false, - activeQuote: undefined, // activeQuote is undefined when quote expires without refresh + needsNewQuote: true, + // activeQuote remains the cached quote — not cleared on expiry })); - const { queryByTestId } = renderScreen( - BridgeView, - { - name: Routes.BRIDGE.ROOT, - }, - { state: mockState }, - ); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - }); + // createBridgeTestState provides source/dest tokens so QuoteDetailsCard + // passes its early-return guard and renders the slippage button. + const testState = createBridgeTestState({ + bridgeReducerOverrides: { sourceAmount: '1.0' }, }); - expect(queryByTestId('edit-slippage-button')).toBeNull(); - }); - it('displays hardware wallet not supported banner when using hardware wallet with Solana source', async () => { - // Mock isHardwareAccount to return true for this test only - const mockIsHardwareAccount = jest.fn().mockReturnValue(true); - jest.mocked(isHardwareAccount).mockImplementation(mockIsHardwareAccount); - - const mockQuote = mockQuoteWithMetadata; - const testState = createBridgeTestState( - { - bridgeControllerOverrides: { - quoteRequest: { - insufficientBal: false, - }, - quotesLoadingStatus: RequestStatus.FETCHED, - quotes: [mockQuote as unknown as QuoteResponse], - quotesLastFetched: Date.now(), - }, - bridgeReducerOverrides: { - sourceAmount: '1.0', - sourceToken: { - address: 'So11111111111111111111111111111111111111112', - chainId: SolScope.Mainnet, - decimals: 9, - image: '', - name: 'Solana', - symbol: 'SOL', - }, - }, - }, - mockState, - ); - - const { getByText } = renderScreen( + const { queryByTestId } = renderScreen( BridgeView, { name: Routes.BRIDGE.ROOT, @@ -1219,110 +1179,15 @@ describe('BridgeView', () => { { state: testState }, ); - // Wait for the banner text to appear await waitFor(() => { - expect( - getByText(strings('bridge.hardware_wallet_not_supported_solana')), - ).toBeTruthy(); - }); - }); - - it('shows no MM fee disclaimer when dest token is mUSD and fee is 0', async () => { - const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da' as Hex; - - // Locally force selector to return mUSD as no-fee for this test only - // This avoids suite-order/caching issues without affecting other tests - const noFeeSpy = jest - .spyOn({ selectNoFeeAssets }, 'selectNoFeeAssets') - .mockReturnValue([musdAddress]); - - // Ensure quote exists and has 0 fee so hasFee === false - jest - .mocked(useBridgeQuoteData as unknown as jest.Mock) - .mockImplementation(() => ({ - ...mockUseBridgeQuoteData, - isLoading: false, - activeQuote: { - ...(mockQuoteWithMetadata as unknown as QuoteResponse), - // Minimal shape to provide 0 bps fee - quote: { feeData: { metabridge: { quoteBpsFee: 0 } } }, - } as unknown as QuoteResponse, - })); - - // Build test state: provide amount, ETH source, mUSD destination - const testState = createBridgeTestState( - { - bridgeControllerOverrides: { - quotesLoadingStatus: RequestStatus.FETCHED, - quotes: [mockQuoteWithMetadata as unknown as QuoteResponse], - quotesLastFetched: 12, - }, - bridgeReducerOverrides: { - sourceAmount: '1.0', - sourceToken: { - address: '0x0000000000000000000000000000000000000000', - chainId: '0x1' as Hex, - decimals: 18, - image: '', - name: 'Ether', - symbol: 'ETH', - }, - destToken: { - address: musdAddress, - chainId: '0x1' as Hex, - decimals: 6, - image: '', - name: 'MetaMask USD', - symbol: 'mUSD', - }, + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.BRIDGE.MODALS.ROOT, + { + screen: Routes.BRIDGE.BRIDGE_VIEW, }, - }, - mockState as DeepPartial, - ); - - // Mark mUSD as a no-fee asset in feature flags without using any - interface BridgeFeatureFlagsChainConfig { - noFeeAssets?: string[]; - isActiveSrc?: boolean; - isActiveDest?: boolean; - } - type StateWithBridgeFlags = DeepPartial & { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - bridgeConfigV2: { - chains: Record; - }; - }; - }; - }; - }; - }; - const typedState = testState as unknown as StateWithBridgeFlags; - typedState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2.chains[ - 'eip155:1' - ].noFeeAssets = [musdAddress]; - - // Keep test isolated from module cache side-effects: skip selector sanity check - - const { getByText } = renderScreen( - BridgeView, - { - name: Routes.BRIDGE.ROOT, - }, - { state: testState }, - ); - - // Expect translated disclaimer text - const expected = strings('bridge.no_mm_fee_disclaimer', { - destTokenSymbol: 'mUSD', - }); - await waitFor(() => { - expect(getByText(expected)).toBeTruthy(); + ); }); - - noFeeSpy.mockRestore(); + expect(queryByTestId('edit-slippage-button')).not.toBeNull(); }); }); @@ -1697,138 +1562,6 @@ describe('BridgeView', () => { }); }); - describe('Blockaid Security Alert', () => { - it('displays blockaid error banner when blockaid error exists', async () => { - const mockQuote = mockQuoteWithMetadata; - const testState = createBridgeTestState({ - bridgeControllerOverrides: { - quotesLoadingStatus: RequestStatus.FETCHED, - quotes: [mockQuote as unknown as QuoteResponse], - quotesLastFetched: Date.now(), - }, - bridgeReducerOverrides: { - sourceAmount: '1.0', - }, - }); - - jest - .mocked(useBridgeQuoteData as unknown as jest.Mock) - .mockImplementation(() => ({ - ...mockUseBridgeQuoteData, - blockaidError: 'This transaction may be a security risk', - activeQuote: mockQuote, - })); - - const { getByText } = renderScreen( - BridgeView, - { - name: Routes.BRIDGE.ROOT, - }, - { state: testState }, - ); - - await waitFor(() => { - expect(getByText(strings('bridge.blockaid_error_title'))).toBeTruthy(); - expect( - getByText('This transaction may be a security risk'), - ).toBeTruthy(); - }); - }); - }); - - describe('Approval Disclaimer', () => { - it('displays approval needed text when quote requires approval', async () => { - const mockQuote = { - ...mockQuoteWithMetadata, - approval: { - chainId: '0x1', - to: '0xToken', - data: '0xApprovalData', - }, - }; - - const testState = createBridgeTestState({ - bridgeControllerOverrides: { - quotesLoadingStatus: RequestStatus.FETCHED, - quotes: [mockQuote as unknown as QuoteResponse], - quotesLastFetched: Date.now(), - }, - bridgeReducerOverrides: { - sourceAmount: '1.5', - sourceToken: { - address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - chainId: '0x1' as Hex, - decimals: 6, - image: '', - name: 'USD Coin', - symbol: 'USDC', - }, - }, - }); - - jest - .mocked(useBridgeQuoteData as unknown as jest.Mock) - .mockImplementation(() => ({ - ...mockUseBridgeQuoteData, - activeQuote: mockQuote, - })); - - const { getByText } = renderScreen( - BridgeView, - { - name: Routes.BRIDGE.ROOT, - }, - { state: testState }, - ); - - await waitFor(() => { - const approvalText = strings('bridge.approval_needed', { - amount: '1.5', - symbol: 'USDC', - }); - expect(getByText(approvalText, { exact: false })).toBeTruthy(); - }); - }); - - it('does not display approval text when quote does not require approval', async () => { - const mockQuote = { - ...mockQuoteWithMetadata, - approval: null, - }; - - const testState = createBridgeTestState({ - bridgeControllerOverrides: { - quotesLoadingStatus: RequestStatus.FETCHED, - quotes: [mockQuote as unknown as QuoteResponse], - quotesLastFetched: Date.now(), - }, - bridgeReducerOverrides: { - sourceAmount: '1.0', - }, - }); - - jest - .mocked(useBridgeQuoteData as unknown as jest.Mock) - .mockImplementation(() => ({ - ...mockUseBridgeQuoteData, - activeQuote: mockQuote, - })); - - const { queryByText } = renderScreen( - BridgeView, - { - name: Routes.BRIDGE.ROOT, - }, - { state: testState }, - ); - - await waitFor(() => { - // Should not find approval text in the document - expect(queryByText(/approval needed/i, { exact: false })).toBeNull(); - }); - }); - }); - describe('Quote Details Card', () => { it('displays quote details card when active quote exists', async () => { const mockQuote = mockQuoteWithMetadata; diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts b/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts index 3a0ea8689b5..4e63125a80c 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts @@ -6,6 +6,7 @@ export const BridgeViewSelectorsIDs = { CONFIRM_BUTTON: 'bridge-confirm-button', CONFIRM_BUTTON_KEYPAD: 'bridge-confirm-button-keypad', BRIDGE_VIEW_SCROLL: 'bridge-view-scroll', + FEE_DISCLAIMER: 'bridge-fee-disclaimer', QUOTE_DETAILS_SKELETON: 'bridge-quote-details-skeleton', } as const; diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx index aeaff64ce20..039dc7f229c 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx @@ -9,7 +9,7 @@ import { renderScreenWithRoutes } from '../../../../../../tests/component-view/r import Routes from '../../../../../constants/navigation/Routes'; import { initialStateBridge } from '../../../../../../tests/component-view/presets/bridge'; import BridgeView from './index'; -import { describeForPlatforms } from '../../../../../util/test/platform'; +import { describeForPlatforms } from '../../../../../../tests/component-view/platform'; import { BridgeViewSelectorsIDs } from './BridgeView.testIds'; import { BuildQuoteSelectors } from '../../../Ramp/Aggregator/Views/BuildQuote/BuildQuote.testIds'; import { CommonSelectorsIDs } from '../../../../../util/Common.testIds'; diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.test.tsx new file mode 100644 index 00000000000..b77cc01db33 --- /dev/null +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.test.tsx @@ -0,0 +1,481 @@ +import React from 'react'; +import renderWithProvider, { + DeepPartial, +} from '../../../../../util/test/renderWithProvider'; +import { waitFor } from '@testing-library/react-native'; +import { BridgeViewFooter } from './BridgeViewFooter'; +import { strings } from '../../../../../../locales/i18n'; +import { SolScope } from '@metamask/keyring-api'; +import { + RequestStatus, + type QuoteResponse, + MetaMetricsSwapsEventSource, +} from '@metamask/bridge-controller'; +import { Hex } from '@metamask/utils'; +import { isHardwareAccount } from '../../../../../util/address'; +import { mockUseBridgeQuoteData } from '../../_mocks_/useBridgeQuoteData.mock'; +import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData'; +import { mockQuoteWithMetadata } from '../../_mocks_/bridgeQuoteWithMetadata'; +import { createBridgeTestState } from '../../testUtils'; +import type { RootState } from '../../../../../reducers'; +import { BridgeViewSelectorsIDs } from './BridgeView.testIds'; + +jest.mock( + '../../../../../multichain-accounts/controllers/account-tree-controller', + () => ({ + accountTreeControllerInit: jest.fn(() => ({ + controller: { + state: { accountTree: { wallets: {} } }, + }, + })), + }), +); + +jest.mock('../../hooks/useBridgeQuoteData', () => ({ + useBridgeQuoteData: jest + .fn() + .mockImplementation(() => mockUseBridgeQuoteData), +})); + +jest.mock('../../../../../util/address', () => ({ + ...jest.requireActual('../../../../../util/address'), + isHardwareAccount: jest.fn(), +})); + +// Mock SwapsConfirmButton to isolate footer-specific behaviour from its own +// dependencies (Engine, useNavigation, useSubmitBridgeTx, …). +jest.mock('../../components/SwapsConfirmButton/index.tsx', () => ({ + SwapsConfirmButton: ({ testID }: { testID?: string }) => { + const MockReact = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return MockReact.createElement(View, { + testID: testID ?? 'bridge-confirm-button', + }); + }, +})); + +// Mock ApprovalTooltip to avoid the navigation dependency of useTooltipModal. +jest.mock('../../components/ApprovalText', () => { + const MockReact = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + MockReact.createElement(View, { testID: 'approval-tooltip' }), + }; +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const mockLocation = MetaMetricsSwapsEventSource.MainView; + +/** + * Builds a Redux state that satisfies all BridgeViewFooter render conditions: + * active quote, valid source amount, and a quotesLastFetched timestamp. + */ +function buildActiveQuoteState( + overrides: { + bridgeControllerOverrides?: Record; + bridgeReducerOverrides?: Record; + } = {}, +) { + return createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuoteWithMetadata as unknown as QuoteResponse], + quotesLastFetched: Date.now(), + ...(overrides.bridgeControllerOverrides ?? {}), + }, + bridgeReducerOverrides: { + sourceAmount: '1.0', + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + image: '', + name: 'Ether', + symbol: 'ETH', + }, + ...(overrides.bridgeReducerOverrides ?? {}), + }, + }); +} + +function renderFooter(state: DeepPartial) { + return renderWithProvider( + , + { state }, + ); +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('BridgeViewFooter', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => mockUseBridgeQuoteData); + }); + + describe('Rendering conditions', () => { + it('renders nothing when loading without an active quote', () => { + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + isLoading: true, + activeQuote: null, + })); + + const { queryByTestId } = renderFooter(buildActiveQuoteState()); + + expect(queryByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)).toBeNull(); + }); + + it('renders nothing when there is no active quote', () => { + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + isLoading: false, + activeQuote: null, + })); + + const { queryByTestId } = renderFooter(buildActiveQuoteState()); + + expect(queryByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)).toBeNull(); + }); + + it('renders nothing when source amount is missing', () => { + const state = buildActiveQuoteState({ + bridgeReducerOverrides: { sourceAmount: undefined }, + }); + + const { queryByTestId } = renderFooter(state); + + expect(queryByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)).toBeNull(); + }); + + it('renders nothing when quotesLastFetched is null', () => { + const state = buildActiveQuoteState({ + bridgeControllerOverrides: { quotesLastFetched: null }, + }); + + const { queryByTestId } = renderFooter(state); + + expect(queryByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)).toBeNull(); + }); + + it('renders confirm button when all conditions are met', async () => { + const { getByTestId } = renderFooter(buildActiveQuoteState()); + + await waitFor(() => { + expect(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)).toBeTruthy(); + }); + }); + }); + + describe('Hardware Wallet Banner', () => { + it('displays hardware wallet not supported banner when using hardware wallet with Solana source', async () => { + jest.mocked(isHardwareAccount).mockReturnValue(true); + + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quoteRequest: { insufficientBal: false }, + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuoteWithMetadata as unknown as QuoteResponse], + quotesLastFetched: Date.now(), + }, + bridgeReducerOverrides: { + sourceAmount: '1.0', + sourceToken: { + address: 'So11111111111111111111111111111111111111112', + chainId: SolScope.Mainnet, + decimals: 9, + image: '', + name: 'Solana', + symbol: 'SOL', + }, + }, + }); + + const { getByText } = renderFooter(testState as DeepPartial); + + await waitFor(() => { + expect( + getByText(strings('bridge.hardware_wallet_not_supported_solana')), + ).toBeTruthy(); + }); + }); + + it('does not display hardware wallet banner for regular accounts with Solana source', () => { + jest.mocked(isHardwareAccount).mockReturnValue(false); + + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuoteWithMetadata as unknown as QuoteResponse], + quotesLastFetched: Date.now(), + }, + bridgeReducerOverrides: { + sourceAmount: '1.0', + sourceToken: { + address: 'So11111111111111111111111111111111111111112', + chainId: SolScope.Mainnet, + decimals: 9, + image: '', + name: 'Solana', + symbol: 'SOL', + }, + }, + }); + + const { queryByText } = renderFooter(testState as DeepPartial); + + expect( + queryByText(strings('bridge.hardware_wallet_not_supported_solana')), + ).toBeNull(); + }); + }); + + describe('Blockaid Security Alert', () => { + it('displays blockaid error banner when blockaid error exists', async () => { + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + blockaidError: 'This transaction may be a security risk', + activeQuote: mockQuoteWithMetadata, + })); + + const { getByText } = renderFooter(buildActiveQuoteState()); + + await waitFor(() => { + expect(getByText(strings('bridge.blockaid_error_title'))).toBeTruthy(); + expect( + getByText('This transaction may be a security risk'), + ).toBeTruthy(); + }); + }); + + it('does not display blockaid banner when there is no blockaid error', () => { + const { queryByText } = renderFooter(buildActiveQuoteState()); + + expect(queryByText(strings('bridge.blockaid_error_title'))).toBeNull(); + }); + }); + + describe('Fee Disclaimer', () => { + it('shows fee disclaimer with fee percentage when fee is greater than zero', async () => { + const feePercentage = 0.875; // quoteBpsFee: 87.5 / 100 + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: { + ...mockQuoteWithMetadata, + quote: { feeData: { metabridge: { quoteBpsFee: 87.5 } } }, + }, + })); + + const { getByText } = renderFooter(buildActiveQuoteState()); + + await waitFor(() => { + expect( + getByText(strings('bridge.fee_disclaimer', { feePercentage }), { + exact: false, + }), + ).toBeTruthy(); + }); + }); + + it('shows no MM fee disclaimer when dest token is mUSD and fee is zero', async () => { + const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da' as Hex; + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + isLoading: false, + activeQuote: { + ...(mockQuoteWithMetadata as unknown as QuoteResponse), + quote: { feeData: { metabridge: { quoteBpsFee: 0 } } }, + } as unknown as QuoteResponse, + })); + + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuoteWithMetadata as unknown as QuoteResponse], + quotesLastFetched: 12, + }, + bridgeReducerOverrides: { + sourceAmount: '1.0', + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + image: '', + name: 'Ether', + symbol: 'ETH', + }, + destToken: { + address: musdAddress, + chainId: '0x1' as Hex, + decimals: 6, + image: '', + name: 'MetaMask USD', + symbol: 'mUSD', + }, + }, + }); + + const { getByText } = renderFooter(testState as DeepPartial); + + await waitFor(() => { + expect( + getByText( + strings('bridge.no_mm_fee_disclaimer', { destTokenSymbol: 'mUSD' }), + ), + ).toBeTruthy(); + }); + }); + }); + + describe('Approval Disclaimer', () => { + it('displays approval needed text when quote requires approval', async () => { + const mockQuote = { + ...mockQuoteWithMetadata, + approval: { + chainId: '0x1', + to: '0xToken', + data: '0xApprovalData', + }, + }; + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockQuote, + })); + + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuote as unknown as QuoteResponse], + quotesLastFetched: Date.now(), + }, + bridgeReducerOverrides: { + sourceAmount: '1.5', + sourceToken: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: '0x1' as Hex, + decimals: 6, + image: '', + name: 'USD Coin', + symbol: 'USDC', + }, + }, + }); + + const { getByText } = renderFooter(testState as DeepPartial); + + await waitFor(() => { + const approvalText = strings('bridge.approval_needed', { + amount: '1.5', + symbol: 'USDC', + }); + expect(getByText(approvalText, { exact: false })).toBeTruthy(); + }); + }); + + it('displays approval tooltip when quote requires approval', async () => { + const mockQuote = { + ...mockQuoteWithMetadata, + approval: { + chainId: '0x1', + to: '0xToken', + data: '0xApprovalData', + }, + }; + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockQuote, + })); + + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuote as unknown as QuoteResponse], + quotesLastFetched: Date.now(), + }, + bridgeReducerOverrides: { + sourceAmount: '1.5', + sourceToken: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: '0x1' as Hex, + decimals: 6, + image: '', + name: 'USD Coin', + symbol: 'USDC', + }, + }, + }); + + const { getByTestId } = renderFooter(testState as DeepPartial); + + await waitFor(() => { + expect(getByTestId('approval-tooltip')).toBeTruthy(); + }); + }); + + it('does not display approval text when quote does not require approval', async () => { + const mockQuote = { + ...mockQuoteWithMetadata, + approval: null, + }; + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockQuote, + })); + + const { queryByText } = renderFooter(buildActiveQuoteState()); + + await waitFor(() => { + expect(queryByText(/approval needed/i, { exact: false })).toBeNull(); + }); + }); + + it('does not display approval tooltip when quote does not require approval', async () => { + const mockQuote = { + ...mockQuoteWithMetadata, + approval: null, + }; + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockQuote, + })); + + const { queryByTestId } = renderFooter(buildActiveQuoteState()); + + await waitFor(() => { + expect(queryByTestId('approval-tooltip')).toBeNull(); + }); + }); + }); +}); diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.tsx new file mode 100644 index 00000000000..b6d27e1cd2e --- /dev/null +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { Box } from '../../../Box/Box'; +import { FlexDirection, AlignItems } from '../../../Box/box.types'; +import { useLatestBalance } from '../../hooks/useLatestBalance'; +import { + selectSourceAmount, + selectDestToken, + selectSourceToken, + selectBridgeControllerState, + selectIsSolanaSourced, +} from '../../../../../core/redux/slices/bridge'; +import { strings } from '../../../../../../locales/i18n'; +import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData'; +import BannerAlert from '../../../../../component-library/components/Banners/Banner/variants/BannerAlert'; +import { BannerAlertSeverity } from '../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.types'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../../../selectors/accountsController'; +import { isHardwareAccount } from '../../../../../util/address'; +import ApprovalTooltip from '../../components/ApprovalText'; +import { + BRIDGE_MM_FEE_RATE, + MetaMetricsSwapsEventSource, +} from '@metamask/bridge-controller'; +import { isNullOrUndefined } from '@metamask/utils'; +import { SwapsConfirmButton } from '../../components/SwapsConfirmButton/index.tsx'; +import { useStyles } from '../../../../../component-library/hooks/useStyles.ts'; +import { createStyles } from './BridgeView.styles.ts'; +import { + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { BridgeViewSelectorsIDs } from './BridgeView.testIds.ts'; + +interface Props { + latestSourceBalance: ReturnType; + location: MetaMetricsSwapsEventSource; +} + +export const BridgeViewFooter = ({ latestSourceBalance, location }: Props) => { + const { styles } = useStyles(createStyles); + const sourceAmount = useSelector(selectSourceAmount); + const sourceToken = useSelector(selectSourceToken); + const destToken = useSelector(selectDestToken); + const { quotesLastFetched } = useSelector(selectBridgeControllerState); + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + const isSolanaSourced = useSelector(selectIsSolanaSourced); + + const { activeQuote, isLoading, blockaidError, needsNewQuote } = + useBridgeQuoteData({ + latestSourceAtomicBalance: latestSourceBalance?.atomicBalance, + }); + + const isValidSourceAmount = + sourceAmount !== undefined && sourceAmount !== '.' && sourceToken?.decimals; + + const isHardwareAddress = selectedAddress + ? !!isHardwareAccount(selectedAddress) + : false; + + if (isLoading && !activeQuote && !needsNewQuote) { + return null; + } + + if (needsNewQuote) { + return ( + + + + ); + } + + if (!activeQuote) { + return null; + } + + // TODO: remove this once controller types are updated + // @ts-expect-error: controller types are not up to date yet + const quoteBpsFee = activeQuote?.quote?.feeData?.metabridge?.quoteBpsFee; + const feePercentage = !isNullOrUndefined(quoteBpsFee) + ? quoteBpsFee / 100 + : BRIDGE_MM_FEE_RATE; + + const hasFee = activeQuote && feePercentage > 0; + + const approval = + activeQuote?.approval && sourceAmount && sourceToken + ? { amount: sourceAmount, symbol: sourceToken.symbol } + : null; + + return ( + isValidSourceAmount && + activeQuote && + quotesLastFetched && ( + + {isHardwareAddress && isSolanaSourced && ( + + )} + {blockaidError && ( + + )} + + + + + {hasFee + ? strings('bridge.fee_disclaimer', { + feePercentage, + }) + : strings('bridge.no_mm_fee_disclaimer', { + destTokenSymbol: destToken?.symbol, + })} + {approval + ? ` ${strings('bridge.approval_needed', approval)}` + : ''}{' '} + + {approval && ( + + )} + + + ) + ); +}; diff --git a/app/components/UI/Bridge/Views/BridgeView/index.tsx b/app/components/UI/Bridge/Views/BridgeView/index.tsx index a4c641bb749..61945c66f4a 100644 --- a/app/components/UI/Bridge/Views/BridgeView/index.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/index.tsx @@ -15,11 +15,6 @@ import { } from '../../components/TokenInputArea'; import { useStyles } from '../../../../../component-library/hooks'; import { Box } from '../../../Box/Box'; -import { FlexDirection, AlignItems } from '../../../Box/box.types'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; import { getNetworkImageSource } from '../../../../../util/networks'; import { useLatestBalance } from '../../hooks/useLatestBalance'; import { @@ -30,7 +25,6 @@ import { resetBridgeState, selectDestToken, selectSourceToken, - selectBridgeControllerState, selectIsEvmNonEvmBridge, selectIsSubmittingTx, selectDestAddress, @@ -75,10 +69,8 @@ import { endTrace, TraceName } from '../../../../../util/trace.ts'; import { useInitialSlippage } from '../../hooks/useInitialSlippage/index.ts'; import { useHasSufficientGas } from '../../hooks/useHasSufficientGas/index.ts'; import { useRecipientInitialization } from '../../hooks/useRecipientInitialization'; -import ApprovalTooltip from '../../components/ApprovalText'; -import { BRIDGE_MM_FEE_RATE } from '@metamask/bridge-controller'; import { selectSourceWalletAddress } from '../../../../../selectors/bridge'; -import { isNullOrUndefined, Hex } from '@metamask/utils'; +import { Hex } from '@metamask/utils'; import { useBridgeQuoteEvents } from '../../hooks/useBridgeQuoteEvents/index.ts'; import { SwapsKeypad } from '../../components/SwapsKeypad/index.tsx'; import { getGasFeesSponsoredNetworkEnabled } from '../../../../../selectors/featureFlagController/gasFeesSponsored'; @@ -93,13 +85,13 @@ import { SwapsKeypadRef } from '../../components/SwapsKeypad/types.ts'; import { GaslessQuickPickOptions } from '../../components/GaslessQuickPickOptions/index.tsx'; import { SwapsConfirmButton } from '../../components/SwapsConfirmButton/index.tsx'; import { useBridgeViewOnFocus } from '../../hooks/useBridgeViewOnFocus/index.ts'; -import { useRenderQuoteExpireModal } from '../../hooks/useRenderQuoteExpireModal/index.ts'; import { type BridgeRouteParams } from '../../hooks/useSwapBridgeNavigation/index.ts'; import BridgeTrendingTokensSection from '../../components/BridgeTrendingTokensSection/BridgeTrendingTokensSection'; import { selectRemoteFeatureFlags } from '../../../../../selectors/featureFlagController'; import type { RootState } from '../../../../../reducers'; const SCROLL_NEAR_BOTTOM_PX = 160; import { useTrackSwapPageViewed } from '../../hooks/useTrackSwapPageViewed/index.ts'; +import { BridgeViewFooter } from './BridgeViewFooter.tsx'; const BridgeView = () => { const [isErrorBannerVisible, setIsErrorBannerVisible] = useState(true); @@ -130,7 +122,6 @@ const BridgeView = () => { const destChainId = useSelector(selectSelectedDestChainId); const destAddress = useSelector(selectDestAddress); const bridgeViewMode = useSelector(selectBridgeViewMode); - const { quotesLastFetched } = useSelector(selectBridgeControllerState); const { handleSwitchTokens } = useSwitchTokens(); const { isStockToken } = useRWAToken(); const selectedAddress = useSelector( @@ -210,6 +201,7 @@ const BridgeView = () => { isNoQuotesAvailable, blockaidError, shouldShowPriceImpactWarning, + needsNewQuote, } = useBridgeQuoteData({ latestSourceAtomicBalance: latestSourceBalance?.atomicBalance, }); @@ -270,8 +262,6 @@ const BridgeView = () => { // Compute error state directly from dependencies const isError = isNoQuotesAvailable || quoteFetchError; - // Always show quote details when there's an active quote - const shouldDisplayQuoteDetails = !!activeQuote; const isZeroState = !sourceAmount || !(Number(sourceAmount) > 0); // Update quote parameters when relevant state changes @@ -341,8 +331,6 @@ const BridgeView = () => { type: 'dest', }); - useRenderQuoteExpireModal({ inputRef, latestSourceBalance }); - const isRWATokenSelected = useMemo( () => (sourceToken && isStockToken(sourceToken as BridgeToken)) || @@ -354,11 +342,10 @@ const BridgeView = () => { : strings('bridge.error_banner_description'); const getContentMode = () => { - if (isLoading && !activeQuote) return 'loading'; + if (isLoading && !activeQuote && !needsNewQuote) return 'loading'; if (isError && isErrorBannerVisible) return 'error'; - if (shouldDisplayQuoteDetails) return 'quote'; if (isZeroState) return 'zero'; - return 'none'; + return 'quote'; }; const contentMode = getContentMode(); @@ -374,83 +361,6 @@ const BridgeView = () => { [], ); - const renderBottomContent = () => { - if (isLoading && !activeQuote) { - return null; - } - - // Prevent bottom section from rendering when no active - // quotes exist and none are being fetching. - // This resolves edge cases when users are redirected back from - // Select Quote page due to quotes expiry. - if (!activeQuote) { - return null; - } - - // TODO: remove this once controller types are updated - // @ts-expect-error: controller types are not up to date yet - const quoteBpsFee = activeQuote?.quote?.feeData?.metabridge?.quoteBpsFee; - const feePercentage = !isNullOrUndefined(quoteBpsFee) - ? quoteBpsFee / 100 - : BRIDGE_MM_FEE_RATE; - - const hasFee = activeQuote && feePercentage > 0; - - const approval = - activeQuote?.approval && sourceAmount && sourceToken - ? { amount: sourceAmount, symbol: sourceToken.symbol } - : null; - - return ( - isValidSourceAmount && - activeQuote && - quotesLastFetched && ( - - {isHardwareAddress && isSolanaSourced && ( - - )} - {blockaidError && ( - - )} - - - - - {hasFee - ? strings('bridge.fee_disclaimer', { - feePercentage, - }) - : strings('bridge.no_mm_fee_disclaimer', { - destTokenSymbol: destToken?.symbol, - })} - {approval - ? ` ${strings('bridge.approval_needed', approval)}` - : ''}{' '} - - {approval && ( - - )} - - - ) - ); - }; - return ( // Need this to be full height of screen // @ts-expect-error The type is incorrect, this will work @@ -553,7 +463,10 @@ const BridgeView = () => { - {renderBottomContent()} + ({ useBridgeQuoteData: jest.fn(), })); -jest.mock('../../hooks/useModalCloseOnQuoteExpiry', () => ({ - useModalCloseOnQuoteExpiry: jest.fn(), -})); - jest.mock('../../hooks/usePriceImpactViewData', () => ({ usePriceImpactViewData: jest.fn(), })); @@ -146,7 +142,6 @@ import { useParams } from '../../../../../util/navigation/navUtils'; import { useLatestBalance } from '../../hooks/useLatestBalance'; import { useBridgeConfirm } from '../../hooks/useBridgeConfirm'; import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData'; -import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; import { usePriceImpactViewData } from '../../hooks/usePriceImpactViewData'; import { PriceImpactHeader } from './PriceImpactHeader'; import { PriceImpactDescription } from './PriceImpactDescription'; @@ -162,10 +157,6 @@ const mockUseBridgeConfirm = useBridgeConfirm as jest.MockedFunction< const mockUseBridgeQuoteData = useBridgeQuoteData as jest.MockedFunction< typeof useBridgeQuoteData >; -const mockUseModalCloseOnQuoteExpiry = - useModalCloseOnQuoteExpiry as jest.MockedFunction< - typeof useModalCloseOnQuoteExpiry - >; const mockUsePriceImpactViewData = usePriceImpactViewData as jest.MockedFunction; const mockPriceImpactHeader = PriceImpactHeader as jest.MockedFunction< @@ -218,20 +209,6 @@ describe('PriceImpactModal', () => { jest.clearAllMocks(); }); - describe('useModalCloseOnQuoteExpiry', () => { - it('calls useModalCloseOnQuoteExpiry on render', () => { - render(); - - expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalled(); - }); - - it('calls useModalCloseOnQuoteExpiry exactly once per render', () => { - render(); - - expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalledTimes(1); - }); - }); - describe('component structure', () => { it('renders PriceImpactHeader', () => { const { getByTestId } = render(); diff --git a/app/components/UI/Bridge/components/PriceImpactModal/index.tsx b/app/components/UI/Bridge/components/PriceImpactModal/index.tsx index 1a154f71670..f9c915ae963 100644 --- a/app/components/UI/Bridge/components/PriceImpactModal/index.tsx +++ b/app/components/UI/Bridge/components/PriceImpactModal/index.tsx @@ -10,7 +10,6 @@ import { PriceImpactDescription } from './PriceImpactDescription'; import { PriceImpactFooter } from './PriceImpactFooter'; import { useLatestBalance } from '../../hooks/useLatestBalance'; import { useBridgeConfirm } from '../../hooks/useBridgeConfirm'; -import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; import { usePriceImpactViewData } from '../../hooks/usePriceImpactViewData'; export const PriceImpactModal = () => { @@ -42,8 +41,6 @@ export const PriceImpactModal = () => { await confirmBridge(); }, [confirmBridge]); - useModalCloseOnQuoteExpiry(); - return ( = ({ const priceImpactIsSafe = !activeQuote?.quote.priceData?.priceImpact || Number(activeQuote.quote.priceData.priceImpact) <= - // @ts-expect-error TODO: remove comment after changes to core are published. (bridgeFeatureFlags?.priceImpactThreshold?.warning ?? AppConstants.BRIDGE.PRICE_IMPACT_WARNING_THRESHOLD); diff --git a/app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.styles.ts b/app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.styles.ts deleted file mode 100644 index 47c9df540b0..00000000000 --- a/app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.styles.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { StyleSheet } from 'react-native'; - -const createStyles = () => - StyleSheet.create({ - container: { - paddingHorizontal: 16, - }, - footer: { - paddingHorizontal: 16, - paddingTop: 24, - paddingBottom: 16, - }, - }); - -export default createStyles; diff --git a/app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.test.tsx b/app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.test.tsx deleted file mode 100644 index 1ce4c085404..00000000000 --- a/app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import '../../_mocks_/initialState'; -import React from 'react'; -import { SafeAreaProvider } from 'react-native-safe-area-context'; -import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import QuoteExpiredModal from './QuoteExpiredModal'; -import Engine from '../../../../../core/Engine'; -import { fireEvent } from '@testing-library/react-native'; - -const mockNavigate = jest.fn(); -const mockGoBack = jest.fn(); -const mockUpdateQuoteParams = jest.fn(); - -jest.mock('@react-navigation/native', () => { - const actualReactNavigation = jest.requireActual('@react-navigation/native'); - return { - ...actualReactNavigation, - useNavigation: jest.fn(() => ({ - navigate: mockNavigate, - goBack: mockGoBack, - })), - }; -}); - -jest.mock('../../hooks/useBridgeQuoteRequest', () => ({ - useBridgeQuoteRequest: () => mockUpdateQuoteParams, -})); - -jest.mock('../../utils/quoteUtils', () => ({ - getQuoteRefreshRate: jest.fn(() => 15000), -})); - -const initialMetrics = { - frame: { x: 0, y: 0, width: 320, height: 640 }, - insets: { top: 0, left: 0, right: 0, bottom: 0 }, -}; - -const renderQuoteExpiredModal = () => - renderWithProvider( - - - , - ); - -describe('QuoteExpiredModal', () => { - beforeEach(() => { - jest.clearAllMocks(); - // Mock BridgeController with minimal required properties - Engine.context.BridgeController = { - resetState: jest.fn(), - } as unknown as typeof Engine.context.BridgeController; - }); - - it('renders correctly', () => { - const { toJSON } = renderQuoteExpiredModal(); - expect(toJSON()).toMatchSnapshot(); - }); - - it('resets BridgeController state, updates quote params, and closes modal when get new quote button is pressed', () => { - const { getByTestId } = renderQuoteExpiredModal(); - const getNewQuoteButton = getByTestId('bottomsheetfooter-button'); - fireEvent.press(getNewQuoteButton); - - expect(Engine.context.BridgeController.resetState).toHaveBeenCalled(); - expect(mockUpdateQuoteParams).toHaveBeenCalled(); - expect(mockGoBack).toHaveBeenCalled(); - }); - - it('handles missing BridgeController gracefully', () => { - // Remove BridgeController mock - Engine.context.BridgeController = - undefined as unknown as typeof Engine.context.BridgeController; - - const { getByTestId } = renderQuoteExpiredModal(); - const getNewQuoteButton = getByTestId('bottomsheetfooter-button'); - fireEvent.press(getNewQuoteButton); - - expect(mockUpdateQuoteParams).toHaveBeenCalled(); - expect(mockGoBack).toHaveBeenCalled(); - }); -}); diff --git a/app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.tsx b/app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.tsx deleted file mode 100644 index 494904e1622..00000000000 --- a/app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import { View } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { strings } from '../../../../../../locales/i18n'; -import BottomSheet, { - BottomSheetRef, -} from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; -import BottomSheetFooter from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; -import Text, { - TextVariant, - TextColor, -} from '../../../../../component-library/components/Texts/Text'; -import { - ButtonSize, - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; -import { useStyles } from '../../../../../component-library/hooks'; -import createStyles from './QuoteExpiredModal.styles'; -import { useBridgeQuoteRequest } from '../../hooks/useBridgeQuoteRequest'; -import Engine from '../../../../../core/Engine'; -import { - selectBridgeFeatureFlags, - selectSourceToken, - setIsSubmittingTx, -} from '../../../../../core/redux/slices/bridge'; -import { useDispatch, useSelector } from 'react-redux'; -import { getQuoteRefreshRate } from '../../utils/quoteUtils'; - -const QuoteExpiredModal = () => { - const navigation = useNavigation(); - const sheetRef = useRef(null); - const { styles } = useStyles(createStyles, {}); - const updateQuoteParams = useBridgeQuoteRequest(); - const dispatch = useDispatch(); - const sourceToken = useSelector(selectSourceToken); - const bridgeFeatureFlags = useSelector(selectBridgeFeatureFlags); - const refreshRate = - getQuoteRefreshRate(bridgeFeatureFlags, sourceToken) / 1000; - - const handleClose = () => { - navigation.goBack(); - }; - - const handleGetNewQuote = () => { - dispatch(setIsSubmittingTx(false)); - // Reset bridge controller state - if (Engine.context.BridgeController?.resetState) { - Engine.context.BridgeController.resetState(); - } - // Update quote params to fetch new quote - updateQuoteParams(); - // Close the modal - navigation.goBack(); - }; - - useEffect(() => { - // Stop polling when modal opens - if (Engine.context.BridgeController?.stopAllPolling) { - Engine.context.BridgeController.stopAllPolling(); - } - }, []); - - const footerButtonProps = [ - { - label: strings('quote_expired_modal.get_new_quote'), - variant: ButtonVariants.Primary, - size: ButtonSize.Lg, - onPress: handleGetNewQuote, - }, - ]; - - return ( - - - - - {strings('quote_expired_modal.description', { - refreshRate, - })} - - - - - ); -}; - -export default QuoteExpiredModal; diff --git a/app/components/UI/Bridge/components/QuoteExpiredModal/__snapshots__/QuoteExpiredModal.test.tsx.snap b/app/components/UI/Bridge/components/QuoteExpiredModal/__snapshots__/QuoteExpiredModal.test.tsx.snap deleted file mode 100644 index 2944fd3a077..00000000000 --- a/app/components/UI/Bridge/components/QuoteExpiredModal/__snapshots__/QuoteExpiredModal.test.tsx.snap +++ /dev/null @@ -1,351 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`QuoteExpiredModal renders correctly 1`] = ` - - - - - - - - - - - - - - - - - - New quotes are available - - - - - - - - - - - - - - Rates update every 15 seconds, so tap Get new quote when you're ready. - - - - - - Get new quote - - - - - - - -`; diff --git a/app/components/UI/Bridge/components/QuoteExpiredModal/index.ts b/app/components/UI/Bridge/components/QuoteExpiredModal/index.ts deleted file mode 100644 index 853829df5a0..00000000000 --- a/app/components/UI/Bridge/components/QuoteExpiredModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './QuoteExpiredModal'; diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/index.test.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/index.test.tsx index c7df7f9dd6e..0b9a494c156 100644 --- a/app/components/UI/Bridge/components/QuoteSelectorView/index.test.tsx +++ b/app/components/UI/Bridge/components/QuoteSelectorView/index.test.tsx @@ -597,7 +597,9 @@ describe('QuoteSelectorView', () => { expect(mockGoBack).toHaveBeenCalled(); }); - it('navigates back when quotes are expired and not loading', () => { + it('does not navigate back when quotes are expired and not loading', () => { + // When quotes expire the view keeps showing cached data (the Redux quotes + // are still present) so there is no reason to dismiss the selector. mockUseBridgeQuoteData.mockReturnValue({ validQuotes: [], bestQuote: null, @@ -609,7 +611,7 @@ describe('QuoteSelectorView', () => { render(); - expect(mockGoBack).toHaveBeenCalled(); + expect(mockGoBack).not.toHaveBeenCalled(); }); it('navigates back when loading and error exists', () => { diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/index.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/index.tsx index a416c12c30d..1813e8d0a54 100644 --- a/app/components/UI/Bridge/components/QuoteSelectorView/index.tsx +++ b/app/components/UI/Bridge/components/QuoteSelectorView/index.tsx @@ -34,15 +34,8 @@ export const QuoteSelectorView = () => { const dispatch = useDispatch(); const selectedQuoteRequestId = useSelector(selectSelectedQuoteRequestId); const currency = useSelector(selectCurrentCurrency); - const { - validQuotes, - bestQuote, - isLoading, - blockaidError, - quoteFetchError, - isExpired, - willRefresh, - } = useBridgeQuoteData(); + const { validQuotes, bestQuote, isLoading, blockaidError, quoteFetchError } = + useBridgeQuoteData(); const sourceToken = useSelector(selectSourceToken); const destToken = useSelector(selectDestToken); const latestSourceBalance = useLatestBalance({ @@ -131,10 +124,10 @@ export const QuoteSelectorView = () => { // Go back to bridge view only if there's an error or quotes are expired useEffect(() => { - if (quoteFetchError || blockaidError || (isExpired && !willRefresh)) { + if (quoteFetchError || blockaidError) { navigation.goBack(); } - }, [quoteFetchError, blockaidError, isExpired, navigation, willRefresh]); + }, [quoteFetchError, blockaidError, navigation]); return ( diff --git a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx index 6780302c682..15f5f0cceb7 100644 --- a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx +++ b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx @@ -140,10 +140,6 @@ jest.mock('../../hooks/useSlippageConfig', () => ({ useSlippageConfig: jest.fn(), })); -jest.mock('../../hooks/useModalCloseOnQuoteExpiry', () => ({ - useModalCloseOnQuoteExpiry: jest.fn(), -})); - jest.mock('../../hooks/useShouldDisableCustomSlippageConfirm', () => ({ useShouldDisableCustomSlippageConfirm: jest.fn(), })); @@ -184,15 +180,11 @@ import { useSlippageStepperDescription } from '../../hooks/useSlippageStepperDes import { useParams } from '../../../../../util/navigation/navUtils'; import { InputStepper } from '../InputStepper'; import Keypad from '../../../../Base/Keypad'; -import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; const mockUseSlippageConfig = useSlippageConfig as jest.MockedFunction< typeof useSlippageConfig >; -const mockUseModalCloseOnQuoteExpiry = - useModalCloseOnQuoteExpiry as jest.MockedFunction< - typeof useModalCloseOnQuoteExpiry - >; + const mockUseShouldDisableCustomSlippageConfirm = useShouldDisableCustomSlippageConfirm as jest.MockedFunction< typeof useShouldDisableCustomSlippageConfirm @@ -955,20 +947,6 @@ describe('CustomSlippageModal', () => { }); }); - describe('useModalCloseOnQuoteExpiry', () => { - it('calls useModalCloseOnQuoteExpiry on render', () => { - render(); - - expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalled(); - }); - - it('calls useModalCloseOnQuoteExpiry exactly once per render', () => { - render(); - - expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalledTimes(1); - }); - }); - describe('handleClose functionality', () => { it('closes modal via header close button', () => { const { getByLabelText } = render(); diff --git a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx index 243b1aa1849..b94daaa162a 100644 --- a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx +++ b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx @@ -23,11 +23,9 @@ import { import { useDispatch, useSelector } from 'react-redux'; import { useSlippageStepperDescription } from '../../hooks/useSlippageStepperDescription'; import { useShouldDisableCustomSlippageConfirm } from '../../hooks/useShouldDisableCustomSlippageConfirm'; -import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; export const CustomSlippageModal = () => { const dispatch = useDispatch(); - useModalCloseOnQuoteExpiry(); const sheetRef = useRef(null); const { sourceChainId, destChainId } = useParams(); diff --git a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx index 774e5839ded..fc5ba455bef 100644 --- a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx +++ b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx @@ -74,10 +74,6 @@ jest.mock('../../hooks/useSlippageConfig', () => ({ useSlippageConfig: jest.fn(), })); -jest.mock('../../hooks/useModalCloseOnQuoteExpiry', () => ({ - useModalCloseOnQuoteExpiry: jest.fn(), -})); - jest.mock('../../../../../util/navigation/navUtils', () => ({ useParams: jest.fn(), })); @@ -119,7 +115,6 @@ import { useGetSlippageOptions } from '../../hooks/useGetSlippageOptions'; import { useSlippageConfig } from '../../hooks/useSlippageConfig'; import { useParams } from '../../../../../util/navigation/navUtils'; import { AUTO_SLIPPAGE_VALUE } from './constants'; -import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; const mockUseGetSlippageOptions = useGetSlippageOptions as jest.MockedFunction< typeof useGetSlippageOptions @@ -128,10 +123,6 @@ const mockUseSlippageConfig = useSlippageConfig as jest.MockedFunction< typeof useSlippageConfig >; const mockUseParams = useParams as jest.MockedFunction; -const mockUseModalCloseOnQuoteExpiry = - useModalCloseOnQuoteExpiry as jest.MockedFunction< - typeof useModalCloseOnQuoteExpiry - >; describe('DefaultSlippageModal', () => { const mockSlippageConfig = { @@ -644,20 +635,6 @@ describe('DefaultSlippageModal', () => { }); }); - describe('useModalCloseOnQuoteExpiry', () => { - it('calls useModalCloseOnQuoteExpiry on render', () => { - render(); - - expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalled(); - }); - - it('calls useModalCloseOnQuoteExpiry exactly once per render', () => { - render(); - - expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalledTimes(1); - }); - }); - describe('auto slippage behavior', () => { it('dispatches undefined for auto slippage on submit', () => { mockSelector.mockReturnValue(undefined); diff --git a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx index 9dda2b94e51..2d34b5caa77 100644 --- a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx +++ b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx @@ -26,12 +26,10 @@ import { DefaultSlippageModalParams } from './types'; import { useParams } from '../../../../../util/navigation/navUtils'; import { useSlippageConfig } from '../../hooks/useSlippageConfig'; import { SlippageType } from '../../types'; -import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; export const DefaultSlippageModal = () => { const navigation = useNavigation(); const dispatch = useDispatch(); - useModalCloseOnQuoteExpiry(); const sheetRef = useRef(null); const slippage = useSelector(selectSlippage); const [selectedSlippage, setSelectedSlippage] = useState( diff --git a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx index 39cc23710d2..3717032b41a 100644 --- a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx +++ b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx @@ -169,11 +169,6 @@ jest.mock('../../../../../util/address', () => ({ isHardwareAccount: jest.fn(), })); -// Mock Skeleton component to prevent animation -jest.mock('../../../../../component-library/components/Skeleton', () => ({ - Skeleton: () => null, -})); - jest.mock('react-native-fade-in-image', () => { const ReactModule = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); @@ -781,7 +776,7 @@ describe('SwapsConfirmButton', () => { .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ ...mockUseBridgeQuoteData, - isExpired: true, + needsNewQuote: true, isLoading: false, })); @@ -805,7 +800,7 @@ describe('SwapsConfirmButton', () => { .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ ...mockUseBridgeQuoteData, - isExpired: true, + needsNewQuote: true, isLoading: false, })); @@ -828,7 +823,7 @@ describe('SwapsConfirmButton', () => { .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ ...mockUseBridgeQuoteData, - isExpired: true, + needsNewQuote: true, isLoading: false, })); @@ -858,7 +853,7 @@ describe('SwapsConfirmButton', () => { .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ ...mockUseBridgeQuoteData, - isExpired: true, + needsNewQuote: true, isLoading: true, activeQuote: null, })); @@ -873,7 +868,7 @@ describe('SwapsConfirmButton', () => { }, ); - // needsNewQuote is true because there is no active quote + // needsNewQuote is true because the hook computed it from isExpired=true with no activeQuote expect( getByText(strings('quote_expired_modal.get_new_quote')), ).toBeTruthy(); @@ -887,7 +882,7 @@ describe('SwapsConfirmButton', () => { .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ ...mockUseBridgeQuoteData, - isExpired: true, + needsNewQuote: false, isLoading: true, activeQuote: mockActiveQuote, })); @@ -902,7 +897,7 @@ describe('SwapsConfirmButton', () => { }, ); - // needsNewQuote is false because activeQuote exists and isLoading is true + // needsNewQuote is false because hook suppresses it when activeQuote exists and isLoading is true expect( queryByText(strings('quote_expired_modal.get_new_quote')), ).toBeNull(); @@ -913,7 +908,7 @@ describe('SwapsConfirmButton', () => { .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ ...mockUseBridgeQuoteData, - isExpired: true, + needsNewQuote: false, isLoading: false, })); @@ -935,7 +930,7 @@ describe('SwapsConfirmButton', () => { }, ); - // needsNewQuote is false because isSubmittingTx is true + // needsNewQuote is false because the hook suppresses it when isSubmittingTx is true expect( queryByText(strings('quote_expired_modal.get_new_quote')), ).toBeNull(); @@ -962,11 +957,7 @@ describe('SwapsConfirmButton', () => { await act(async () => { await waitFor(() => { expect(mockSubmitBridgeTx).toHaveBeenCalledWith({ - quoteResponse: { - ...mockActiveQuote, - aggregator: mockActiveQuote.quote.bridgeId, - walletAddress: '0x1234567890123456789012345678901234567890', - }, + quoteResponse: mockActiveQuote, location: 'Main View', }); expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); @@ -1025,11 +1016,7 @@ describe('SwapsConfirmButton', () => { await act(async () => { await waitFor(() => { expect(mockSubmitBridgeTx).toHaveBeenCalledWith({ - quoteResponse: { - ...solanaActiveQuote, - aggregator: solanaActiveQuote.quote.bridgeId, - walletAddress: '0x1234567890123456789012345678901234567890', - }, + quoteResponse: solanaActiveQuote, location: 'Main View', }); expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); diff --git a/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx b/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx index 904a7179eb3..be12f21f417 100644 --- a/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx +++ b/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx @@ -72,7 +72,7 @@ export const SwapsConfirmButton = ({ const { activeQuote, isLoading, - isExpired, + needsNewQuote, blockaidError, quoteFetchError, isNoQuotesAvailable, @@ -82,13 +82,6 @@ export const SwapsConfirmButton = ({ const hasSufficientGas = useHasSufficientGas({ quote: activeQuote }); - // The quote expired and no fetch is in progress — offer to get a new one. - // Also treat the edge-case where a fetch IS running but there is no active - // quote to fall back on — the user would otherwise be stuck on a spinner - // with no way to retry ("escape hatch"). - const needsNewQuote = - isExpired && !isSubmittingTx && (!isLoading || !activeQuote); - // Check both the display amount and the atomic amount are non-zero. // An amount like 0.000000001 BTC (8 decimals) is non-zero as a number but // resolves to 0 satoshis, meaning no quote will be fetched. @@ -164,7 +157,6 @@ export const SwapsConfirmButton = ({ if ( Number.isFinite(priceImpact) && priceImpact >= - // @ts-expect-error TODO: remove comment after changes to core are published. (bridgeFeatureFlags?.priceImpactThreshold?.error ?? AppConstants.BRIDGE.PRICE_IMPACT_ERROR_THRESHOLD) ) { diff --git a/app/components/UI/Bridge/components/TokenSelectorItem.abTestConfig.ts b/app/components/UI/Bridge/components/TokenSelectorItem.abTestConfig.ts new file mode 100644 index 00000000000..17b2db140d8 --- /dev/null +++ b/app/components/UI/Bridge/components/TokenSelectorItem.abTestConfig.ts @@ -0,0 +1,26 @@ +export const TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY = + 'swapsSWAPS4242AbtestTokenSelectorBalanceLayout'; + +export enum TokenSelectorBalanceLayoutVariant { + Control = 'control', + Treatment = 'treatment', +} + +interface TokenSelectorBalanceLayoutConfig { + showTokenBalanceFirst: boolean; + removeTickerFromTokenBalance: boolean; +} + +export const TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS: Record< + TokenSelectorBalanceLayoutVariant, + TokenSelectorBalanceLayoutConfig +> = { + [TokenSelectorBalanceLayoutVariant.Control]: { + showTokenBalanceFirst: false, + removeTickerFromTokenBalance: false, + }, + [TokenSelectorBalanceLayoutVariant.Treatment]: { + showTokenBalanceFirst: true, + removeTickerFromTokenBalance: true, + }, +}; diff --git a/app/components/UI/Bridge/components/TokenSelectorItem.test.tsx b/app/components/UI/Bridge/components/TokenSelectorItem.test.tsx index 5f03b70aaa4..53529bd157a 100644 --- a/app/components/UI/Bridge/components/TokenSelectorItem.test.tsx +++ b/app/components/UI/Bridge/components/TokenSelectorItem.test.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; +import { Text as RNText } from 'react-native'; import { TokenSelectorItem } from './TokenSelectorItem'; import { ethers } from 'ethers'; +import { useABTest } from '../../../../hooks'; import { createMockTokenWithBalance } from '../testUtils/fixtures'; import { TOKEN_BALANCE_LOADING, @@ -13,6 +15,10 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(() => []), })); +jest.mock('../../../../hooks', () => ({ + useABTest: jest.fn(), +})); + jest.mock('../../../../../locales/i18n', () => ({ strings: (key: string) => { const translations: Record = { @@ -91,9 +97,18 @@ jest.mock('../../../../component-library/components/Tags/Tag', () => { describe('TokenSelectorItem', () => { const mockOnPress = jest.fn(); + const mockUseABTest = jest.mocked(useABTest); beforeEach(() => { jest.clearAllMocks(); + mockUseABTest.mockReturnValue({ + variant: { + showTokenBalanceFirst: false, + removeTickerFromTokenBalance: false, + }, + variantName: 'control', + isActive: false, + }); }); describe('rendering', () => { @@ -382,4 +397,56 @@ describe('TokenSelectorItem', () => { expect(fiatBalanceElement.props.numberOfLines).toBe(1); }); }); + + describe('A/B variants', () => { + it('keeps fiat above token balance in the control layout', () => { + const token = createMockTokenWithBalance({ + balance: '50.0', + balanceFiat: '$500', + symbol: 'USDC', + }); + + const controlRender = render( + , + ); + expect(controlRender.getByText('50 USDC')).toBeOnTheScreen(); + + const controlTextOrder = controlRender + .UNSAFE_getAllByType(RNText) + .map((textNode) => String(textNode.props.children)); + expect(controlTextOrder.indexOf('$500')).toBeLessThan( + controlTextOrder.indexOf('50 USDC'), + ); + }); + + it('shows token balance first without the ticker in the treatment layout', () => { + mockUseABTest.mockReturnValue({ + variant: { + showTokenBalanceFirst: true, + removeTickerFromTokenBalance: true, + }, + variantName: 'treatment', + isActive: true, + }); + + const token = createMockTokenWithBalance({ + balance: '50.0', + balanceFiat: '$500', + symbol: 'USDC', + }); + + const treatmentRender = render( + , + ); + expect(treatmentRender.getByText('50')).toBeOnTheScreen(); + expect(treatmentRender.queryByText('50 USDC')).not.toBeOnTheScreen(); + + const treatmentTextOrder = treatmentRender + .UNSAFE_getAllByType(RNText) + .map((textNode) => String(textNode.props.children)); + expect(treatmentTextOrder.indexOf('50')).toBeLessThan( + treatmentTextOrder.indexOf('$500'), + ); + }); + }); }); diff --git a/app/components/UI/Bridge/components/TokenSelectorItem.tsx b/app/components/UI/Bridge/components/TokenSelectorItem.tsx index cf6f6881a44..be71f41ce6f 100644 --- a/app/components/UI/Bridge/components/TokenSelectorItem.tsx +++ b/app/components/UI/Bridge/components/TokenSelectorItem.tsx @@ -5,6 +5,8 @@ import { View, TouchableOpacity, Platform, + StyleProp, + TextStyle, } from 'react-native'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../locales/i18n'; @@ -46,6 +48,12 @@ import { ACCOUNT_TYPE_LABELS } from '../../../../constants/account-type-labels'; import parseAmount from '../../../../util/parseAmount'; import { getTokenImageSource } from '../utils'; import { useRWAToken } from '../hooks/useRWAToken'; +import { useABTest } from '../../../../hooks'; +import { + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, + TokenSelectorBalanceLayoutVariant, +} from './TokenSelectorItem.abTestConfig'; const createStyles = ({ theme, @@ -136,12 +144,22 @@ interface TokenSelectorItemProps { isNoFeeAsset?: boolean; } +const isLoadingBalance = (balance?: string) => + balance === TOKEN_BALANCE_LOADING || + balance === TOKEN_BALANCE_LOADING_UPPERCASE; + const FiatBalanceView = ({ balance, isSelected, + textStyle, + textVariant, + textColor, }: { balance?: string; isSelected: boolean; + textStyle?: StyleProp; + textVariant: TextVariant; + textColor: TextColor; }) => { const { styles } = useStyles(createStyles, { isSelected }); @@ -149,18 +167,51 @@ const FiatBalanceView = ({ return null; } - if ( - balance === TOKEN_BALANCE_LOADING || - balance === TOKEN_BALANCE_LOADING_UPPERCASE - ) { + if (isLoadingBalance(balance)) { + return ; + } + + return ( + + {balance} + + ); +}; + +const TokenBalanceView = ({ + balance, + isSelected, + textStyle, + textVariant, + textColor, +}: { + balance?: string; + isSelected: boolean; + textStyle?: StyleProp; + textVariant: TextVariant; + textColor: TextColor; +}) => { + const { styles } = useStyles(createStyles, { isSelected }); + + if (!balance) { + return null; + } + + if (isLoadingBalance(balance)) { return ; } return ( {balance} @@ -178,6 +229,10 @@ export const TokenSelectorItem: React.FC = ({ isNoFeeAsset = false, }) => { const { styles } = useStyles(createStyles, { isSelected }); + const { variant } = useABTest( + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, + ); const noFeeAssets = useSelector((state: RootState) => selectNoFeeAssets(state, token.chainId), ); @@ -197,8 +252,18 @@ export const TokenSelectorItem: React.FC = ({ return parseAmount(balance, 5) || balance; }; - const cryptoBalance = token.balance - ? `${formatTokenBalance(token.balance)} ${token.symbol}` + const selectedVariant = + variant ?? + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS[ + TokenSelectorBalanceLayoutVariant.Control + ]; + const formattedTokenBalance = token.balance + ? formatTokenBalance(token.balance) + : undefined; + const cryptoBalance = formattedTokenBalance + ? selectedVariant.removeTickerFromTokenBalance + ? formattedTokenBalance + : `${formattedTokenBalance} ${token.symbol}` : undefined; const isNative = token.address === ethers.constants.AddressZero; @@ -206,8 +271,16 @@ export const TokenSelectorItem: React.FC = ({ // to check if the token is a stock by checking if the name includes 'ondo' or 'stock' const { isStockToken } = useRWAToken(); - const balance = shouldShowBalance ? fiatValue : undefined; - const secondaryBalance = shouldShowBalance ? cryptoBalance : undefined; + const fiatBalance = shouldShowBalance ? fiatValue : undefined; + const tokenBalance = shouldShowBalance ? cryptoBalance : undefined; + const topRowBalanceTextStyle = { + textVariant: TextVariant.BodyMDMedium, + textColor: TextColor.Default, + }; + const bottomRowBalanceTextStyle = { + textVariant: TextVariant.BodyMD, + textColor: TextColor.Alternative, + }; const label = token.accountType ? ACCOUNT_TYPE_LABELS[token.accountType] @@ -291,21 +364,21 @@ export const TokenSelectorItem: React.FC = ({ )} - {secondaryBalance ? ( - secondaryBalance === TOKEN_BALANCE_LOADING || - secondaryBalance === TOKEN_BALANCE_LOADING_UPPERCASE ? ( - - ) : ( - - {secondaryBalance} - - ) - ) : null} + {selectedVariant.showTokenBalanceFirst ? ( + + ) : ( + + )} = ({ {token.name} - + {selectedVariant.showTokenBalanceFirst ? ( + + ) : ( + + )} {isStockToken(token as BridgeToken) && } diff --git a/app/components/UI/Bridge/hooks/useAutoUpdateDestToken/useAutoUpdateDestToken.test.ts b/app/components/UI/Bridge/hooks/useAutoUpdateDestToken/useAutoUpdateDestToken.test.ts index 603c21aa959..03e497dff2c 100644 --- a/app/components/UI/Bridge/hooks/useAutoUpdateDestToken/useAutoUpdateDestToken.test.ts +++ b/app/components/UI/Bridge/hooks/useAutoUpdateDestToken/useAutoUpdateDestToken.test.ts @@ -8,9 +8,9 @@ import { useAutoUpdateDestToken } from '.'; import { BridgeToken } from '../../types'; import { Hex } from '@metamask/utils'; import { BtcScope, SolScope } from '@metamask/keyring-api'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as bridgeSlice from '../../../../../core/redux/slices/bridge'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as tokenUtils from '../../utils/tokenUtils'; describe('useAutoUpdateDestToken', () => { diff --git a/app/components/UI/Bridge/hooks/useBridgeConfirm/index.ts b/app/components/UI/Bridge/hooks/useBridgeConfirm/index.ts index d449a326fe4..171e42264c3 100644 --- a/app/components/UI/Bridge/hooks/useBridgeConfirm/index.ts +++ b/app/components/UI/Bridge/hooks/useBridgeConfirm/index.ts @@ -3,7 +3,6 @@ import { useBridgeQuoteData } from '../useBridgeQuoteData'; import { useNavigation } from '@react-navigation/native'; import { setIsSubmittingTx } from '../../../../../core/redux/slices/bridge'; import Routes from '../../../../../constants/navigation/Routes'; -import { BridgeQuoteResponse } from '../../types'; import useSubmitBridgeTx from '../../../../../util/bridge/hooks/useSubmitBridgeTx'; import { selectSourceWalletAddress } from '../../../../../selectors/bridge'; import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller'; @@ -28,14 +27,8 @@ export const useBridgeConfirm = ({ latestSourceBalance, location }: Params) => { if (activeQuote && walletAddress) { dispatch(setIsSubmittingTx(true)); - const quoteResponse: BridgeQuoteResponse = { - ...activeQuote, - aggregator: activeQuote.quote.bridgeId, - walletAddress, - }; - await submitBridgeTx({ - quoteResponse, + quoteResponse: activeQuote, location, }); } diff --git a/app/components/UI/Bridge/hooks/useBridgeConfirm/useBridgeConfirm.test.ts b/app/components/UI/Bridge/hooks/useBridgeConfirm/useBridgeConfirm.test.ts index 23ee2efeea4..68a79a83574 100644 --- a/app/components/UI/Bridge/hooks/useBridgeConfirm/useBridgeConfirm.test.ts +++ b/app/components/UI/Bridge/hooks/useBridgeConfirm/useBridgeConfirm.test.ts @@ -102,11 +102,7 @@ describe('useBridgeConfirm', () => { }); expect(mockSubmitBridgeTx).toHaveBeenCalledWith({ - quoteResponse: { - ...mockQuoteWithMetadata, - aggregator: mockQuoteWithMetadata.quote.bridgeId, - walletAddress: WALLET_ADDRESS, - }, + quoteResponse: mockQuoteWithMetadata, location: MetaMetricsSwapsEventSource.MainView, }); }); diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts index f3c896bc51a..9d5bb981f57 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts @@ -99,11 +99,28 @@ export const useBridgeQuoteData = ({ ) : undefined; - const activeQuote = + const rawActiveQuote = isExpired && !willRefresh && !isSubmittingTx ? undefined : (manuallySelectedQuote ?? bestQuote); + // When quotes are expired but the user hasn't yet triggered a new fetch, + // keep showing the last quotes that are still present in Redux. They are NOT + // cleared from the store on expiry — only BridgeController.resetState() + // (called on "Get new quote") removes them. Reading from Redux directly means + // every consumer of this hook (BridgeView, QuoteSelectorView, …) sees the + // same cached data without needing per-instance refs. + const isShowingCachedQuote = + isExpired && + !willRefresh && + !isSubmittingTx && + quotesLoadingStatus !== RequestStatus.LOADING && + !!(manuallySelectedQuote ?? bestQuote); + + const activeQuote = isShowingCachedQuote + ? (manuallySelectedQuote ?? bestQuote) + : rawActiveQuote; + // Validate that the quote's source asset matches the selected source token // This prevents showing stale quote data when user changes source token on the same chain const isQuoteSourceTokenMatch = useMemo(() => { @@ -144,16 +161,19 @@ export const useBridgeQuoteData = ({ const isQuoteDestTokenMatch = isQuoteDestTokenMatchForQuote(activeQuote); - // Filter all quotes to only include valid ones (not expired and matching dest token) + // Filter all quotes to only include valid ones (not expired and matching dest token). + // When showing cached data the expiry guard is bypassed so the Redux quotes + // that are still in the store remain visible until the user requests new ones. const validQuotes = useMemo( () => - isExpired && !willRefresh && !isSubmittingTx + isExpired && !willRefresh && !isSubmittingTx && !isShowingCachedQuote ? [] : allQuotes.filter((quote) => isQuoteDestTokenMatchForQuote(quote)), [ isExpired, willRefresh, isSubmittingTx, + isShowingCachedQuote, allQuotes, isQuoteDestTokenMatchForQuote, ], @@ -229,11 +249,17 @@ export const useBridgeQuoteData = ({ !bestQuote && quotesLastFetched && !isLoading, ); + // The quote expired and no fetch is in progress — offer to get a new one. + // Also treat the edge-case where a fetch IS running but there is no active + // quote to fall back on — the user would otherwise be stuck on a spinner + // with no way to retry ("escape hatch"). + const needsNewQuote = + isExpired && !isSubmittingTx && (!isLoading || !activeQuote); + const shouldShowPriceImpactWarning = Boolean( activeQuote?.quote.priceData?.priceImpact !== undefined && bridgeFeatureFlags?.priceImpactThreshold && Number(activeQuote?.quote.priceData?.priceImpact) >= - // @ts-expect-error TODO: remove comment after changes to core are published. (bridgeFeatureFlags.priceImpactThreshold.warning ?? AppConstants.BRIDGE.PRICE_IMPACT_WARNING_THRESHOLD), ); @@ -319,5 +345,6 @@ export const useBridgeQuoteData = ({ blockaidError, shouldShowPriceImpactWarning, validQuotes, + needsNewQuote, }; }; diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts index 52488512b3e..fe4922ecb97 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts @@ -141,6 +141,7 @@ describe('useBridgeQuoteData', () => { quoteFetchError: null, isNoQuotesAvailable: false, isExpired: false, + needsNewQuote: false, shouldShowPriceImpactWarning: false, willRefresh: false, blockaidError: null, @@ -296,6 +297,7 @@ describe('useBridgeQuoteData', () => { quoteFetchError: null, isNoQuotesAvailable: true, isExpired: false, + needsNewQuote: false, willRefresh: false, blockaidError: null, shouldShowPriceImpactWarning: false, @@ -366,16 +368,26 @@ describe('useBridgeQuoteData', () => { state: testState, }); + // When expired but not loading, the hook serves the last known Redux quotes + // as a cache so the UI can keep displaying them until the user requests a + // fresh fetch via "Get new quote". expect(result.current).toEqual({ - activeQuote: undefined, + activeQuote: mockQuoteWithMetadata, bestQuote: mockQuoteWithMetadata, destTokenAmount: undefined, - formattedQuoteData: undefined, + formattedQuoteData: { + estimatedTime: '5 seconds', + networkFee: '-', + priceImpact: '-0.20%', + rate: '--', + slippage: '0.5%', + }, isLoading: false, quoteFetchError: null, isNoQuotesAvailable: false, shouldShowPriceImpactWarning: false, isExpired: true, + needsNewQuote: true, willRefresh: false, blockaidError: null, quotesLoadingStatus: null, @@ -411,6 +423,7 @@ describe('useBridgeQuoteData', () => { quoteFetchError: null, isNoQuotesAvailable: false, isExpired: false, + needsNewQuote: false, shouldShowPriceImpactWarning: false, willRefresh: false, blockaidError: null, @@ -449,6 +462,7 @@ describe('useBridgeQuoteData', () => { quoteFetchError: error, isNoQuotesAvailable: false, isExpired: false, + needsNewQuote: false, willRefresh: false, blockaidError: null, quotesLoadingStatus: null, @@ -1462,7 +1476,7 @@ describe('useBridgeQuoteData', () => { }); }); - it('does not override activeQuote with manually selected when expired and not refreshing', () => { + it('keeps showing manually selected quote as activeQuote when expired and not refreshing', () => { const manuallySelectedQuote = { ...mockQuoteWithMetadata, quote: { @@ -1501,8 +1515,9 @@ describe('useBridgeQuoteData', () => { state: testState, }); - // When expired and not refreshing and not submitting, activeQuote should be undefined - expect(result.current.activeQuote).toBeUndefined(); + // When expired but not loading, the last known Redux quotes are served as + // a cache. The manually-selected quote is still shown (not cleared). + expect(result.current.activeQuote).toEqual(manuallySelectedQuote); expect(result.current.isExpired).toBe(true); }); diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts index ecf661c8351..eb9c0b82cfd 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts @@ -484,6 +484,50 @@ describe('useBridgeQuoteRequest', () => { }); }); + describe('hardware wallet accounts', () => { + it('sends gasIncluded and gasIncluded7702 false when useIsGasIncluded7702Supported dispatches false for hardware wallet', async () => { + // useIsGasIncluded7702Supported now incorporates the HW wallet check and + // dispatches isGasIncluded7702Supported=false for hardware wallets. + // useIsGasIncludedSTXSendBundleSupported already dispatches false for HW + // wallets via selectShouldUseSmartTransaction. + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + isGasIncludedSTXSendBundleSupported: false, + isGasIncluded7702Supported: false, + sourceToken: { + address: '0xSourceToken', + chainId: '0x1', + decimals: 18, + symbol: 'SRC', + }, + destToken: { + address: '0xDestToken', + chainId: '0x1', + decimals: 18, + symbol: 'DEST', + }, + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteRequest(), { + state: testState, + }); + + await act(async () => { + await result.current(); + jest.advanceTimersByTime(DEBOUNCE_WAIT); + }); + + expect(spyUpdateBridgeQuoteRequestParams).toHaveBeenCalledWith( + expect.objectContaining({ + gasIncluded: false, + gasIncluded7702: false, + }), + undefined, + ); + }); + }); + describe('insufficientBal parameter', () => { it('includes insufficientBal false when balance is sufficient', async () => { mockUseIsInsufficientBalance.mockReturnValue(false); diff --git a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts index bafe1d3eac6..c3488f1cea1 100644 --- a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts +++ b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts @@ -8,13 +8,15 @@ import { formatChainIdToHex, isNonEvmChainId, } from '@metamask/bridge-controller'; +import { useIsHardwareWalletForBridge } from '../useIsHardwareWalletForBridge'; /** * Hook that determines if 7702 gasless support is available for bridge/swap. * Should be used at the page level (e.g., BridgeView) to avoid repeated calculations. * - * Requirement for 7702: + * Requirements for 7702: * - Relay must be supported (for 7702 delegation) + * - Source wallet must not be a hardware wallet * * @param chainId - The chain ID to check (can be Hex, CAIP, or other format) - only EVM chains are supported */ @@ -40,9 +42,11 @@ export const useIsGasIncluded7702Supported = ( return isRelaySupported(evmChainId as Hex); }, [evmChainId]); + const isHardwareWallet = useIsHardwareWalletForBridge(); + // 7702 is available when ALL conditions are met const isGasIncluded7702Supported = Boolean( - evmChainId && !!isRelaySupportedForChain, + evmChainId && !!isRelaySupportedForChain && !isHardwareWallet, ); useEffect(() => { diff --git a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts index 7c31e0e6dd7..1a3d7c3a8de 100644 --- a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts +++ b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts @@ -7,8 +7,18 @@ import configureStore from '../../../../../util/test/configureStore'; // Mock dependencies jest.mock('../../../../../util/transactions/transaction-relay'); +jest.mock('../useIsHardwareWalletForBridge', () => ({ + useIsHardwareWalletForBridge: jest.fn().mockReturnValue(false), +})); const mockIsRelaySupported = jest.mocked(isRelaySupported); +const { useIsHardwareWalletForBridge } = jest.requireMock( + '../useIsHardwareWalletForBridge', +); +const mockUseIsHardwareWalletForBridge = + useIsHardwareWalletForBridge as jest.MockedFunction< + typeof useIsHardwareWalletForBridge + >; describe('useIsGasIncluded7702Supported', () => { const MAINNET_CHAIN_ID = '0x1' as Hex; @@ -28,6 +38,7 @@ describe('useIsGasIncluded7702Supported', () => { beforeEach(() => { jest.clearAllMocks(); mockIsRelaySupported.mockResolvedValue(false); + mockUseIsHardwareWalletForBridge.mockReturnValue(false); }); afterEach(() => { @@ -147,6 +158,32 @@ describe('useIsGasIncluded7702Supported', () => { }); }); + describe('when source wallet is a hardware account', () => { + it('updates isGasIncluded7702Supported to false even when relay is supported', async () => { + mockIsRelaySupported.mockResolvedValue(true); + mockUseIsHardwareWalletForBridge.mockReturnValue(true); + + const { store } = renderHookWithProvider( + () => useIsGasIncluded7702Supported(MAINNET_CHAIN_ID), + { state: {} }, + ); + + await expectGasIncluded7702State(store, false); + }); + + it('updates isGasIncluded7702Supported to false for hardware wallet regardless of chain', async () => { + mockIsRelaySupported.mockResolvedValue(true); + mockUseIsHardwareWalletForBridge.mockReturnValue(true); + + const { store } = renderHookWithProvider( + () => useIsGasIncluded7702Supported('eip155:59144'), // Linea + { state: {} }, + ); + + await expectGasIncluded7702State(store, false); + }); + }); + describe('edge cases', () => { it('handles case-insensitive chainId matching', async () => { mockIsRelaySupported.mockResolvedValue(true); diff --git a/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.test.ts b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.test.ts new file mode 100644 index 00000000000..f1082ff5e73 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.test.ts @@ -0,0 +1,56 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { useIsHardwareWalletForBridge } from './index'; +import { isHardwareAccount } from '../../../../../util/address'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../../util/address', () => ({ + isHardwareAccount: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockIsHardwareAccount = isHardwareAccount as jest.MockedFunction< + typeof isHardwareAccount +>; + +describe('useIsHardwareWalletForBridge', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockReturnValue(undefined); + mockIsHardwareAccount.mockReturnValue(false); + }); + + it('returns false when source wallet address is undefined', () => { + mockUseSelector.mockReturnValue(undefined); + + const { result } = renderHook(() => useIsHardwareWalletForBridge()); + + expect(result.current).toBe(false); + expect(mockIsHardwareAccount).not.toHaveBeenCalled(); + }); + + it('returns true when source wallet is a hardware account', () => { + const address = '0x1234567890123456789012345678901234567890'; + mockUseSelector.mockReturnValue(address); + mockIsHardwareAccount.mockReturnValue(true); + + const { result } = renderHook(() => useIsHardwareWalletForBridge()); + + expect(result.current).toBe(true); + expect(mockIsHardwareAccount).toHaveBeenCalledWith(address); + }); + + it('returns false when source wallet is not a hardware account', () => { + const address = '0x1234567890123456789012345678901234567890'; + mockUseSelector.mockReturnValue(address); + mockIsHardwareAccount.mockReturnValue(false); + + const { result } = renderHook(() => useIsHardwareWalletForBridge()); + + expect(result.current).toBe(false); + expect(mockIsHardwareAccount).toHaveBeenCalledWith(address); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.ts b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.ts new file mode 100644 index 00000000000..c4ba9df5dee --- /dev/null +++ b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { selectSourceWalletAddress } from '../../../../../selectors/bridge'; +import { isHardwareAccount } from '../../../../../util/address'; + +/** + * Returns whether the current bridge source account is a hardware wallet. + * Used to omit gas-included / 7702 params from bridge quote requests so responses + * are non-sponsored for hardware signers. + */ +export function useIsHardwareWalletForBridge(): boolean { + const walletAddress = useSelector(selectSourceWalletAddress); + + return useMemo( + () => Boolean(walletAddress && isHardwareAccount(walletAddress)), + [walletAddress], + ); +} diff --git a/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.test.ts b/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.test.ts deleted file mode 100644 index 271db4145ca..00000000000 --- a/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; -import { useModalCloseOnQuoteExpiry } from './index'; -import { useBridgeQuoteData } from '../useBridgeQuoteData'; -import Routes from '../../../../../constants/navigation/Routes'; -import { CommonActions } from '@react-navigation/native'; - -jest.mock('../useBridgeQuoteData', () => ({ - useBridgeQuoteData: jest.fn(), -})); - -const mockDispatch = jest.fn(); -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - dispatch: mockDispatch, - }), -})); - -const mockUseBridgeQuoteData = { - isExpired: false, - willRefresh: false, -}; - -describe('useModalCloseOnQuoteExpiry', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest - .mocked(useBridgeQuoteData) - .mockReturnValue( - mockUseBridgeQuoteData as ReturnType, - ); - }); - - it('dispatches a reset to QuoteExpiredModal when quote is expired and will not refresh', () => { - // Arrange - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => useModalCloseOnQuoteExpiry()); - - // Assert - expect(mockDispatch).toHaveBeenCalledWith( - CommonActions.reset({ - index: 0, - routes: [{ name: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL }], - }), - ); - }); - - it('does not dispatch when quote is not expired', () => { - // Arrange - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: false, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => useModalCloseOnQuoteExpiry()); - - // Assert - expect(mockDispatch).not.toHaveBeenCalled(); - }); - - it('does not dispatch when quote is expired but will refresh', () => { - // Arrange - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: true, - } as ReturnType); - - // Act - renderHookWithProvider(() => useModalCloseOnQuoteExpiry()); - - // Assert - expect(mockDispatch).not.toHaveBeenCalled(); - }); - - it('dispatches again when quote transitions from not-expired to expired', () => { - // Arrange – start with not expired - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: false, - willRefresh: false, - } as ReturnType); - - const { rerender } = renderHookWithProvider(() => - useModalCloseOnQuoteExpiry(), - ); - - expect(mockDispatch).not.toHaveBeenCalled(); - - // Quote expires - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - rerender({}); - - // Assert - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith( - CommonActions.reset({ - index: 0, - routes: [{ name: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL }], - }), - ); - }); - - it('dispatches reset with index 0 so QuoteExpiredModal is the only route', () => { - // Arrange - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => useModalCloseOnQuoteExpiry()); - - // Assert - const dispatchedAction = mockDispatch.mock.calls[0][0]; - expect(dispatchedAction.payload.index).toBe(0); - expect(dispatchedAction.payload.routes).toHaveLength(1); - expect(dispatchedAction.payload.routes[0].name).toBe( - Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - ); - }); -}); diff --git a/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.ts b/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.ts deleted file mode 100644 index a5f4b7394c9..00000000000 --- a/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useEffect } from 'react'; -import { CommonActions, useNavigation } from '@react-navigation/native'; -import { useBridgeQuoteData } from '../useBridgeQuoteData'; -import Routes from '../../../../../constants/navigation/Routes'; - -/** - * Resets the BridgeModalStack to show only QuoteExpiredModal when quotes expire. - * - * Must be called from a screen that lives inside BridgeModalStack so that - * CommonActions.reset targets BridgeModalStack (not the root navigator). - * This prevents the previous modal's BottomSheetOverlay from remaining - * visible behind QuoteExpiredModal. - */ -export const useModalCloseOnQuoteExpiry = () => { - const navigation = useNavigation(); - const { isExpired, willRefresh } = useBridgeQuoteData(); - - useEffect(() => { - if (isExpired && !willRefresh) { - navigation.dispatch( - CommonActions.reset({ - index: 0, - routes: [{ name: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL }], - }), - ); - } - }, [isExpired, willRefresh, navigation]); -}; diff --git a/app/components/UI/Bridge/hooks/usePriceImpactViewData/index.ts b/app/components/UI/Bridge/hooks/usePriceImpactViewData/index.ts index 7401d4c62f1..07bf8b83c1d 100644 --- a/app/components/UI/Bridge/hooks/usePriceImpactViewData/index.ts +++ b/app/components/UI/Bridge/hooks/usePriceImpactViewData/index.ts @@ -13,11 +13,9 @@ export const usePriceImpactViewData = (priceImpact?: string) => { priceImpactValue: priceImpact, threshold: { error: - // @ts-expect-error TODO: remove comment after changes to core are published. bridgeFeatureFlags?.priceImpactThreshold?.error ?? AppConstants.BRIDGE.PRICE_IMPACT_ERROR_THRESHOLD, warning: - // @ts-expect-error TODO: remove comment after changes to core are published. bridgeFeatureFlags?.priceImpactThreshold?.warning ?? AppConstants.BRIDGE.PRICE_IMPACT_WARNING_THRESHOLD, }, diff --git a/app/components/UI/Bridge/hooks/useRenderQuoteExpireModal/index.ts b/app/components/UI/Bridge/hooks/useRenderQuoteExpireModal/index.ts deleted file mode 100644 index c190735edf7..00000000000 --- a/app/components/UI/Bridge/hooks/useRenderQuoteExpireModal/index.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { RefObject, useEffect, useRef } from 'react'; -import { TokenInputAreaRef } from '../../components/TokenInputArea'; -import { useBridgeQuoteData } from '../useBridgeQuoteData'; -import { useLatestBalance } from '../useLatestBalance'; -import { useSelector } from 'react-redux'; -import { - selectIsSelectingRecipient, - selectIsSelectingToken, - selectIsSubmittingTx, -} from '../../../../../core/redux/slices/bridge'; -import { useIsFocused, useNavigation } from '@react-navigation/native'; -import Routes from '../../../../../constants/navigation/Routes'; - -interface Params { - inputRef: RefObject; - latestSourceBalance: ReturnType; -} - -export const useRenderQuoteExpireModal = ({ - inputRef, - latestSourceBalance, -}: Params) => { - const navigation = useNavigation(); - const isBridgeViewFocused = useIsFocused(); - const isSelectingRecipient = useSelector(selectIsSelectingRecipient); - const isSelectingToken = useSelector(selectIsSelectingToken); - const isSubmittingTx = useSelector(selectIsSubmittingTx); - - const { isExpired, willRefresh } = useBridgeQuoteData({ - latestSourceAtomicBalance: latestSourceBalance?.atomicBalance, - }); - - // Track whether the expired-quote modal has already been shown for the - // current expiry cycle, so it doesn't re-trigger when unrelated deps - // (e.g. isSelectingToken) flip back after navigation. - const hasShownExpiredModal = useRef(false); - - // Reset the flag whenever the quote is no longer expired - // (i.e. a new quote was fetched or is loading). - useEffect(() => { - if (!isExpired) { - hasShownExpiredModal.current = false; - } - }, [isExpired]); - - useEffect(() => { - if ( - isExpired && - !willRefresh && - isBridgeViewFocused && - !isSelectingRecipient && - !isSelectingToken && - !isSubmittingTx && - !hasShownExpiredModal.current - ) { - hasShownExpiredModal.current = true; - inputRef.current?.blur(); - // open the quote tooltip modal - navigation.navigate(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - }); - } - }, [ - isExpired, - willRefresh, - navigation, - isBridgeViewFocused, - isSelectingRecipient, - isSelectingToken, - isSubmittingTx, - inputRef, - ]); -}; diff --git a/app/components/UI/Bridge/hooks/useRenderQuoteExpireModal/useRenderQuoteExpireModal.test.ts b/app/components/UI/Bridge/hooks/useRenderQuoteExpireModal/useRenderQuoteExpireModal.test.ts deleted file mode 100644 index f595d5ffb7e..00000000000 --- a/app/components/UI/Bridge/hooks/useRenderQuoteExpireModal/useRenderQuoteExpireModal.test.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; -import { useRenderQuoteExpireModal } from './index'; -import { useBridgeQuoteData } from '../useBridgeQuoteData'; -import Routes from '../../../../../constants/navigation/Routes'; -import { BigNumber } from 'ethers'; - -// Mock useBridgeQuoteData -const mockUseBridgeQuoteData = { - isExpired: false, - willRefresh: false, -}; -jest.mock('../useBridgeQuoteData', () => ({ - useBridgeQuoteData: jest.fn(), -})); - -// Mock redux selectors -jest.mock('../../../../../core/redux/slices/bridge', () => ({ - ...jest.requireActual('../../../../../core/redux/slices/bridge'), - selectIsSelectingRecipient: jest.fn().mockReturnValue(false), - selectIsSelectingToken: jest.fn().mockReturnValue(false), - selectIsSubmittingTx: jest.fn().mockReturnValue(false), -})); - -import { - selectIsSelectingRecipient, - selectIsSelectingToken, - selectIsSubmittingTx, -} from '../../../../../core/redux/slices/bridge'; - -// Mock navigation -const mockNavigate = jest.fn(); -let mockIsFocused = true; -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: mockNavigate, - setOptions: jest.fn(), - }), - useIsFocused: () => mockIsFocused, -})); - -const createMockInputRef = () => ({ - current: { - blur: jest.fn(), - focus: jest.fn(), - isFocused: jest.fn().mockReturnValue(false), - }, -}); - -const mockLatestSourceBalance = { - displayBalance: '2.0', - atomicBalance: BigNumber.from('2000000000000000000'), -}; - -describe('useRenderQuoteExpireModal', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockIsFocused = true; - jest - .mocked(useBridgeQuoteData) - .mockReturnValue( - mockUseBridgeQuoteData as ReturnType, - ); - jest.mocked(selectIsSelectingRecipient).mockReturnValue(false); - jest.mocked(selectIsSelectingToken).mockReturnValue(false); - jest.mocked(selectIsSubmittingTx).mockReturnValue(false); - }); - - it('navigates to quote expired modal when quote is expired and will not refresh', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(inputRef.current.blur).toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - }); - }); - - it('does not navigate when quote is not expired', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: false, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(inputRef.current.blur).not.toHaveBeenCalled(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not navigate when quote is expired but will refresh', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: true, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(inputRef.current.blur).not.toHaveBeenCalled(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not navigate when user is selecting a recipient', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(selectIsSelectingRecipient).mockReturnValue(true); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not navigate when user is selecting a token', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(selectIsSelectingToken).mockReturnValue(true); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not navigate when bridge view is not focused', () => { - // Arrange - const inputRef = createMockInputRef(); - mockIsFocused = false; - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(inputRef.current.blur).not.toHaveBeenCalled(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not navigate when a transaction is being submitted', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(selectIsSubmittingTx).mockReturnValue(true); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not show modal twice for the same expiry cycle', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act – render once (modal shown), then rerender - const { rerender } = renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - expect(mockNavigate).toHaveBeenCalledTimes(1); - - // Trigger a rerender with same expired state - rerender({}); - - // Assert – should still only have been called once - expect(mockNavigate).toHaveBeenCalledTimes(1); - }); - - it('shows modal after bridge view regains focus while quote remains expired', () => { - // Arrange - const inputRef = createMockInputRef(); - mockIsFocused = false; - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act – render out of focus first - const { rerender } = renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - expect(mockNavigate).not.toHaveBeenCalled(); - - // Regain focus while quote is still expired - mockIsFocused = true; - rerender({}); - - // Assert - expect(inputRef.current.blur).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - }); - }); - - it('resets and shows modal again after quote recovers then expires again', () => { - // Arrange - const inputRef = createMockInputRef(); - - // First render: expired - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - const { rerender } = renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - expect(mockNavigate).toHaveBeenCalledTimes(1); - - // Quote recovers (not expired anymore) - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: false, - willRefresh: false, - } as ReturnType); - - rerender({}); - - // Quote expires again - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - rerender({}); - - // Assert – modal shown a second time - expect(mockNavigate).toHaveBeenCalledTimes(2); - }); - - it('blurs the input ref before navigating', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(inputRef.current.blur).toHaveBeenCalledTimes(1); - }); - - it('handles null inputRef.current gracefully', () => { - // Arrange - const inputRef = { current: null }; - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act & Assert – should not throw - expect(() => - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ), - ).not.toThrow(); - - expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - }); - }); - - it('handles undefined latestSourceBalance', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: undefined, - }), - ); - - // Assert – should still show the modal - expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - }); - }); -}); diff --git a/app/components/UI/Bridge/hooks/useSwitchTokens/useSwitchTokens.test.ts b/app/components/UI/Bridge/hooks/useSwitchTokens/useSwitchTokens.test.ts index 3618da99f6e..ceaaf05101c 100644 --- a/app/components/UI/Bridge/hooks/useSwitchTokens/useSwitchTokens.test.ts +++ b/app/components/UI/Bridge/hooks/useSwitchTokens/useSwitchTokens.test.ts @@ -5,7 +5,7 @@ import { waitFor } from '@testing-library/react-native'; import { BridgeToken } from '../../types'; import { Hex } from '@metamask/utils'; import { SolScope } from '@metamask/keyring-api'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as bridgeSlice from '../../../../../core/redux/slices/bridge'; import Engine from '../../../../../core/Engine'; diff --git a/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts b/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts index 26fe449e1bb..758bf428b42 100644 --- a/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts +++ b/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useSelector } from 'react-redux'; import { useABTest } from '../../../../../hooks'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; @@ -13,15 +13,46 @@ import { NUMPAD_QUICK_ACTIONS_AB_KEY, NUMPAD_QUICK_ACTIONS_VARIANTS, } from '../../components/GaslessQuickPickOptions/abTestConfig'; +import { + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, +} from '../../components/TokenSelectorItem.abTestConfig'; export const useTrackSwapPageViewed = () => { const { trackEvent, createEventBuilder } = useAnalytics(); const sourceToken = useSelector(selectSourceToken); const destToken = useSelector(selectDestToken); const abTestContext = useSelector(selectAbTestContext); - const { variantName, isActive } = useABTest( - NUMPAD_QUICK_ACTIONS_AB_KEY, - NUMPAD_QUICK_ACTIONS_VARIANTS, + const { variantName: numpadVariantName, isActive: isNumpadAbActive } = + useABTest(NUMPAD_QUICK_ACTIONS_AB_KEY, NUMPAD_QUICK_ACTIONS_VARIANTS); + const { + variantName: tokenSelectorVariantName, + isActive: isTokenSelectorAbActive, + } = useABTest( + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, + ); + + const activeABTests = useMemo( + () => [ + ...(isNumpadAbActive + ? [{ key: NUMPAD_QUICK_ACTIONS_AB_KEY, value: numpadVariantName }] + : []), + ...(isTokenSelectorAbActive + ? [ + { + key: TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + value: tokenSelectorVariantName, + }, + ] + : []), + ], + [ + isNumpadAbActive, + numpadVariantName, + isTokenSelectorAbActive, + tokenSelectorVariantName, + ], ); const hasTrackedPageView = useRef(false); @@ -44,13 +75,8 @@ export const useTrackSwapPageViewed = () => { abTestContext.assetsASSETS2493AbtestTokenDetailsLayout, }, }), - ...(isActive && { - active_ab_tests: [ - { - key: NUMPAD_QUICK_ACTIONS_AB_KEY, - value: variantName, - }, - ], + ...(activeABTests.length > 0 && { + active_ab_tests: activeABTests, }), }; trackEvent( @@ -64,8 +90,7 @@ export const useTrackSwapPageViewed = () => { destToken, trackEvent, createEventBuilder, - isActive, - variantName, + activeABTests, abTestContext, ]); }; diff --git a/app/components/UI/Bridge/routes.tsx b/app/components/UI/Bridge/routes.tsx index 163f44e9d3c..7cdcaac9460 100644 --- a/app/components/UI/Bridge/routes.tsx +++ b/app/components/UI/Bridge/routes.tsx @@ -4,7 +4,6 @@ import Routes from '../../../constants/navigation/Routes'; import { BridgeTokenSelector } from './components/BridgeTokenSelector'; import BridgeView from './Views/BridgeView'; import BlockExplorersModal from './components/TransactionDetails/BlockExplorersModal'; -import QuoteExpiredModal from './components/QuoteExpiredModal'; import BlockaidModal from './components/BlockaidModal'; import RecipientSelectorModal from './components/RecipientSelectorModal'; import MarketClosedBottomSheet from './components/MarketClosedBottomSheets/MarketClosedBottomSheet'; @@ -66,10 +65,6 @@ export const BridgeModalStack = () => ( name={Routes.BRIDGE.MODALS.TRANSACTION_DETAILS_BLOCK_EXPLORER} component={BlockExplorersModal} /> - { return ( origin === ORIGIN_METAMASK && (txMeta.type === TransactionType.bridgeApproval || - txMeta.type === TransactionType.bridge) + txMeta.type === TransactionType.bridge || + txMeta.type === TransactionType.swap || + txMeta.type === TransactionType.swapApproval) ); }; diff --git a/app/components/UI/Button/index.js b/app/components/UI/Button/index.js index c32ac8b4c27..b168233fc06 100644 --- a/app/components/UI/Button/index.js +++ b/app/components/UI/Button/index.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { StyleSheet } from 'react-native'; -import GenericButton from '../GenericButton'; // eslint-disable-line import/no-unresolved +import GenericButton from '../GenericButton'; // eslint-disable-line import-x/no-unresolved import { useTheme } from '../../../util/theme'; import { ViewPropTypes } from 'deprecated-react-native-prop-types'; diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index 27d1479bc0c..7280faff6f3 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -668,6 +668,7 @@ function setupLoadCardDataMock( externalWalletDetailsData: { mappedWalletDetails?: Record[]; } | null; + delegationSettings: Record | null; }>, ) { const defaults = { @@ -682,6 +683,7 @@ function setupLoadCardDataMock( isCardholder: true, kycStatus: { verificationState: 'VERIFIED' as const, userId: 'user-123' }, externalWalletDetailsData: null, + delegationSettings: null, }; const config = { ...defaults, ...overrides }; @@ -5776,4 +5778,277 @@ describe('CardHome Component', () => { expect(mockTrackEvent).toHaveBeenCalled(); }); }); + + describe('Enable Card - ChooseYourCard Redirect', () => { + it('navigates to ChooseYourCard when eligible US user presses Enable Card', async () => { + // Given: Verified, authenticated US user with shipping address, metal card enabled, no card + const priorityTokenForNav = { ...mockPriorityToken }; + const allTokensForNav = [mockPriorityToken]; + const delegationSettingsForNav = { networks: [] }; + const externalWalletDetailsForNav = { mappedWalletDetails: [] }; + + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'us', + isMetalCardCheckoutEnabled: true, + }); + + (useLoadCardData as jest.Mock).mockReturnValueOnce({ + priorityToken: priorityTokenForNav, + allTokens: allTokensForNav, + cardDetails: null, + isLoading: false, + error: null, + warning: CardStateWarning.NoCard, + isAuthenticated: true, + isBaanxLoginEnabled: true, + isCardholder: false, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: { + id: 'user-123', + addressLine1: '123 Main St', + city: 'New York', + zip: '10001', + usState: 'NY', + }, + }, + externalWalletDetailsData: externalWalletDetailsForNav, + delegationSettings: delegationSettingsForNav, + fetchAllData: mockFetchAllData, + refetchAllData: mockRefetchAllData, + fetchCardDetails: mockFetchCardDetails, + }); + + // When: user presses Enable Card button + render(); + const enableButton = screen.getByTestId( + CardHomeSelectors.ENABLE_CARD_BUTTON, + ); + fireEvent.press(enableButton); + + // Then: navigates to ChooseYourCard with home flow and card data params + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + Routes.CARD.CHOOSE_YOUR_CARD, + expect.objectContaining({ + flow: 'home', + shippingAddress: expect.objectContaining({ + line1: '123 Main St', + city: 'New York', + zip: '10001', + }), + priorityToken: priorityTokenForNav, + allTokens: allTokensForNav, + delegationSettings: delegationSettingsForNav, + externalWalletDetailsData: externalWalletDetailsForNav, + }), + ); + }); + }); + + it('navigates to delegation when warning is NeedDelegation even with metal card enabled', async () => { + // Given: US user with shipping address and metal card enabled, but warning is NeedDelegation (not NoCard) + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'us', + isMetalCardCheckoutEnabled: true, + }); + + (useLoadCardData as jest.Mock).mockReturnValueOnce({ + priorityToken: null, + allTokens: [], + cardDetails: null, + isLoading: false, + error: null, + warning: CardStateWarning.NeedDelegation, + isAuthenticated: true, + isBaanxLoginEnabled: true, + isCardholder: true, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: { + id: 'user-123', + addressLine1: '123 Main St', + city: 'New York', + zip: '10001', + usState: 'NY', + }, + }, + externalWalletDetailsData: null, + delegationSettings: null, + fetchAllData: mockFetchAllData, + refetchAllData: mockRefetchAllData, + fetchCardDetails: mockFetchCardDetails, + }); + + // When: user presses Enable Card button + render(); + const enableButton = screen.getByTestId( + CardHomeSelectors.ENABLE_ASSETS_BUTTON, + ); + fireEvent.press(enableButton); + + // Then: navigates to SpendingLimit (delegation) instead of ChooseYourCard + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + 'CardSpendingLimit', + expect.objectContaining({ + flow: 'manage', + }), + ); + }); + }); + + it('navigates to delegation when metal card checkout is disabled', async () => { + // Given: Verified US user but metal card checkout is disabled + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'us', + isMetalCardCheckoutEnabled: false, + }); + + (useLoadCardData as jest.Mock).mockReturnValueOnce({ + priorityToken: null, + allTokens: [], + cardDetails: null, + isLoading: false, + error: null, + warning: CardStateWarning.NeedDelegation, + isAuthenticated: true, + isBaanxLoginEnabled: true, + isCardholder: true, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: { + id: 'user-123', + addressLine1: '123 Main St', + city: 'New York', + zip: '10001', + usState: 'NY', + }, + }, + fetchAllData: mockFetchAllData, + refetchAllData: mockRefetchAllData, + fetchCardDetails: mockFetchCardDetails, + }); + + // When: user presses Enable Card button + render(); + const enableButton = screen.getByTestId( + CardHomeSelectors.ENABLE_ASSETS_BUTTON, + ); + fireEvent.press(enableButton); + + // Then: navigates to SpendingLimit (delegation) instead of ChooseYourCard + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + 'CardSpendingLimit', + expect.objectContaining({ + flow: 'manage', + }), + ); + }); + }); + + it('navigates to delegation for international user even with metal card enabled', async () => { + // Given: Verified international user with metal card checkout enabled + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'international', + isMetalCardCheckoutEnabled: true, + }); + + (useLoadCardData as jest.Mock).mockReturnValueOnce({ + priorityToken: null, + allTokens: [], + cardDetails: null, + isLoading: false, + error: null, + warning: CardStateWarning.NeedDelegation, + isAuthenticated: true, + isBaanxLoginEnabled: true, + isCardholder: true, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: { + id: 'user-123', + addressLine1: '123 Main St', + city: 'London', + zip: 'SW1A 1AA', + }, + }, + fetchAllData: mockFetchAllData, + refetchAllData: mockRefetchAllData, + fetchCardDetails: mockFetchCardDetails, + }); + + // When: user presses Enable Card button + render(); + const enableButton = screen.getByTestId( + CardHomeSelectors.ENABLE_ASSETS_BUTTON, + ); + fireEvent.press(enableButton); + + // Then: navigates to SpendingLimit (delegation), not ChooseYourCard + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + 'CardSpendingLimit', + expect.objectContaining({ + flow: 'manage', + }), + ); + }); + }); + + it('navigates to delegation when US user has no shipping address', async () => { + // Given: Verified US user with metal card enabled but no address data + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'us', + isMetalCardCheckoutEnabled: true, + }); + + (useLoadCardData as jest.Mock).mockReturnValueOnce({ + priorityToken: null, + allTokens: [], + cardDetails: null, + isLoading: false, + error: null, + warning: CardStateWarning.NeedDelegation, + isAuthenticated: true, + isBaanxLoginEnabled: true, + isCardholder: true, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: null, + }, + fetchAllData: mockFetchAllData, + refetchAllData: mockRefetchAllData, + fetchCardDetails: mockFetchCardDetails, + }); + + // When: user presses Enable Card button + render(); + const enableButton = screen.getByTestId( + CardHomeSelectors.ENABLE_ASSETS_BUTTON, + ); + fireEvent.press(enableButton); + + // Then: navigates to delegation since no shipping address is available + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + 'CardSpendingLimit', + expect.objectContaining({ + flow: 'manage', + }), + ); + }); + }); + }); }); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 9268ccee8ac..23e8e504812 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -950,6 +950,58 @@ const CardHome = () => { [isAuthenticated, kycStatus, warning, externalWalletDetailsData], ); + const shouldRedirectToChooseCard = useMemo( + () => + !isLoading && + !cardSetupState.isKYCPending && + !isCardProvisioning && + isMetalCardCheckoutEnabled && + isBaanxLoginEnabled && + isAuthenticated && + warning === CardStateWarning.NoCard && + userLocation === 'us' && + !!userShippingAddress, + [ + isLoading, + cardSetupState.isKYCPending, + isCardProvisioning, + isMetalCardCheckoutEnabled, + isBaanxLoginEnabled, + isAuthenticated, + warning, + userLocation, + userShippingAddress, + ], + ); + + const navigateToChooseYourCard = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) + .addProperties({ + action: CardActions.ORDER_METAL_CARD_BUTTON, + }) + .build(), + ); + + navigation.navigate(Routes.CARD.CHOOSE_YOUR_CARD, { + flow: 'home', + shippingAddress: userShippingAddress, + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, + }); + }, [ + navigation, + trackEvent, + createEventBuilder, + userShippingAddress, + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, + ]); + const ButtonsSection = useMemo(() => { if (isLoading) { return ( @@ -989,7 +1041,11 @@ const CardHome = () => { variant={ButtonVariants.Primary} label={strings('card.card_home.enable_card_button_label')} size={ButtonSize.Lg} - onPress={openOnboardingDelegationAction} + onPress={ + shouldRedirectToChooseCard + ? navigateToChooseYourCard + : openOnboardingDelegationAction + } width={ButtonWidthTypes.Full} testID={cardSetupState.setupTestId} /> @@ -1032,6 +1088,8 @@ const CardHome = () => { tw, openOnboardingDelegationAction, isCardProvisioning, + shouldRedirectToChooseCard, + navigateToChooseYourCard, ]); const isUserEligibleForMetalCard = useMemo( diff --git a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx index 9037fc36450..84e7b8be547 100644 --- a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx +++ b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; import ChooseYourCard from './ChooseYourCard'; import { ChooseYourCardSelectors } from './ChooseYourCard.testIds'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; -import { CardType } from '../../types'; +import { AllowanceState, CardType } from '../../types'; import { CardActions, CardScreens } from '../../util/metrics'; const mockNavigate = jest.fn(); @@ -26,11 +26,13 @@ jest.mock('@react-navigation/native', () => { }; }); +const mockUseParams = jest.fn(() => ({ + flow: 'onboarding', + shippingAddress: undefined, +})); + jest.mock('../../../../../util/navigation/navUtils', () => ({ - useParams: () => ({ - flow: 'onboarding', - shippingAddress: undefined, - }), + useParams: () => mockUseParams(), })); jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ @@ -44,8 +46,9 @@ jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => { const map: Record = { 'card.choose_your_card.title': 'Choose your card', + 'card.choose_your_card.upgrade_title': 'Upgrade to Metal', 'card.choose_your_card.continue_button': 'Continue', - 'card.choose_your_card.virtual_card.name': 'Orange Virtual Card', + 'card.choose_your_card.virtual_card.name': 'Virtual Card', 'card.choose_your_card.virtual_card.price': 'Free', 'card.choose_your_card.virtual_card.feature_1': 'Virtual card for Apple Pay and Google Pay', @@ -55,17 +58,37 @@ jest.mock('../../../../../../locales/i18n', () => ({ '1% USDC cashback on every purchase', 'card.choose_your_card.metal_card.name': 'Metal Card', 'card.choose_your_card.metal_card.price': '$199/year', + 'card.choose_your_card.metal_card.everything_in_virtual': + 'Everything in virtual, plus:', 'card.choose_your_card.metal_card.feature_1': - 'Engraved metal card and virtual card for Apple Pay and Google Pay', + 'Premium engraved metal card', 'card.choose_your_card.metal_card.feature_2': - '3% cashback on the first $10,000 spent each year, then 1% after that', + '3% cashback on first $10,000/year', 'card.choose_your_card.metal_card.feature_3': 'No foreign transaction fees', + 'card.choose_your_card.earn_up_to_badge': + 'Earn up to $300 in cashback annually', + 'card.choose_your_card.upgrade_to_metal_label': + 'Or upgrade to Metal for 3x rewards', }; return map[key] || key; }, })); +jest.mock('react-native-linear-gradient', () => { + // eslint-disable-next-line @typescript-eslint/no-shadow + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + children, + ...props + }: React.PropsWithChildren>) => + React.createElement(View, props, children), + }; +}); + // Mock CardImage component jest.mock('../../components/CardImage/CardImage', () => { // eslint-disable-next-line @typescript-eslint/no-shadow @@ -156,12 +179,16 @@ describe('ChooseYourCard', () => { it('renders all required UI elements', () => { const { getByTestId } = render(); - expect(getByTestId(ChooseYourCardSelectors.CONTAINER)).toBeTruthy(); - expect(getByTestId(ChooseYourCardSelectors.TITLE)).toBeTruthy(); - expect(getByTestId(ChooseYourCardSelectors.CARD_CAROUSEL)).toBeTruthy(); - expect(getByTestId(ChooseYourCardSelectors.CARD_NAME)).toBeTruthy(); - expect(getByTestId(ChooseYourCardSelectors.CARD_PRICE)).toBeTruthy(); - expect(getByTestId(ChooseYourCardSelectors.CONTINUE_BUTTON)).toBeTruthy(); + expect(getByTestId(ChooseYourCardSelectors.CONTAINER)).toBeOnTheScreen(); + expect(getByTestId(ChooseYourCardSelectors.TITLE)).toBeOnTheScreen(); + expect( + getByTestId(ChooseYourCardSelectors.CARD_CAROUSEL), + ).toBeOnTheScreen(); + expect(getByTestId(ChooseYourCardSelectors.CARD_NAME)).toBeOnTheScreen(); + expect(getByTestId(ChooseYourCardSelectors.CARD_PRICE)).toBeOnTheScreen(); + expect( + getByTestId(ChooseYourCardSelectors.CONTINUE_BUTTON), + ).toBeOnTheScreen(); }); it('displays correct title text', () => { @@ -190,10 +217,10 @@ describe('ChooseYourCard', () => { getByTestId( `${ChooseYourCardSelectors.CARD_IMAGE}-${CardType.VIRTUAL}`, ), - ).toBeTruthy(); + ).toBeOnTheScreen(); expect( getByTestId(`${ChooseYourCardSelectors.CARD_IMAGE}-${CardType.METAL}`), - ).toBeTruthy(); + ).toBeOnTheScreen(); }); it('displays virtual card features by default', () => { @@ -201,13 +228,13 @@ describe('ChooseYourCard', () => { expect( getByText(strings('card.choose_your_card.virtual_card.feature_1')), - ).toBeTruthy(); + ).toBeOnTheScreen(); expect( getByText(strings('card.choose_your_card.virtual_card.feature_2')), - ).toBeTruthy(); + ).toBeOnTheScreen(); expect( getByText(strings('card.choose_your_card.virtual_card.feature_3')), - ).toBeTruthy(); + ).toBeOnTheScreen(); }); }); @@ -251,5 +278,110 @@ describe('ChooseYourCard', () => { flow: 'onboarding', }); }); + + it('navigates to spending limit with manage flow params when flow is home and virtual card selected', () => { + const priorityToken = { + caipChainId: 'eip155:1', + symbol: 'USDC', + name: 'USD Coin', + address: '0x123', + decimals: 6, + allowanceState: AllowanceState.Enabled, + allowance: '1000', + }; + const allTokens = [priorityToken]; + const delegationSettings = { networks: [] }; + const externalWalletDetailsData = { + walletDetails: {}, + mappedWalletDetails: [priorityToken], + priorityWalletDetail: priorityToken, + }; + + mockUseParams.mockImplementationOnce(() => ({ + flow: 'home', + shippingAddress: undefined, + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, + })); + + const { getByTestId } = render(); + + fireEvent.press(getByTestId(ChooseYourCardSelectors.CONTINUE_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.SPENDING_LIMIT, { + flow: 'manage', + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, + }); + }); + }); + + describe('Button Variant', () => { + it('renders Secondary variant when virtual card is selected', () => { + const { getByTestId } = render(); + + const continueButton = getByTestId( + ChooseYourCardSelectors.CONTINUE_BUTTON, + ); + expect(continueButton.props.children).toBeDefined(); + }); + + it('renders continue button for default virtual selection', () => { + const { getByTestId } = render(); + + expect( + getByTestId(ChooseYourCardSelectors.CONTINUE_BUTTON), + ).toBeOnTheScreen(); + }); + }); + + describe('Upgrade to Metal Link', () => { + it('shows upgrade link when virtual card is selected in onboarding flow', () => { + const { getByTestId } = render(); + + expect( + getByTestId(ChooseYourCardSelectors.UPGRADE_TO_METAL_BUTTON), + ).toBeOnTheScreen(); + }); + + it('displays correct upgrade link label', () => { + const { getByText } = render(); + + expect( + getByText(strings('card.choose_your_card.upgrade_to_metal_label')), + ).toBeOnTheScreen(); + }); + + it('scrolls to metal card when upgrade link is pressed', async () => { + const { getByTestId } = render(); + + fireEvent.press( + getByTestId(ChooseYourCardSelectors.UPGRADE_TO_METAL_BUTTON), + ); + + await waitFor(() => { + expect( + getByTestId(ChooseYourCardSelectors.CARD_NAME), + ).toHaveTextContent(strings('card.choose_your_card.metal_card.name')); + }); + }); + + it('hides upgrade link after scrolling to metal card', async () => { + const { getByTestId, queryByTestId } = render(); + + fireEvent.press( + getByTestId(ChooseYourCardSelectors.UPGRADE_TO_METAL_BUTTON), + ); + + await waitFor(() => { + expect( + queryByTestId(ChooseYourCardSelectors.UPGRADE_TO_METAL_BUTTON), + ).not.toBeOnTheScreen(); + }); + }); }); }); diff --git a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.testIds.ts b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.testIds.ts index a386ac74435..1c89b86863f 100644 --- a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.testIds.ts +++ b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.testIds.ts @@ -6,4 +6,5 @@ export const ChooseYourCardSelectors = { CARD_NAME: 'choose-your-card-name', CARD_PRICE: 'choose-your-card-price', CONTINUE_BUTTON: 'choose-your-card-continue-button', + UPGRADE_TO_METAL_BUTTON: 'choose-your-card-upgrade-to-metal-button', }; diff --git a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx index 290173210e4..96e51c63162 100644 --- a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx +++ b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx @@ -13,7 +13,10 @@ import { FlatList, ListRenderItem, View, + TouchableOpacity, + Animated, } from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { @@ -38,23 +41,44 @@ import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { CardActions, CardScreens } from '../../util/metrics'; import { ChooseYourCardSelectors } from './ChooseYourCard.testIds'; -import { CardType, CardStatus } from '../../types'; +import { + CardType, + CardStatus, + DelegationSettingsResponse, + CardExternalWalletDetailsResponse, + CardTokenAllowance, +} from '../../types'; import CardImage from '../../components/CardImage/CardImage'; import { useParams } from '../../../../../util/navigation/navUtils'; import type { ShippingAddress } from '../ReviewOrder'; -export type ChooseYourCardFlow = 'onboarding' | 'upgrade'; +export type ChooseYourCardFlow = 'onboarding' | 'upgrade' | 'home'; export interface ChooseYourCardParams { flow?: ChooseYourCardFlow; shippingAddress?: ShippingAddress; + priorityToken?: CardTokenAllowance | null; + allTokens?: CardTokenAllowance[]; + delegationSettings?: DelegationSettingsResponse | null; + externalWalletDetailsData?: + | { + walletDetails: never[]; + mappedWalletDetails: never[]; + priorityWalletDetail: null; + } + | { + walletDetails: CardExternalWalletDetailsResponse; + mappedWalletDetails: CardTokenAllowance[]; + priorityWalletDetail: CardTokenAllowance | undefined; + } + | null; } interface CardOption { id: CardType; name: string; price: string; - features: string[]; + features: { label: string; isHighlighted: boolean }[]; } const ItemSeparator = ({ width }: { width: number }) => ( @@ -68,11 +92,42 @@ const ChooseYourCard = () => { const { width: screenWidth } = useWindowDimensions(); const flatListRef = useRef(null); const [activeIndex, setActiveIndex] = useState(0); + const [hasUserSwiped, setHasUserSwiped] = useState(false); + const arrowAnimValue = useRef(new Animated.Value(0)).current; - const { flow = 'onboarding', shippingAddress } = - useParams(); + const { + flow = 'onboarding', + shippingAddress, + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, + } = useParams(); const isUpgradeFlow = flow === 'upgrade'; + // Arrow bounce animation for swipe indicator + useEffect(() => { + if (activeIndex !== 0 || isUpgradeFlow || hasUserSwiped) return; + + const animation = Animated.loop( + Animated.sequence([ + Animated.timing(arrowAnimValue, { + toValue: 8, + duration: 600, + useNativeDriver: true, + }), + Animated.timing(arrowAnimValue, { + toValue: 0, + duration: 600, + useNativeDriver: true, + }), + ]), + ); + animation.start(); + + return () => animation.stop(); + }, [activeIndex, isUpgradeFlow, arrowAnimValue, hasUserSwiped]); + const CARD_WIDTH = screenWidth - 64; const CARD_SPACING = 16; @@ -83,9 +138,18 @@ const ChooseYourCard = () => { name: strings('card.choose_your_card.virtual_card.name'), price: strings('card.choose_your_card.virtual_card.price'), features: [ - strings('card.choose_your_card.virtual_card.feature_1'), - strings('card.choose_your_card.virtual_card.feature_2'), - strings('card.choose_your_card.virtual_card.feature_3'), + { + label: strings('card.choose_your_card.virtual_card.feature_1'), + isHighlighted: false, + }, + { + label: strings('card.choose_your_card.virtual_card.feature_2'), + isHighlighted: false, + }, + { + label: strings('card.choose_your_card.virtual_card.feature_3'), + isHighlighted: false, + }, ], }, { @@ -93,9 +157,24 @@ const ChooseYourCard = () => { name: strings('card.choose_your_card.metal_card.name'), price: strings('card.choose_your_card.metal_card.price'), features: [ - strings('card.choose_your_card.metal_card.feature_1'), - strings('card.choose_your_card.metal_card.feature_2'), - strings('card.choose_your_card.metal_card.feature_3'), + { + label: strings( + 'card.choose_your_card.metal_card.everything_in_virtual', + ), + isHighlighted: false, + }, + { + label: strings('card.choose_your_card.metal_card.feature_1'), + isHighlighted: true, + }, + { + label: strings('card.choose_your_card.metal_card.feature_2'), + isHighlighted: true, + }, + { + label: strings('card.choose_your_card.metal_card.feature_3'), + isHighlighted: true, + }, ], }, ], @@ -121,7 +200,65 @@ const ChooseYourCard = () => { ); }, [trackEvent, createEventBuilder, flow]); + const peekTimersRef = useRef[]>([]); + const peekStoppedRef = useRef(false); + + const stopPeekAnimation = useCallback(() => { + peekStoppedRef.current = true; + peekTimersRef.current.forEach(clearTimeout); + peekTimersRef.current = []; + }, []); + + useEffect(() => { + if (isUpgradeFlow || cardOptions.length <= 1) return; + + const peekDistance = (CARD_WIDTH + CARD_SPACING) * 0.15; + const BOUNCE_HOLD = 600; + const PAUSE_BETWEEN_BOUNCES = 3000; + const cycleDuration = BOUNCE_HOLD + PAUSE_BETWEEN_BOUNCES; + + const scheduleBounce = (delay: number) => { + peekTimersRef.current.push( + setTimeout(() => { + if (peekStoppedRef.current) return; + flatListRef.current?.scrollToOffset({ + offset: peekDistance, + animated: true, + }); + }, delay), + ); + + peekTimersRef.current.push( + setTimeout(() => { + if (peekStoppedRef.current) return; + flatListRef.current?.scrollToOffset({ + offset: 0, + animated: true, + }); + }, delay + BOUNCE_HOLD), + ); + + peekTimersRef.current.push( + setTimeout(() => { + if (peekStoppedRef.current) return; + scheduleBounce(0); + }, delay + cycleDuration), + ); + }; + + scheduleBounce(800); + + return stopPeekAnimation; + }, [ + isUpgradeFlow, + cardOptions.length, + CARD_WIDTH, + CARD_SPACING, + stopPeekAnimation, + ]); + const handleContinue = useCallback(() => { + stopPeekAnimation(); const selectedCard = cardOptions[activeIndex]; trackEvent( @@ -135,7 +272,18 @@ const ChooseYourCard = () => { ); if (selectedCard.id === CardType.VIRTUAL) { - navigate(Routes.CARD.SPENDING_LIMIT, { flow: 'onboarding' }); + navigate( + Routes.CARD.SPENDING_LIMIT, + flow === 'onboarding' + ? { flow: 'onboarding' } + : { + flow: 'manage', + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, + }, + ); } else { navigate(Routes.CARD.REVIEW_ORDER, { shippingAddress, @@ -151,17 +299,37 @@ const ChooseYourCard = () => { flow, shippingAddress, isUpgradeFlow, + stopPeekAnimation, + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, ]); + const handleScrollToMetal = useCallback(() => { + stopPeekAnimation(); + setHasUserSwiped(true); + flatListRef.current?.scrollToIndex({ index: 1, animated: true }); + setTimeout(() => setActiveIndex(1), 300); + }, [stopPeekAnimation]); + const handleScroll = useCallback( (event: NativeSyntheticEvent) => { const contentOffsetX = event.nativeEvent.contentOffset.x; const index = Math.round(contentOffsetX / (CARD_WIDTH + CARD_SPACING)); if (index !== activeIndex && index >= 0 && index < cardOptions.length) { + stopPeekAnimation(); + setHasUserSwiped(true); setActiveIndex(index); } }, - [activeIndex, cardOptions.length, CARD_WIDTH, CARD_SPACING], + [ + activeIndex, + cardOptions.length, + CARD_WIDTH, + CARD_SPACING, + stopPeekAnimation, + ], ); const renderCardItem: ListRenderItem = useCallback( @@ -178,17 +346,17 @@ const ChooseYourCard = () => { ); const renderFeatureItem = useCallback( - (feature: string, index: number) => ( + (feature: string, index: number, isHighlighted: boolean) => ( {feature} @@ -235,7 +403,6 @@ const ChooseYourCard = () => { ); const selectedCard = cardOptions[activeIndex]; - const showPagination = cardOptions.length > 1; return ( @@ -257,12 +424,14 @@ const ChooseYourCard = () => { - + item.id} horizontal showsHorizontalScrollIndicator={false} @@ -276,6 +445,36 @@ const ChooseYourCard = () => { getItemLayout={getItemLayout} testID={ChooseYourCardSelectors.CARD_CAROUSEL} /> + {activeIndex === 0 && + !isUpgradeFlow && + !hasUserSwiped && + cardOptions.length > 1 && ( + + + + + + + )} {showPagination && ( @@ -302,21 +501,59 @@ const ChooseYourCard = () => { - + {selectedCard.id === CardType.METAL && ( + + + + + {strings('card.choose_your_card.earn_up_to_badge')} + + + + )} + + {selectedCard.features.map((feature, index) => - renderFeatureItem(feature, index), + renderFeatureItem(feature.label, index, feature.isHighlighted), )} - + + + ); +}; + +export default AssetOverviewClaimBonus; diff --git a/app/components/UI/Earn/components/AssetOverviewClaimBonus/index.ts b/app/components/UI/Earn/components/AssetOverviewClaimBonus/index.ts new file mode 100644 index 00000000000..07bdd246b74 --- /dev/null +++ b/app/components/UI/Earn/components/AssetOverviewClaimBonus/index.ts @@ -0,0 +1 @@ +export { default } from './AssetOverviewClaimBonus'; diff --git a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.styles.ts b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.styles.ts index 0f2ebbc2001..6dbe4ccb2b5 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.styles.ts +++ b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.styles.ts @@ -1,4 +1,4 @@ -import { StyleSheet } from 'react-native'; +import { StyleSheet, TextStyle } from 'react-native'; import { Theme } from '../../../../../util/theme/models'; const styleSheet = (params: { @@ -16,10 +16,7 @@ const styleSheet = (params: { gap: 16, }, buttonsContainer: { - marginTop: 16, - padding: 16, borderRadius: 12, - backgroundColor: theme.colors.background.section, }, button: { flex: 1, @@ -29,10 +26,15 @@ const styleSheet = (params: { }, balances: { flex: 1, - justifyContent: 'center', - marginLeft: 16, - alignSelf: 'center', - }, + flexDirection: 'column', + alignItems: 'flex-start', + alignContent: 'flex-start', + paddingLeft: 16, + }, + tokenAmount: { + ...theme.typography.sBodySM, + color: theme.colors.text.alternative, + } as TextStyle, musdConversionCta: { paddingTop: 16, paddingBottom: userHasLendingPositions ? 8 : 0, @@ -41,7 +43,6 @@ const styleSheet = (params: { paddingTop: 16, }, earnings: { - paddingHorizontal: 16, paddingTop: 16, }, }); diff --git a/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap b/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap index 32786f9cd2d..ed3a2a3a78b 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap +++ b/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap @@ -12,59 +12,102 @@ exports[`EarnLendingBalance does renders earnings for output tokens 1`] = ` "paddingTop": 14, }, { - "backgroundColor": "#f3f3f4", "borderRadius": 12, - "marginTop": 16, - "padding": 16, }, ] } > - - - Withdraw - - + + Withdraw + + + @@ -420,22 +464,21 @@ exports[`EarnLendingBalance renders balance and buttons when user has lending po > ADAI - - - +5.20% - - + } + > + 32.05 ADAI + $76.00 - + - 32.05 ADAI + +5.20% - + - - - Withdraw - - - + Withdraw + + + + - - Deposit more - - + + Deposit more + + + `; diff --git a/app/components/UI/Earn/components/EarnLendingBalance/index.tsx b/app/components/UI/Earn/components/EarnLendingBalance/index.tsx index 2caae94baf7..6e6df00857c 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/index.tsx +++ b/app/components/UI/Earn/components/EarnLendingBalance/index.tsx @@ -14,10 +14,9 @@ import Badge, { import BadgeWrapper, { BadgePosition, } from '../../../../../component-library/components/Badges/BadgeWrapper'; -import Button, { - ButtonSize, - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; +import SensitiveText, { + SensitiveTextLength, +} from '../../../../../component-library/components/Texts/SensitiveText'; import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; @@ -26,6 +25,7 @@ import Engine from '../../../../../core/Engine'; import { RootState } from '../../../../../reducers'; import { earnSelectors } from '../../../../../selectors/earnController'; import { selectNetworkConfigurationByChainId } from '../../../../../selectors/networkController'; +import { selectPrivacyMode } from '../../../../../selectors/preferencesController'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { useStyles } from '../../../../hooks/useStyles'; @@ -44,6 +44,12 @@ import { trace, TraceName } from '../../../../../util/trace'; import MusdConversionAssetOverviewCta from '../Musd/MusdConversionAssetOverviewCta'; import useStakingEligibility from '../../../Stake/hooks/useStakingEligibility'; import { useMusdCtaVisibility } from '../../hooks/useMusdCtaVisibility'; +import { + Button, + ButtonVariant, + ButtonSize, + Text as DesignSystemText, +} from '@metamask/design-system-react-native'; export const EARN_LENDING_BALANCE_TEST_IDS = { RECEIPT_TOKEN_BALANCE_ASSET_LOGO: 'receipt-token-balance-asset-logo', @@ -74,6 +80,7 @@ const EarnLendingBalance = ({ asset }: EarnLendingBalanceProps) => { const isStablecoinLendingEnabled = useSelector( selectStablecoinLendingEnabledFlag, ); + const privacyMode = useSelector(selectPrivacyMode); const navigation = useNavigation(); @@ -236,7 +243,11 @@ const EarnLendingBalance = ({ asset }: EarnLendingBalanceProps) => { + } > { > {receiptToken.name} - + + {receiptToken.balanceFormatted} + )} @@ -275,25 +293,29 @@ const EarnLendingBalance = ({ asset }: EarnLendingBalanceProps) => { {Boolean(receiptToken) && ( )} {userHasUnderlyingTokensAvailableToLend && !isAssetReceiptToken && isEligible && ( )} )} diff --git a/app/components/UI/Earn/components/EarnTokenList/EarnTokenList.test.tsx b/app/components/UI/Earn/components/EarnTokenList/EarnTokenList.test.tsx index 1afbf808532..8a7b7b6e81e 100644 --- a/app/components/UI/Earn/components/EarnTokenList/EarnTokenList.test.tsx +++ b/app/components/UI/Earn/components/EarnTokenList/EarnTokenList.test.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/no-namespace */ +/* eslint-disable import-x/no-namespace */ import { act, fireEvent } from '@testing-library/react-native'; import { TrxScope } from '@metamask/keyring-api'; import React from 'react'; diff --git a/app/components/UI/Earn/components/Earnings/EarningsHistoryButton/EarningsHistoryButton.tsx b/app/components/UI/Earn/components/Earnings/EarningsHistoryButton/EarningsHistoryButton.tsx index 80f3e8bdcdc..1d686c4c61b 100644 --- a/app/components/UI/Earn/components/Earnings/EarningsHistoryButton/EarningsHistoryButton.tsx +++ b/app/components/UI/Earn/components/Earnings/EarningsHistoryButton/EarningsHistoryButton.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { View } from 'react-native'; import { strings } from '../../../../../../../locales/i18n'; import Button, { + ButtonSize, ButtonVariants, ButtonWidthTypes, } from '../../../../../../component-library/components/Buttons/Button'; @@ -37,6 +38,7 @@ const EarningsHistoryButton = ({ asset }: EarningsHistoryButtonProps) => { testID={WalletViewSelectorsIDs.EARN_EARNINGS_HISTORY_BUTTON} width={ButtonWidthTypes.Full} variant={ButtonVariants.Secondary} + size={ButtonSize.Md} label={ outputToken?.experience?.type === EARN_EXPERIENCES.STABLECOIN_LENDING ? strings('earn.view_earnings_history.lending') diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.view.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.view.test.tsx index c2eb29cbffc..3b4a34b3a1a 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.view.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.view.test.tsx @@ -1,7 +1,7 @@ import '../../../../../../../tests/component-view/mocks'; import { renderComponentViewScreen } from '../../../../../../../tests/component-view/render'; import { initialStateWallet } from '../../../../../../../tests/component-view/presets/wallet'; -import { describeForPlatforms } from '../../../../../../util/test/platform'; +import { describeForPlatforms } from '../../../../../../../tests/component-view/platform'; import React from 'react'; import { View } from 'react-native'; import MusdConversionAssetListCta from './index'; diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx index 3fa6a6945e6..d815be5d533 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx @@ -1,7 +1,7 @@ import '../../../../../../../tests/component-view/mocks'; import { renderComponentViewScreen } from '../../../../../../../tests/component-view/render'; import { initialStateWallet } from '../../../../../../../tests/component-view/presets/wallet'; -import { describeForPlatforms } from '../../../../../../util/test/platform'; +import { describeForPlatforms } from '../../../../../../../tests/component-view/platform'; import React from 'react'; import { View } from 'react-native'; import MusdConversionAssetOverviewCta from './index'; diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.styles.ts b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.styles.ts deleted file mode 100644 index 52d1b4225cf..00000000000 --- a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.styles.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { Theme } from '../../../../../../util/theme/models'; - -const styleSheet = (params: { theme: Theme }) => - StyleSheet.create({ - balanceButtonsContainer: { - marginTop: 16, - padding: 16, - borderRadius: 12, - backgroundColor: params.theme.colors.background.section, - }, - buttonsRow: { - flexDirection: 'row', - justifyContent: 'space-between', - gap: 16, - }, - balanceActionButton: { - flex: 1, - }, - }); - -export default styleSheet; diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx index 6c3255a4063..9ae6c33edcf 100644 --- a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx +++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx @@ -27,16 +27,6 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: mockNavigate }), })); -jest.mock('../../../../../../component-library/hooks', () => ({ - useStyles: () => ({ - styles: { - balanceButtonsContainer: {}, - balanceActionButton: {}, - buttonsRow: {}, - }, - }), -})); - const mockTrackEvent = jest.fn(); const mockBuilderAddProps = jest.fn().mockReturnThis(); const mockBuilderBuild = jest.fn().mockReturnValue({}); diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx index e2aaaddd41c..ba8e90e84fe 100644 --- a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx +++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx @@ -1,15 +1,15 @@ import React from 'react'; import { useSelector } from 'react-redux'; -import { View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import Button, { - ButtonVariants, -} from '../../../../../../component-library/components/Buttons/Button'; -import { useStyles } from '../../../../../../component-library/hooks'; -import { useTheme } from '../../../../../../util/theme'; +import { + Box, + BoxFlexDirection, + BoxJustifyContent, + Button, + ButtonVariant, +} from '@metamask/design-system-react-native'; import Routes from '../../../../../../constants/navigation/Routes'; import { TokenI } from '../../../../Tokens/types'; -import styleSheet from './TronStakingButtons.styles'; import { TronStakingButtonsTestIds } from './TronStakingButtons.testIds'; import { strings } from '../../../../../../../locales/i18n'; import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; @@ -25,8 +25,6 @@ interface TronStakingButtonsProps { } const TronStakingButtons = ({ asset }: TronStakingButtonsProps) => { - const theme = useTheme(); - const { styles } = useStyles(styleSheet, { theme }); const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useAnalytics(); const { isEligible } = useStakingEligibility(); @@ -76,26 +74,30 @@ const TronStakingButtons = ({ asset }: TronStakingButtonsProps) => { }; return ( - - + + + {isEligible && ( + )} + ); }; diff --git a/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.styles.ts b/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.styles.ts deleted file mode 100644 index 22503998c09..00000000000 --- a/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.styles.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { Theme } from '../../../../../../util/theme/models'; - -const styleSheet = (params: { theme: Theme }) => - StyleSheet.create({ - container: { - marginTop: 16, - padding: 16, - borderRadius: 12, - backgroundColor: params.theme.colors.background.section, - }, - ctaContent: { - alignItems: 'center', - marginBottom: 16, - gap: 4, - }, - ctaTitle: { - textAlign: 'center', - }, - ctaText: { - textAlign: 'center', - }, - earnButton: { - width: '100%', - }, - }); - -export default styleSheet; diff --git a/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.test.tsx b/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.test.tsx index aedc9fec711..b5d2024e456 100644 --- a/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.test.tsx +++ b/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.test.tsx @@ -13,18 +13,6 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: mockNavigate }), })); -jest.mock('../../../../../../component-library/hooks', () => ({ - useStyles: () => ({ - styles: { - container: {}, - ctaContent: {}, - ctaTitle: {}, - ctaText: {}, - earnButton: {}, - }, - }), -})); - const mockTrackEvent = jest.fn(); jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({ diff --git a/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.tsx b/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.tsx index ee1c5e9191d..4f4812ad574 100644 --- a/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.tsx +++ b/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.tsx @@ -1,18 +1,17 @@ import React from 'react'; -import { View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import Button, { - ButtonVariants, -} from '../../../../../../component-library/components/Buttons/Button'; -import Text, { - TextColor, +import { + Box, + BoxAlignItems, + BoxBackgroundColor, + Text, TextVariant, -} from '../../../../../../component-library/components/Texts/Text'; -import { useStyles } from '../../../../../../component-library/hooks'; -import { useTheme } from '../../../../../../util/theme'; + TextColor, + Button, + ButtonVariant, +} from '@metamask/design-system-react-native'; import Routes from '../../../../../../constants/navigation/Routes'; import { TokenI } from '../../../../Tokens/types'; -import styleSheet from './TronStakingCta.styles'; import { TronStakingCtaTestIds } from './TronStakingCta.testIds'; import { strings } from '../../../../../../../locales/i18n'; import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; @@ -27,8 +26,6 @@ interface TronStakingCtaProps { } const TronStakingCta = ({ asset, aprText }: TronStakingCtaProps) => { - const theme = useTheme(); - const { styles } = useStyles(styleSheet, { theme }); const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useAnalytics(); const { isEligible } = useStakingEligibility(); @@ -55,25 +52,33 @@ const TronStakingCta = ({ asset, aprText }: TronStakingCtaProps) => { }; return ( - - - + + + {strings('stake.stake_your_trx_cta.title')} - + {strings('stake.stake_your_trx_cta.description_start')} - {aprText ? {aprText} : null} + {aprText ? ( + {aprText} + ) : null} {strings('stake.stake_your_trx_cta.description_end')} - + + ); }; diff --git a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx index 02f082ad394..9ba6d9e7854 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx +++ b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx @@ -1,29 +1,169 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { fireEvent } from '@testing-library/react-native'; +import type { CaipChainId } from '@metamask/utils'; import TronUnstakedBanner from './TronUnstakedBanner'; -import { TronUnstakedBannerTestIds } from './TronUnstakedBanner.testIds'; import { strings } from '../../../../../../../locales/i18n'; +import useTronClaimUnstakedTrx from '../../../hooks/useTronClaimUnstakedTrx'; +import useEarnToasts from '../../../hooks/useEarnToasts'; +import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import { selectTronClaimUnstakedTrxButtonEnabled } from '../../../../../../selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled'; +import { TronUnstakedBannerTestIds } from './TronUnstakedBanner.testIds'; + +jest.mock( + '../../../../../../selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled', + () => ({ + selectTronClaimUnstakedTrxButtonEnabled: jest.fn(), + }), +); + +jest.mock('../../../hooks/useTronClaimUnstakedTrx'); +const mockUseTronClaimUnstakedTrx = + useTronClaimUnstakedTrx as jest.MockedFunction< + typeof useTronClaimUnstakedTrx + >; + +const mockShowToast = jest.fn(); +const mockFailedToastResult = { variant: 'Icon', labelOptions: [] }; +const mockFailedToastFn = jest.fn().mockReturnValue(mockFailedToastResult); +jest.mock('../../../hooks/useEarnToasts'); +(useEarnToasts as jest.Mock).mockReturnValue({ + showToast: mockShowToast, + EarnToastOptions: { + tronWithdrawal: { failed: mockFailedToastFn }, + }, +}); + +const mockSelectTronClaimUnstakedTrxButtonEnabled = + selectTronClaimUnstakedTrxButtonEnabled as unknown as jest.Mock; + +const renderBanner = (props: { amount: string; chainId: CaipChainId }) => + renderWithProvider(, undefined, false); describe('TronUnstakedBanner', () => { - it('renders the claim text with the given amount', () => { - const { getByTestId } = render(); + const mockHandleClaimUnstakedTrx = jest.fn(); - const expected = strings('stake.tron.has_claimable_trx', { + beforeEach(() => { + jest.clearAllMocks(); + mockSelectTronClaimUnstakedTrxButtonEnabled.mockReturnValue(true); + mockUseTronClaimUnstakedTrx.mockReturnValue({ + handleClaimUnstakedTrx: mockHandleClaimUnstakedTrx, + isSubmitting: false, + errors: undefined, + }); + (useEarnToasts as jest.Mock).mockReturnValue({ + showToast: mockShowToast, + EarnToastOptions: { + tronWithdrawal: { failed: mockFailedToastFn }, + }, + }); + }); + + it('renders the title with the given amount', () => { + const { getByText } = renderBanner({ amount: '100', + chainId: 'tron:728126428', }); + + const expectedTitle = strings('stake.tron.unstaked_banner.title', { + amount: '100', + }); + expect(getByText(expectedTitle)).toBeOnTheScreen(); + }); + + it('renders the description', () => { + const { getByText } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); + + const expectedDescription = strings( + 'stake.tron.unstaked_banner.description', + ); + expect(getByText(expectedDescription)).toBeOnTheScreen(); + }); + + it('renders the claim button when tronClaimUnstakedTrxButtonEnabled is true', () => { + const { getByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); + expect( - getByTestId(TronUnstakedBannerTestIds.BANNER_TEXT), - ).toHaveTextContent(expected); + getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON), + ).toBeOnTheScreen(); }); - it('renders with a different amount', () => { - const { getByTestId } = render(); + it('does not render the claim button when tronClaimUnstakedTrxButtonEnabled is false', () => { + mockSelectTronClaimUnstakedTrxButtonEnabled.mockReturnValue(false); - const expected = strings('stake.tron.has_claimable_trx', { - amount: '2,500', + const { getByText, queryByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', }); + expect( - getByTestId(TronUnstakedBannerTestIds.BANNER_TEXT), - ).toHaveTextContent(expected); + queryByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON), + ).not.toBeOnTheScreen(); + expect( + getByText(strings('stake.tron.unstaked_banner.description')), + ).toBeOnTheScreen(); + }); + + it('calls handleClaimUnstakedTrx when button is pressed', () => { + const { getByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); + + fireEvent.press(getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON)); + expect(mockHandleClaimUnstakedTrx).toHaveBeenCalledTimes(1); + }); + + it('disables the button when isSubmitting is true', () => { + mockUseTronClaimUnstakedTrx.mockReturnValue({ + handleClaimUnstakedTrx: mockHandleClaimUnstakedTrx, + isSubmitting: true, + errors: undefined, + }); + + const { getByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); + + const button = getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON); + expect(button.props.accessibilityState?.disabled).toBe(true); + }); + + it('shows error toast when errors are returned', () => { + mockUseTronClaimUnstakedTrx.mockReturnValue({ + handleClaimUnstakedTrx: mockHandleClaimUnstakedTrx, + isSubmitting: false, + errors: ['InsufficientBalance'], + }); + + renderBanner({ amount: '100', chainId: 'tron:728126428' }); + + expect(mockFailedToastFn).toHaveBeenCalledWith(['InsufficientBalance']); + expect(mockShowToast).toHaveBeenCalledWith(mockFailedToastResult); + }); + + it('shows title-only error toast when valid is false without explicit errors', () => { + mockUseTronClaimUnstakedTrx.mockReturnValue({ + handleClaimUnstakedTrx: mockHandleClaimUnstakedTrx, + isSubmitting: false, + errors: [], + }); + + renderBanner({ amount: '100', chainId: 'tron:728126428' }); + + expect(mockFailedToastFn).toHaveBeenCalledWith([]); + expect(mockShowToast).toHaveBeenCalledWith(mockFailedToastResult); + }); + + it('does not show error toast when there are no errors', () => { + renderBanner({ amount: '100', chainId: 'tron:728126428' }); + + expect(mockShowToast).not.toHaveBeenCalled(); }); }); diff --git a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.testIds.ts b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.testIds.ts index e63594cf588..8118ba57fd9 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.testIds.ts +++ b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.testIds.ts @@ -1,3 +1,4 @@ export enum TronUnstakedBannerTestIds { - BANNER_TEXT = 'tron-unstaked-banner', + BANNER_DESCRIPTION = 'tron-unstaked-banner-description', + CLAIM_BUTTON = 'tron-unstaked-banner-button', } diff --git a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx index de5efe97e61..65c6ba3d3d1 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx +++ b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx @@ -1,26 +1,68 @@ -import React from 'react'; +import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import type { CaipChainId } from '@metamask/utils'; import { strings } from '../../../../../../../locales/i18n'; import Banner, { BannerAlertSeverity, BannerVariant, } from '../../../../../../component-library/components/Banners/Banner'; -import { Text } from '@metamask/design-system-react-native'; +import { + Text, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; +import useTronClaimUnstakedTrx from '../../../hooks/useTronClaimUnstakedTrx'; +import useEarnToasts from '../../../hooks/useEarnToasts'; +import { selectTronClaimUnstakedTrxButtonEnabled } from '../../../../../../selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled'; import { TronUnstakedBannerTestIds } from './TronUnstakedBanner.testIds'; interface TronUnstakedBannerProps { amount: string; + chainId: CaipChainId; } -const TronUnstakedBanner = ({ amount }: TronUnstakedBannerProps) => ( - - {strings('stake.tron.has_claimable_trx', { amount })} - +const TronUnstakedBanner = ({ amount, chainId }: TronUnstakedBannerProps) => { + const showClaimButton = useSelector(selectTronClaimUnstakedTrxButtonEnabled); + const { handleClaimUnstakedTrx, isSubmitting, errors } = + useTronClaimUnstakedTrx({ chainId }); + const { showToast, EarnToastOptions } = useEarnToasts(); + + useEffect(() => { + if (errors) { + showToast(EarnToastOptions.tronWithdrawal.failed(errors)); } - /> -); + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only re-fire when a new error occurs; showToast/EarnToastOptions refs change on theme switch and would cause repeat toasts. + }, [errors]); + + return ( + + + {strings('stake.tron.unstaked_banner.description')} + + {showClaimButton ? ( + + ) : null} + + } + /> + ); +}; export default TronUnstakedBanner; diff --git a/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.test.tsx b/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.test.tsx index 7690dc03d6e..29715638e52 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.test.tsx +++ b/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.test.tsx @@ -1,29 +1,33 @@ import React from 'react'; import { render } from '@testing-library/react-native'; import TronUnstakingBanner from './TronUnstakingBanner'; -import { TronUnstakingBannerTestIds } from './TronUnstakingBanner.testIds'; import { strings } from '../../../../../../../locales/i18n'; describe('TronUnstakingBanner', () => { - it('renders the unstaking text with the given amount', () => { - const { getByTestId } = render(); + it('renders the title with the given amount', () => { + const { getByText } = render(); - const expected = strings('stake.tron.trx_unstaking_in_progress', { + const expectedTitle = strings('stake.tron.unstaking_banner.title', { amount: '500', }); - expect( - getByTestId(TronUnstakingBannerTestIds.BANNER_TEXT), - ).toHaveTextContent(expected); + expect(getByText(expectedTitle)).toBeOnTheScreen(); + }); + + it('renders the description', () => { + const { getByText } = render(); + + const expectedDescription = strings( + 'stake.tron.unstaking_banner.description', + ); + expect(getByText(expectedDescription)).toBeOnTheScreen(); }); it('renders with a different amount', () => { - const { getByTestId } = render(); + const { getByText } = render(); - const expected = strings('stake.tron.trx_unstaking_in_progress', { + const expectedTitle = strings('stake.tron.unstaking_banner.title', { amount: '1,234.5', }); - expect( - getByTestId(TronUnstakingBannerTestIds.BANNER_TEXT), - ).toHaveTextContent(expected); + expect(getByText(expectedTitle)).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.testIds.ts b/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.testIds.ts deleted file mode 100644 index 72fc0ac4dfa..00000000000 --- a/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.testIds.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum TronUnstakingBannerTestIds { - BANNER_TEXT = 'tron-unstaking-banner', -} diff --git a/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.tsx b/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.tsx index 58c75e54c68..83dc7d73a02 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.tsx +++ b/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.tsx @@ -4,8 +4,6 @@ import Banner, { BannerAlertSeverity, BannerVariant, } from '../../../../../../component-library/components/Banners/Banner'; -import { Text } from '@metamask/design-system-react-native'; -import { TronUnstakingBannerTestIds } from './TronUnstakingBanner.testIds'; interface TronUnstakingBannerProps { amount: string; @@ -15,11 +13,8 @@ const TronUnstakingBanner = ({ amount }: TronUnstakingBannerProps) => ( - {strings('stake.tron.trx_unstaking_in_progress', { amount })} - - } + title={strings('stake.tron.unstaking_banner.title', { amount })} + description={strings('stake.tron.unstaking_banner.description')} /> ); diff --git a/app/components/UI/Earn/constants/events/musdEvents.ts b/app/components/UI/Earn/constants/events/musdEvents.ts index 0abf0ed12c0..45b6b037161 100644 --- a/app/components/UI/Earn/constants/events/musdEvents.ts +++ b/app/components/UI/Earn/constants/events/musdEvents.ts @@ -8,6 +8,8 @@ const EVENT_LOCATIONS = { HOME_CASH_SECTION: 'home_cash_section', TOKEN_LIST_ITEM: 'token_list_item', ASSET_OVERVIEW: 'asset_overview', + ASSET_OVERVIEW_CLAIMABLE_BONUS_TOOLTIP: + 'asset_overview_claimable_bonus_tooltip', CONVERSION_EDUCATION_SCREEN: 'conversion_education_screen', CUSTOM_AMOUNT_SCREEN: 'custom_amount_screen', // Single convert screen. BUY_SCREEN: 'buy_screen', // Buy mUSD screen. @@ -16,6 +18,8 @@ const EVENT_LOCATIONS = { 'quick_convert_max_bottom_sheet_confirmation_screen', CUSTOM_AMOUNT_NAVBAR: 'custom_amount_navbar', PERCENTAGE_ROW: 'percentage_row', + /** CTA on full page Cash token list */ + MOBILE_TOKEN_LIST_PAGE: 'mobile-token-list-page', }; const MUSD_CTA_TYPES = { diff --git a/app/components/UI/Earn/hooks/useEarnToasts.tsx b/app/components/UI/Earn/hooks/useEarnToasts.tsx index 978311ff51f..b5ea01e41c5 100644 --- a/app/components/UI/Earn/hooks/useEarnToasts.tsx +++ b/app/components/UI/Earn/hooks/useEarnToasts.tsx @@ -48,6 +48,9 @@ export interface EarnToastOptionsConfig { success: EarnToastOptions; failed: EarnToastOptions; }; + tronWithdrawal: { + failed: (errors: string[]) => EarnToastOptions; + }; } interface EarnToastLabelOptions { @@ -233,6 +236,26 @@ const useEarnToasts = (): { closeButtonOptions, }, }, + tronWithdrawal: { + failed: (errors: string[]) => ({ + ...earnBaseToastOptions.error, + labelOptions: getEarnToastLabels({ + primary: strings('stake.tron.unstaked_banner.error'), + primaryIsBold: true, + ...(errors.length > 0 && { + secondary: ( + + {errors.map((err) => `\u2022 ${err}`).join('\n')} + + ), + }), + }), + closeButtonOptions, + }), + }, }), [ closeButtonOptions, diff --git a/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts b/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts index 0d696491db7..ff3aa66d5e0 100644 --- a/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts +++ b/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts @@ -129,6 +129,9 @@ describe('useMerklClaimStatus', () => { success: mockSuccessToast, failed: mockFailedToast, }, + tronWithdrawal: { + failed: jest.fn().mockReturnValue(mockFailedToast), + }, }; const createMockEventBuilder = () => { diff --git a/app/components/UI/Earn/hooks/useMusdConversion.test.ts b/app/components/UI/Earn/hooks/useMusdConversion.test.ts index 0a6f5137ab9..b3be0d3c9e8 100644 --- a/app/components/UI/Earn/hooks/useMusdConversion.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConversion.test.ts @@ -85,7 +85,7 @@ const mockTransactionPayController = { }; const mockApprovalController = { - reject: jest.fn(), + rejectRequest: jest.fn(), }; const mockFetchGasFeeEstimates = jest.fn().mockResolvedValue(undefined); @@ -967,7 +967,7 @@ describe('useMusdConversion', () => { ).rejects.toThrow(postCreationError); }); - expect(mockApprovalController.reject).toHaveBeenCalledWith( + expect(mockApprovalController.rejectRequest).toHaveBeenCalledWith( 'tx-max-123', expect.objectContaining({ message: @@ -1004,7 +1004,7 @@ describe('useMusdConversion', () => { ); const rejectCleanupError = new Error('Failed to reject pending approval'); - mockApprovalController.reject.mockImplementation(() => { + mockApprovalController.rejectRequest.mockImplementation(() => { throw rejectCleanupError; }); @@ -1016,7 +1016,7 @@ describe('useMusdConversion', () => { ).rejects.toThrow(postCreationError); }); - expect(mockApprovalController.reject).toHaveBeenCalledTimes(1); + expect(mockApprovalController.rejectRequest).toHaveBeenCalledTimes(1); expect(Logger.error).toHaveBeenCalledWith( rejectCleanupError, '[mUSD Max Conversion] Failed to reject transaction after post-creation configuration error', diff --git a/app/components/UI/Earn/hooks/useMusdConversion.ts b/app/components/UI/Earn/hooks/useMusdConversion.ts index b8d033a8553..4425ce87d7c 100644 --- a/app/components/UI/Earn/hooks/useMusdConversion.ts +++ b/app/components/UI/Earn/hooks/useMusdConversion.ts @@ -308,7 +308,7 @@ export const useMusdConversion = () => { 'Error creating max conversion transaction', ); try { - ApprovalController.reject( + ApprovalController.rejectRequest( transactionId, providerErrors.userRejectedRequest({ message: diff --git a/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts b/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts index 2795dd14830..3b3f0fb0780 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts @@ -180,6 +180,17 @@ describe('useMusdConversionStatus', () => { labelOptions: [{ label: 'Bonus claim failed', isBold: true }], }, }, + tronWithdrawal: { + failed: jest.fn().mockReturnValue({ + variant: ToastVariants.Icon as const, + iconName: IconName.Danger, + hasNoTimeout: false, + iconColor: '#000000', + backgroundColor: '#FFFFFF', + hapticsType: NotificationFeedbackType.Error, + labelOptions: [{ label: 'Withdrawal failed', isBold: true }], + }), + }, }; // Default mock data diff --git a/app/components/UI/Earn/hooks/useTronClaimUnstakedTrx.test.ts b/app/components/UI/Earn/hooks/useTronClaimUnstakedTrx.test.ts new file mode 100644 index 00000000000..2148bcf5fd5 --- /dev/null +++ b/app/components/UI/Earn/hooks/useTronClaimUnstakedTrx.test.ts @@ -0,0 +1,135 @@ +import { act, renderHook } from '@testing-library/react-native'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import useTronClaimUnstakedTrx from './useTronClaimUnstakedTrx'; +import { claimUnstakedTrx } from '../utils/tron-staking-snap'; + +const mockSelectSelectedInternalAccountByScope = jest.fn(); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn((selector) => selector()), +})); + +jest.mock('../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: () => + mockSelectSelectedInternalAccountByScope, +})); + +jest.mock('../utils/tron-staking-snap', () => ({ + claimUnstakedTrx: jest.fn(), +})); + +describe('useTronClaimUnstakedTrx', () => { + const mockClaimUnstakedTrx = claimUnstakedTrx as jest.MockedFunction< + typeof claimUnstakedTrx + >; + + const mockAccount: Partial = { + id: 'tron-account-1', + metadata: { + name: 'Tron Account', + snap: { + id: 'npm:@metamask/tron-wallet-snap', + name: 'Tron Wallet Snap', + enabled: true, + }, + importTime: 0, + keyring: { type: 'snap' }, + lastSelected: 0, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSelectSelectedInternalAccountByScope.mockReturnValue(mockAccount); + }); + + it('returns initial state', () => { + const { result } = renderHook(() => + useTronClaimUnstakedTrx({ chainId: 'tron:728126428' }), + ); + + expect(result.current.isSubmitting).toBe(false); + expect(result.current.errors).toBeUndefined(); + expect(typeof result.current.handleClaimUnstakedTrx).toBe('function'); + }); + + it('calls claimUnstakedTrx with correct params on handleClaimUnstakedTrx', async () => { + mockClaimUnstakedTrx.mockResolvedValue({ valid: true }); + + const { result } = renderHook(() => + useTronClaimUnstakedTrx({ chainId: 'tron:728126428' }), + ); + + await act(async () => { + await result.current.handleClaimUnstakedTrx(); + }); + + expect(mockClaimUnstakedTrx).toHaveBeenCalledWith(mockAccount, { + fromAccountId: 'tron-account-1', + assetId: 'tron:728126428/slip44:195', + }); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.errors).toBeUndefined(); + }); + + it('sets errors when claimUnstakedTrx returns errors', async () => { + mockClaimUnstakedTrx.mockResolvedValue({ + valid: false, + errors: ['Insufficient energy'], + }); + + const { result } = renderHook(() => + useTronClaimUnstakedTrx({ chainId: 'tron:728126428' }), + ); + + await act(async () => { + await result.current.handleClaimUnstakedTrx(); + }); + + expect(result.current.errors).toEqual(['Insufficient energy']); + }); + + it('sets empty errors array when claimUnstakedTrx returns valid:false without errors', async () => { + mockClaimUnstakedTrx.mockResolvedValue({ valid: false }); + + const { result } = renderHook(() => + useTronClaimUnstakedTrx({ chainId: 'tron:728126428' }), + ); + + await act(async () => { + await result.current.handleClaimUnstakedTrx(); + }); + + expect(result.current.errors).toEqual([]); + expect(result.current.isSubmitting).toBe(false); + }); + + it('sets errors when claimUnstakedTrx throws', async () => { + mockClaimUnstakedTrx.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => + useTronClaimUnstakedTrx({ chainId: 'tron:728126428' }), + ); + + await act(async () => { + await result.current.handleClaimUnstakedTrx(); + }); + + expect(result.current.errors).toEqual(['Network error']); + expect(result.current.isSubmitting).toBe(false); + }); + + it('does nothing when no account is selected', async () => { + mockSelectSelectedInternalAccountByScope.mockReturnValue(null); + + const { result } = renderHook(() => + useTronClaimUnstakedTrx({ chainId: 'tron:728126428' }), + ); + + await act(async () => { + await result.current.handleClaimUnstakedTrx(); + }); + + expect(mockClaimUnstakedTrx).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Earn/hooks/useTronClaimUnstakedTrx.ts b/app/components/UI/Earn/hooks/useTronClaimUnstakedTrx.ts new file mode 100644 index 00000000000..e73cb9103f8 --- /dev/null +++ b/app/components/UI/Earn/hooks/useTronClaimUnstakedTrx.ts @@ -0,0 +1,61 @@ +import type { CaipAssetType } from '@metamask/snaps-sdk'; +import type { CaipChainId } from '@metamask/utils'; +import { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; +import Logger from '../../../../util/Logger'; +import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; +import { claimUnstakedTrx } from '../utils/tron-staking-snap'; + +interface UseTronClaimUnstakedTrxParams { + chainId: CaipChainId; +} + +interface UseTronClaimUnstakedTrxReturn { + handleClaimUnstakedTrx: () => Promise; + isSubmitting: boolean; + errors?: string[]; +} + +const useTronClaimUnstakedTrx = ({ + chainId, +}: UseTronClaimUnstakedTrxParams): UseTronClaimUnstakedTrxReturn => { + const selectedTronAccount = useSelector(selectSelectedInternalAccountByScope)( + chainId, + ); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState(undefined); + + const handleClaimUnstakedTrx = useCallback(async () => { + if (!selectedTronAccount?.id || !chainId) return; + + setIsSubmitting(true); + setErrors(undefined); + + try { + const assetId = `${chainId}/slip44:195` as CaipAssetType; + + const result = await claimUnstakedTrx(selectedTronAccount, { + fromAccountId: selectedTronAccount.id, + assetId, + }); + + if (!result?.valid) { + setErrors(result?.errors ?? []); + } + } catch (error) { + Logger.error(error as Error, '[Tron Claim] Failed to claim unstaked TRX'); + setErrors([(error as Error).message]); + } finally { + setIsSubmitting(false); + } + }, [selectedTronAccount, chainId]); + + return { + handleClaimUnstakedTrx, + isSubmitting, + errors, + }; +}; + +export default useTronClaimUnstakedTrx; diff --git a/app/components/UI/Earn/selectors/featureFlags/index.test.ts b/app/components/UI/Earn/selectors/featureFlags/index.test.ts index 0bd392cd5b5..0df35e95e25 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.test.ts @@ -25,7 +25,7 @@ import { VersionGatedFeatureFlag, validatedVersionGatedFeatureFlag, } from '../../../../../util/remoteFeatureFlag'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as remoteFeatureFlagModule from '../../../../../util/remoteFeatureFlag'; jest.mock('react-native-device-info', () => ({ diff --git a/app/components/UI/Earn/types/tron-staking.types.ts b/app/components/UI/Earn/types/tron-staking.types.ts index 47f8181bce5..a1795e71c9d 100644 --- a/app/components/UI/Earn/types/tron-staking.types.ts +++ b/app/components/UI/Earn/types/tron-staking.types.ts @@ -39,6 +39,16 @@ export interface TronUnstakeResult { errors?: string[]; } +export interface TronClaimUnstakedTrxParams { + fromAccountId: string; + assetId: CaipAssetType; +} + +export interface TronClaimUnstakedTrxResult { + valid: boolean; + errors?: string[]; +} + export interface ComputeFeeParams { transaction: string; accountId: string; diff --git a/app/components/UI/Earn/utils/musdConversionTransaction.test.ts b/app/components/UI/Earn/utils/musdConversionTransaction.test.ts index 272f96aa12a..7d208760176 100644 --- a/app/components/UI/Earn/utils/musdConversionTransaction.test.ts +++ b/app/components/UI/Earn/utils/musdConversionTransaction.test.ts @@ -89,7 +89,7 @@ interface MockedEngineContext { >; }; ApprovalController?: { - reject: jest.Mock; + rejectRequest: jest.Mock; }; } @@ -180,7 +180,7 @@ describe('musdConversionTransaction', () => { updatePaymentToken: transactionPayControllerUpdatePaymentToken, }, ApprovalController: { - reject: approvalControllerReject, + rejectRequest: approvalControllerReject, }, }; diff --git a/app/components/UI/Earn/utils/musdConversionTransaction.ts b/app/components/UI/Earn/utils/musdConversionTransaction.ts index 27880df7abe..eb457d09aa3 100644 --- a/app/components/UI/Earn/utils/musdConversionTransaction.ts +++ b/app/components/UI/Earn/utils/musdConversionTransaction.ts @@ -239,7 +239,7 @@ export async function replaceMusdConversionTransactionForPayToken( // This is an automatic rejection (not user-initiated) try { - ApprovalController.reject( + ApprovalController.rejectRequest( transactionMeta.id, providerErrors.userRejectedRequest({ message: diff --git a/app/components/UI/Earn/utils/tron-staking-snap.ts b/app/components/UI/Earn/utils/tron-staking-snap.ts index 17d2ee78a14..75fdda098b7 100644 --- a/app/components/UI/Earn/utils/tron-staking-snap.ts +++ b/app/components/UI/Earn/utils/tron-staking-snap.ts @@ -10,6 +10,8 @@ import type { TronUnstakeValidateParams, TronUnstakeConfirmParams, TronUnstakeResult, + TronClaimUnstakedTrxParams, + TronClaimUnstakedTrxResult, ComputeFeeParams, ComputeFeeResult, ComputeStakeFeeParams, @@ -110,3 +112,18 @@ export async function confirmTronUnstake( }, })) as TronUnstakeResult; } + +export async function claimUnstakedTrx( + fromAccount: InternalAccount, + params: TronClaimUnstakedTrxParams, +): Promise { + return (await handleSnapRequest(controllerMessenger, { + snapId: fromAccount.metadata?.snap?.id as SnapId, + origin: 'metamask', + handler: HandlerType.OnClientRequest, + request: { + method: 'claimUnstakedTrx', + params, + }, + })) as TronClaimUnstakedTrxResult; +} diff --git a/app/components/UI/EditGasFee1559/__snapshots__/index.test.tsx.snap b/app/components/UI/EditGasFee1559/__snapshots__/index.test.tsx.snap deleted file mode 100644 index a3ac625917f..00000000000 --- a/app/components/UI/EditGasFee1559/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,581 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EditGasFee1559 should render correctly 1`] = ` - - - - - - - - - - - - - - - - - - - - - ~ - - - - - Max fee - : - - - ( - ) - - - - - - - - - - Low - , - "name": "low", - "topLabel": false, - }, - { - "label": - Market - , - "name": "medium", - "topLabel": false, - }, - { - "label": - Aggressive - , - "name": "high", - "topLabel": false, - }, - ] - } - /> - - - - - Advanced options - - - - - - - - - - Gas limit - - - - - - - } - min={"21000"} - name="Gas limit" - onChangeValue={[Function]} - /> - - - - - Max priority fee - - - - - - - } - min={"0"} - name="Max priority fee" - onChangeValue={[Function]} - rightLabelComponent={ - - - Estimate - : - - - - GWEI - - } - unit="GWEI" - value="2" - /> - - - - - Max fee - - - - - - - } - min={"0"} - name="Max fee" - onChangeValue={[Function]} - rightLabelComponent={ - - - Estimate - : - - - - GWEI - - } - unit="GWEI" - value="50" - /> - - - - - - - - How should I choose? - - - - Save - - - - - - We have updated the gas fee based on current network conditions and have increased it by at least 10% (required by the network). - - - } - isVisible={false} - title={null} - toggleModal={[Function]} - /> - - - - - - Selecting the right gas fee depends on the type of transaction and how important it is to you. - - - Low - - - Use low to wait for a cheaper price. Time estimates are much less accurate as prices are somewhat unpredictable. - - - Market - - - Use market for fast processing at current market price. - - - Aggressive - - - High probability, even in volatile markets. Use Aggressive to cover surges in network traffic due to things like popular NFT drops. - - - - - - } - isVisible={false} - propagateSwipe={true} - title="How should I choose?" - toggleModal={[Function]} - /> - - - - - -`; diff --git a/app/components/UI/EditGasFee1559/index.js b/app/components/UI/EditGasFee1559/index.js deleted file mode 100644 index 797caeab358..00000000000 --- a/app/components/UI/EditGasFee1559/index.js +++ /dev/null @@ -1,1006 +0,0 @@ -/* eslint-disable react/display-name */ -import React, { useState } from 'react'; -import { - View, - StyleSheet, - TouchableOpacity, - ScrollView, - TouchableWithoutFeedback, -} from 'react-native'; -import Text from '../../Base/Text'; -import StyledButton from '../StyledButton'; -import RangeInput from '../../Base/RangeInput'; -import MaterialCommunityIcon from 'react-native-vector-icons/MaterialCommunityIcons'; -import InfoModal from '../../Base/InfoModal'; -import Icon from 'react-native-vector-icons/Ionicons'; -import { strings } from '../../../../locales/i18n'; -import Alert, { AlertType } from '../../Base/Alert'; -import HorizontalSelector from '../../Base/HorizontalSelector'; -import Device from '../../../util/device'; -import { getDecimalChainId, isMainnetByChainId } from '../../../util/networks'; -import PropTypes from 'prop-types'; -import BigNumber from 'bignumber.js'; -import FadeAnimationView from '../FadeAnimationView'; -import { MetaMetricsEvents } from '../../../core/Analytics'; - -import TimeEstimateInfoModal from '../TimeEstimateInfoModal'; -import useModalHandler from '../../Base/hooks/useModalHandler'; -import AppConstants from '../../../core/AppConstants'; -import { useTheme } from '../../../util/theme'; -import { - GAS_LIMIT_INCREMENT, - GAS_PRICE_INCREMENT as GAS_INCREMENT, - GAS_LIMIT_MIN, - GAS_PRICE_MIN as GAS_MIN, -} from '../../../util/gasUtils'; -import { useMetrics } from '../../../components/hooks/useMetrics'; - -const createStyles = (colors) => - StyleSheet.create({ - root: { - backgroundColor: colors.background.default, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - minHeight: 200, - maxHeight: '95%', - paddingTop: 24, - paddingBottom: Device.isIphoneX() ? 32 : 24, - }, - wrapper: { - paddingHorizontal: 24, - }, - customGasHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - width: '100%', - paddingBottom: 20, - }, - newGasFeeHeader: { - flexDirection: 'row', - alignItems: 'center', - width: '100%', - justifyContent: 'center', - }, - headerContainer: { - alignItems: 'center', - marginBottom: 22, - }, - headerText: { - fontSize: 48, - flex: 1, - textAlign: 'center', - }, - headerTitle: { - flexDirection: 'row', - }, - saveButton: { - marginBottom: 20, - }, - labelTextContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - hitSlop: { - top: 10, - left: 10, - bottom: 10, - right: 10, - }, - labelInfo: { - color: colors.text.muted, - }, - advancedOptionsContainer: { - marginTop: 25, - marginBottom: 30, - }, - advancedOptionsInputsContainer: { - marginTop: 14, - }, - rangeInputContainer: { - marginBottom: 20, - }, - advancedOptionsButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - advancedOptionsIcon: { - paddingTop: 1, - marginLeft: 5, - }, - learnMoreLabels: { - marginTop: 9, - }, - /* Add when the learn more link is ready - learnMoreLink: { - marginTop: 14 - },*/ - warningTextContainer: { - lineHeight: 20, - paddingLeft: 4, - flex: 1, - }, - warningText: { - lineHeight: 20, - flex: 1, - color: colors.text.default, - }, - warningContainer: { - marginBottom: 20, - }, - dappEditGasContainer: { - marginVertical: 20, - }, - subheader: { - marginBottom: 6, - }, - learnMoreModal: { - maxHeight: Device.getDeviceHeight() * 0.7, - }, - redInfo: { - marginLeft: 2, - color: colors.error.default, - }, - }); - -/** - * The EditGasFee1559 component will be deprecated in favor of EditGasFee1559Update as part of the gas polling refactor code that moves gas fee modifications to `app/core/GasPolling`. When the refactoring is completed, the EditGasFee1559Update will be renamed EditGasFee1559 and this component will be removed. The EditGasFee1559Update is currently being used in the Update Transaction(Speed Up/Cancel) flow. - */ - -const EditGasFee1559 = ({ - selected, - gasFee, - gasOptions, - onChange, - onCancel, - onSave, - gasFeeNative, - gasFeeConversion, - gasFeeMaxNative, - gasFeeMaxConversion, - maxPriorityFeeNative, - maxPriorityFeeConversion, - maxFeePerGasNative, - maxFeePerGasConversion, - primaryCurrency, - chainId, - timeEstimate, - timeEstimateColor, - timeEstimateId, - error, - warning, - dappSuggestedGas, - ignoreOptions, - updateOption, - extendOptions = {}, - recommended, - warningMinimumEstimateOption, - suggestedEstimateOption, - animateOnChange, - isAnimating, - onUpdatingValuesStart, - onUpdatingValuesEnd, - analyticsParams, - view, -}) => { - const [showInfoModal, setShowInfoModal] = useState(false); - const [showAdvancedOptions, setShowAdvancedOptions] = useState(!selected); - const [maxPriorityFeeError, setMaxPriorityFeeError] = useState(null); - const [maxFeeError, setMaxFeeError] = useState(null); - const [showLearnMoreModal, setShowLearnMoreModal] = useState(false); - const [selectedOption, setSelectedOption] = useState(selected); - const [showInputs, setShowInputs] = useState(!dappSuggestedGas); - const [ - isVisibleTimeEstimateInfoModal, - , - showTimeEstimateInfoModal, - hideTimeEstimateInfoModal, - ] = useModalHandler(false); - const { colors } = useTheme(); - const { trackEvent, createEventBuilder } = useMetrics(); - - const styles = createStyles(colors); - - const getAnalyticsParams = () => { - try { - return { - ...analyticsParams, - chain_id: getDecimalChainId(chainId), - function_type: view, - gas_mode: selectedOption ? 'Basic' : 'Advanced', - speed_set: selectedOption || undefined, - }; - } catch (error) { - return {}; - } - }; - - const toggleAdvancedOptions = () => { - if (!showAdvancedOptions) { - trackEvent( - createEventBuilder(MetaMetricsEvents.GAS_ADVANCED_OPTIONS_CLICKED) - .addProperties(getAnalyticsParams()) - .build(), - ); - } - setShowAdvancedOptions((showAdvancedOptions) => !showAdvancedOptions); - }; - - const toggleLearnMoreModal = () => { - setShowLearnMoreModal((showLearnMoreModal) => !showLearnMoreModal); - }; - - const save = () => { - trackEvent( - createEventBuilder(MetaMetricsEvents.GAS_FEE_CHANGED) - .addProperties(getAnalyticsParams()) - .build(), - ); - - onSave(selectedOption); - }; - - const changeGas = (gas, selectedOption) => { - setSelectedOption(selectedOption); - onChange(gas, selectedOption); - }; - - const changedMaxPriorityFee = (value) => { - const lowerValue = new BigNumber( - gasOptions?.[warningMinimumEstimateOption]?.suggestedMaxPriorityFeePerGas, - ); - const higherValue = new BigNumber( - gasOptions?.high?.suggestedMaxPriorityFeePerGas, - ).multipliedBy(new BigNumber(1.5)); - const updateFloor = new BigNumber(updateOption?.maxPriortyFeeThreshold); - - const valueBN = new BigNumber(value); - - if (updateFloor && !updateFloor.isNaN() && valueBN.lt(updateFloor)) { - setMaxPriorityFeeError( - updateOption?.isCancel - ? strings('edit_gas_fee_eip1559.max_priority_fee_cancel_low', { - cancel_value: updateFloor, - }) - : strings('edit_gas_fee_eip1559.max_priority_fee_speed_up_low', { - speed_up_floor_value: updateFloor, - }), - ); - } else if (!lowerValue.isNaN() && valueBN.lt(lowerValue)) { - setMaxPriorityFeeError( - strings('edit_gas_fee_eip1559.max_priority_fee_low'), - ); - } else if (!higherValue.isNaN() && valueBN.gt(higherValue)) { - setMaxPriorityFeeError( - strings('edit_gas_fee_eip1559.max_priority_fee_high'), - ); - } else { - setMaxPriorityFeeError(''); - } - - const newGas = { ...gasFee, suggestedMaxPriorityFeePerGas: value }; - - changeGas(newGas, null); - }; - - const changedMaxFeePerGas = (value) => { - const lowerValue = new BigNumber( - gasOptions?.[warningMinimumEstimateOption]?.suggestedMaxFeePerGas, - ); - const higherValue = new BigNumber( - gasOptions?.high?.suggestedMaxFeePerGas, - ).multipliedBy(new BigNumber(1.5)); - const updateFloor = new BigNumber(updateOption?.maxFeeThreshold); - - const valueBN = new BigNumber(value); - - if (updateFloor && !updateFloor.isNaN() && valueBN.lt(updateFloor)) { - setMaxFeeError( - updateOption?.isCancel - ? strings('edit_gas_fee_eip1559.max_fee_cancel_low', { - cancel_value: updateFloor, - }) - : strings('edit_gas_fee_eip1559.max_fee_speed_up_low', { - speed_up_floor_value: updateFloor, - }), - ); - } else if (!lowerValue.isNaN() && valueBN.lt(lowerValue)) { - setMaxFeeError(strings('edit_gas_fee_eip1559.max_fee_low')); - } else if (!higherValue.isNaN() && valueBN.gt(higherValue)) { - setMaxFeeError(strings('edit_gas_fee_eip1559.max_fee_high')); - } else { - setMaxFeeError(''); - } - - const newGas = { ...gasFee, suggestedMaxFeePerGas: value }; - changeGas(newGas, null); - }; - - const changedGasLimit = (value) => { - const newGas = { ...gasFee, suggestedGasLimit: value }; - changeGas(newGas, null); - }; - - const selectOption = (option) => { - setSelectedOption(option); - setMaxFeeError(''); - setMaxPriorityFeeError(''); - changeGas({ ...gasOptions[option] }, option); - }; - - const shouldIgnore = (option) => - ignoreOptions.find((item) => item === option); - - const renderLabel = (selected, disabled, label) => ( - - {label} - - ); - - const renderOptions = () => - [ - { - name: AppConstants.GAS_OPTIONS.LOW, - label: strings('edit_gas_fee_eip1559.low'), - }, - { - name: AppConstants.GAS_OPTIONS.MEDIUM, - label: strings('edit_gas_fee_eip1559.market'), - }, - { - name: AppConstants.GAS_OPTIONS.HIGH, - label: strings('edit_gas_fee_eip1559.aggressive'), - }, - ] - .filter(({ name }) => !shouldIgnore(name)) - .map(({ name, label, ...option }) => ({ - name, - label: renderLabel(selectedOption === name, false, label), - topLabel: recommended?.name === name && recommended.render, - ...option, - ...extendOptions[name], - })); - - const isMainnet = isMainnetByChainId(chainId); - const nativeCurrencySelected = primaryCurrency === 'ETH' || !isMainnet; - let gasFeePrimary, - gasFeeMaxPrimary, - maxFeePerGasPrimary, - maxPriorityFeePerGasPrimary, - gasFeeMaxSecondary; - if (nativeCurrencySelected) { - gasFeePrimary = gasFeeNative; - gasFeeMaxPrimary = gasFeeMaxNative; - gasFeeMaxSecondary = gasFeeMaxConversion; - maxFeePerGasPrimary = maxFeePerGasNative; - maxPriorityFeePerGasPrimary = maxPriorityFeeNative; - } else { - gasFeePrimary = gasFeeConversion; - gasFeeMaxPrimary = gasFeeMaxConversion; - gasFeeMaxSecondary = gasFeeMaxNative; - maxFeePerGasPrimary = maxFeePerGasConversion; - maxPriorityFeePerGasPrimary = maxPriorityFeeConversion; - } - - const valueToWatch = `${gasFeeNative}${gasFeeMaxNative}`; - - const renderInputs = () => ( - - - - {/* TODO(eip1559) hook with strings i18n */} - - - - - - {strings('edit_gas_fee_eip1559.advanced_options')} - - - - - - {(showAdvancedOptions || updateOption?.showAdvanced) && ( - - - - - {strings('edit_gas_fee_eip1559.gas_limit')}{' '} - - - setShowInfoModal('gas_limit')} - > - - - - } - min={GAS_LIMIT_MIN} - value={gasFee.suggestedGasLimit} - onChangeValue={changedGasLimit} - name={strings('edit_gas_fee_eip1559.gas_limit')} - increment={GAS_LIMIT_INCREMENT} - /> - - - - - {strings('edit_gas_fee_eip1559.max_priority_fee')}{' '} - - - setShowInfoModal('max_priority_fee')} - > - - - - } - rightLabelComponent={ - - - {strings('edit_gas_fee_eip1559.estimate')}: - {' '} - { - gasOptions?.[suggestedEstimateOption] - ?.suggestedMaxPriorityFeePerGas - }{' '} - GWEI - - } - value={gasFee.suggestedMaxPriorityFeePerGas} - name={strings('edit_gas_fee_eip1559.max_priority_fee')} - unit={'GWEI'} - min={GAS_MIN} - increment={GAS_INCREMENT} - inputInsideLabel={ - maxPriorityFeePerGasPrimary && - `≈ ${maxPriorityFeePerGasPrimary}` - } - error={maxPriorityFeeError} - onChangeValue={changedMaxPriorityFee} - /> - - - - - {strings('edit_gas_fee_eip1559.max_fee')}{' '} - - - setShowInfoModal('max_fee')} - > - - - - } - rightLabelComponent={ - - - {strings('edit_gas_fee_eip1559.estimate')}: - {' '} - { - gasOptions?.[suggestedEstimateOption] - ?.suggestedMaxFeePerGas - }{' '} - GWEI - - } - value={gasFee.suggestedMaxFeePerGas} - name={strings('edit_gas_fee_eip1559.max_fee')} - unit={'GWEI'} - min={GAS_MIN} - increment={GAS_INCREMENT} - error={maxFeeError} - onChangeValue={changedMaxFeePerGas} - inputInsideLabel={ - maxFeePerGasPrimary && `≈ ${maxFeePerGasPrimary}` - } - /> - - - )} - - - - - - {strings('edit_gas_fee_eip1559.learn_more.title')} - - - - {updateOption - ? strings('edit_gas_fee_eip1559.submit') - : strings('edit_gas_fee_eip1559.save')} - - - - ); - - const renderWarning = () => { - if (!warning) return null; - if (typeof warning === 'string') - return ( - ( - - )} - style={styles.warningContainer} - > - {() => ( - - - {warning} - - - )} - - ); - - return warning; - }; - - const renderError = () => { - if (!error) return null; - if (typeof error === 'string') - return ( - ( - - )} - style={styles.warningContainer} - > - {() => ( - - - {error} - - - )} - - ); - - return error; - }; - - const renderDisplayTitle = () => { - if (updateOption) - return updateOption.isCancel - ? strings('edit_gas_fee_eip1559.cancel_transaction') - : strings('edit_gas_fee_eip1559.speed_up_transaction'); - return strings('edit_gas_fee_eip1559.edit_priority'); - }; - - return ( - - - - - - - - - - - {renderDisplayTitle} - - - - {updateOption && ( - - - {strings('edit_gas_fee_eip1559.new_gas_fee')}{' '} - - - setShowInfoModal('new_gas_fee')} - > - - - - )} - - {renderWarning} - {renderError} - - - - ~{gasFeePrimary} - - - - - {strings('edit_gas_fee_eip1559.max_fee')}:{' '} - - {gasFeeMaxPrimary} ({gasFeeMaxSecondary}) - - - - {timeEstimate} - - {(timeEstimateId === AppConstants.GAS_TIMES.MAYBE || - timeEstimateId === AppConstants.GAS_TIMES.UNKNOWN) && ( - - - - )} - - - {!showInputs ? ( - - setShowInputs(true)} - > - {strings('edit_gas_fee_eip1559.edit_suggested_gas_fee')} - - - ) : ( - renderInputs() - )} - setShowInfoModal(null)} - body={ - - - {showInfoModal === 'gas_limit' && - strings('edit_gas_fee_eip1559.learn_more_gas_limit')} - {showInfoModal === 'max_priority_fee' && - strings( - 'edit_gas_fee_eip1559.learn_more_max_priority_fee', - )} - {showInfoModal === 'max_fee' && - strings('edit_gas_fee_eip1559.learn_more_max_fee')} - {showInfoModal === 'new_gas_fee' && - updateOption && - updateOption.isCancel - ? strings( - 'edit_gas_fee_eip1559.learn_more_cancel_gas_fee', - ) - : strings('edit_gas_fee_eip1559.learn_more_new_gas_fee')} - - - } - /> - - - - - - {strings('edit_gas_fee_eip1559.learn_more.intro')} - - - {strings('edit_gas_fee_eip1559.learn_more.low_label')} - - - {strings('edit_gas_fee_eip1559.learn_more.low_text')} - - - {strings( - 'edit_gas_fee_eip1559.learn_more.market_label', - )} - - - {strings( - 'edit_gas_fee_eip1559.learn_more.market_text', - )} - - - {strings( - 'edit_gas_fee_eip1559.learn_more.aggressive_label', - )} - - - {strings( - 'edit_gas_fee_eip1559.learn_more.aggressive_text', - )} - - {/* TODO(eip1559) add link when available - - - {strings('edit_gas_fee_eip1559.learn_more.link')} - - */} - - - - - } - /> - - - - - - ); -}; - -EditGasFee1559.defaultProps = { - ignoreOptions: [], - warningMinimumEstimateOption: AppConstants.GAS_OPTIONS.LOW, - suggestedEstimateOption: AppConstants.GAS_OPTIONS.MEDIUM, -}; - -EditGasFee1559.propTypes = { - /** - * Gas option selected (low, medium, high) - */ - selected: PropTypes.string, - /** - * Gas fee currently active - */ - gasFee: PropTypes.object, - /** - * Gas fee options to select from - */ - gasOptions: PropTypes.object, - /** - * Function called when user selected or changed the gas - */ - onChange: PropTypes.func, - /** - * Function called when user cancels - */ - onCancel: PropTypes.func, - /** - * Function called when user saves the new gas - */ - onSave: PropTypes.func, - /** - * Gas fee in native currency - */ - gasFeeNative: PropTypes.string, - /** - * Gas fee converted to chosen currency - */ - gasFeeConversion: PropTypes.string, - /** - * Maximum gas fee in native currency - */ - gasFeeMaxNative: PropTypes.string, - /** - * Maximum gas fee converted to chosen currency - */ - gasFeeMaxConversion: PropTypes.string, - /** - * Maximum priority gas fee in native currency - */ - maxPriorityFeeNative: PropTypes.string, - /** - * Maximum priority gas fee converted to chosen currency - */ - maxPriorityFeeConversion: PropTypes.string, - /** - * Maximum fee per gas fee in native currency - */ - maxFeePerGasNative: PropTypes.string, - /** - * Maximum fee per gas fee converted to chosen currency - */ - maxFeePerGasConversion: PropTypes.string, - /** - * Primary currency, either ETH or Fiat - */ - primaryCurrency: PropTypes.string, - /** - * A string representing the network chainId - */ - chainId: PropTypes.string, - /** - * String that represents the time estimates - */ - timeEstimate: PropTypes.string, - /** - * String that represents the color of the time estimate - */ - timeEstimateColor: PropTypes.string, - /** - * Time estimate name (unknown, low, medium, high, less_than, range) - */ - timeEstimateId: PropTypes.string, - /** - * Error message to show - */ - error: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.bool, - PropTypes.node, - ]), - /** - * Warning message to show - */ - warning: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.bool, - PropTypes.node, - ]), - /** - * Boolean that specifies if the gas price was suggested by the dapp - */ - dappSuggestedGas: PropTypes.bool, - /** - * Ignore option array - */ - ignoreOptions: PropTypes.array, - /** - * Option to display speed up/cancel view - */ - updateOption: PropTypes.object, - /** - * Extend options object. Object has option keys and properties will be spread - */ - extendOptions: PropTypes.object, - /** - * Recommended object with type and render function - */ - recommended: PropTypes.object, - /** - * Estimate option to compare with for too low warning - */ - warningMinimumEstimateOption: PropTypes.string, - /** - * Suggested estimate option to show recommended values - */ - suggestedEstimateOption: PropTypes.string, - /** - * Function to call when update animation starts - */ - onUpdatingValuesStart: PropTypes.func, - /** - * Function to call when update animation ends - */ - onUpdatingValuesEnd: PropTypes.func, - /** - * If the values should animate upon update or not - */ - animateOnChange: PropTypes.bool, - /** - * Boolean to determine if the animation is happening - */ - isAnimating: PropTypes.bool, - /** - * Extra analytics params to be send with the gas analytics - */ - analyticsParams: PropTypes.object, - /** - * (For analytics purposes) View (Approve, Transfer, Confirm) where this component is being used - */ - view: PropTypes.string.isRequired, -}; - -export default EditGasFee1559; diff --git a/app/components/UI/EditGasFee1559/index.test.tsx b/app/components/UI/EditGasFee1559/index.test.tsx deleted file mode 100644 index 155e2825e8b..00000000000 --- a/app/components/UI/EditGasFee1559/index.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { shallow } from 'enzyme'; -import React from 'react'; - -import EditGasFee1559 from './'; - -describe('EditGasFee1559', () => { - it('should render correctly', () => { - const wrapper = shallow( - , - ); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/app/components/UI/EditGasFeeLegacy/__snapshots__/index.test.tsx.snap b/app/components/UI/EditGasFeeLegacy/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 883a9d010a3..00000000000 --- a/app/components/UI/EditGasFeeLegacy/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,289 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EditGasFeeLegacy should render correctly 1`] = ` - - - - - - - - - - - Edit gas fee - - - - - - - - - - - - ~ - - - - - - - - - - - - - - - Gas limit - - - - - - - } - min={"21000"} - name="Gas limit" - onChangeValue={[Function]} - value="21000" - /> - - - - - Gas price - - - - - - - } - min={"0"} - name="Gas price" - onChangeValue={[Function]} - unit="GWEI" - value="10" - /> - - - - - - - Save - - - - - - } - isVisible={false} - title={null} - toggleModal={[Function]} - /> - - - - -`; diff --git a/app/components/UI/EditGasFeeLegacy/index.js b/app/components/UI/EditGasFeeLegacy/index.js deleted file mode 100644 index f4920d400b4..00000000000 --- a/app/components/UI/EditGasFeeLegacy/index.js +++ /dev/null @@ -1,624 +0,0 @@ -/* eslint-disable react/display-name */ -import React, { useState } from 'react'; -import { - View, - StyleSheet, - TouchableOpacity, - ScrollView, - TouchableWithoutFeedback, -} from 'react-native'; -import PropTypes from 'prop-types'; -import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; -import BigNumber from 'bignumber.js'; -import Text from '../../Base/Text'; -import StyledButton from '../StyledButton'; -import RangeInput from '../../Base/RangeInput'; -import MaterialCommunityIcon from 'react-native-vector-icons/MaterialCommunityIcons'; -import InfoModal from '../../Base/InfoModal'; -import Icon from 'react-native-vector-icons/Ionicons'; -import { strings } from '../../../../locales/i18n'; -import Alert, { AlertType } from '../../Base/Alert'; -import HorizontalSelector from '../../Base/HorizontalSelector'; -import Device from '../../../util/device'; -import { getDecimalChainId, isMainnetByChainId } from '../../../util/networks'; -import FadeAnimationView from '../FadeAnimationView'; -import { MetaMetricsEvents } from '../../../core/Analytics'; - -import AppConstants from '../../../core/AppConstants'; -import { useTheme } from '../../../util/theme'; -import { - GAS_LIMIT_INCREMENT, - GAS_PRICE_INCREMENT, - GAS_LIMIT_MIN, - GAS_PRICE_MIN, -} from '../../../util/gasUtils'; -import { useMetrics } from '../../../components/hooks/useMetrics'; - -const createStyles = (colors) => - StyleSheet.create({ - root: { - backgroundColor: colors.background.default, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - minHeight: 200, - maxHeight: '95%', - paddingTop: 24, - paddingBottom: Device.isIphoneX() ? 32 : 24, - }, - wrapper: { - paddingHorizontal: 24, - }, - customGasHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - width: '100%', - paddingBottom: 20, - }, - headerContainer: { - alignItems: 'center', - marginBottom: 22, - }, - headerText: { - fontSize: 48, - }, - headerTitle: { - flexDirection: 'row', - }, - headerTitleSide: { - flex: 1, - }, - labelTextContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - hitSlop: { - top: 10, - left: 10, - bottom: 10, - right: 10, - }, - labelInfo: { - color: colors.text.muted, - }, - advancedOptionsContainer: { - marginTop: 25, - marginBottom: 30, - }, - advancedOptionsInputsContainer: { - marginTop: 14, - }, - rangeInputContainer: { - marginBottom: 20, - }, - advancedOptionsButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - advancedOptionsIcon: { - paddingTop: 1, - marginLeft: 5, - }, - warningTextContainer: { - paddingLeft: 4, - lineHeight: 20, - textAlign: 'center', - }, - warningText: { - lineHeight: 20, - color: colors.text.default, - }, - }); - -/** - * The EditGasFeeLegacy component will be deprecated in favor of EditGasFeeLegacyUpdate as part of the gas polling refactor code that moves gas fee modifications to `app/core/GasPolling`. When the refactoring is completed, the EditGasFeeLegacyUpdate will be renamed EditGasFeeLegacy and this component will be removed. The EditGasFeeLegacyUpdate is currently being used in the Update Transaction(Speed Up/Cancel) flow. - */ - -const EditGasFeeLegacy = ({ - selected, - gasFee, - gasOptions, - onChange, - onCancel, - onSave, - gasFeeNative, - gasFeeConversion, - primaryCurrency, - chainId, - gasEstimateType, - error, - warning, - ignoreOptions, - extendOptions = {}, - recommended, - warningMinimumEstimateOption, - onUpdatingValuesStart, - onUpdatingValuesEnd, - animateOnChange, - isAnimating, - analyticsParams, - view, -}) => { - const onlyAdvanced = gasEstimateType !== GAS_ESTIMATE_TYPES.LEGACY; - const [showRangeInfoModal, setShowRangeInfoModal] = useState(false); - const [showAdvancedOptions, setShowAdvancedOptions] = useState( - !selected || onlyAdvanced, - ); - const [selectedOption, setSelectedOption] = useState(selected); - const [gasPriceError, setGasPriceError] = useState(); - const { colors } = useTheme(); - const { trackEvent, createEventBuilder } = useMetrics(); - const styles = createStyles(colors); - - const getAnalyticsParams = () => { - try { - return { - ...analyticsParams, - chain_id: getDecimalChainId(chainId), - function_type: view, - gas_mode: selectedOption ? 'Basic' : 'Advanced', - speed_set: selectedOption || undefined, - }; - } catch (error) { - return {}; - } - }; - - const toggleAdvancedOptions = () => { - if (!showAdvancedOptions) { - trackEvent( - createEventBuilder(MetaMetricsEvents.GAS_ADVANCED_OPTIONS_CLICKED) - .addProperties(getAnalyticsParams()) - .build(), - ); - } - setShowAdvancedOptions((showAdvancedOptions) => !showAdvancedOptions); - }; - - const save = () => { - trackEvent( - createEventBuilder(MetaMetricsEvents.GAS_FEE_CHANGED) - .addProperties(getAnalyticsParams()) - .build(), - ); - - onSave(selectedOption); - }; - - const changeGas = (gas, selectedOption) => { - setSelectedOption(selectedOption); - onChange(gas, selectedOption); - }; - - const changedGasPrice = (value) => { - const lowerValue = new BigNumber( - gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY - ? gasOptions?.[warningMinimumEstimateOption] - : gasOptions?.gasPrice, - ); - const higherValue = new BigNumber( - gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY - ? gasOptions?.high - : gasOptions?.gasPrice, - ).multipliedBy(new BigNumber(1.5)); - - const valueBN = new BigNumber(value); - - if (!lowerValue.isNaN() && valueBN.lt(lowerValue)) { - setGasPriceError(strings('edit_gas_fee_eip1559.gas_price_low')); - } else if (!higherValue.isNaN() && valueBN.gt(higherValue)) { - setGasPriceError(strings('edit_gas_fee_eip1559.gas_price_high')); - } else { - setGasPriceError(''); - } - - const newGas = { ...gasFee, suggestedGasPrice: value }; - - changeGas(newGas, null); - }; - - const changedGasLimit = (value) => { - const newGas = { ...gasFee, suggestedGasLimit: value }; - - changeGas(newGas, null); - }; - - const selectOption = (option) => { - setGasPriceError(''); - setSelectedOption(option); - changeGas({ ...gasFee, suggestedGasPrice: gasOptions[option] }, option); - }; - - const shouldIgnore = (option) => - ignoreOptions.find((item) => item === option); - - const renderLabel = (selected, disabled, label) => ( - - {label} - - ); - - const renderOptions = () => - [ - { - name: AppConstants.GAS_OPTIONS.LOW, - label: strings('edit_gas_fee_eip1559.low'), - }, - { - name: AppConstants.GAS_OPTIONS.MEDIUM, - label: strings('edit_gas_fee_eip1559.medium'), - }, - { - name: AppConstants.GAS_OPTIONS.HIGH, - label: strings('edit_gas_fee_eip1559.high'), - }, - ] - .filter(({ name }) => !shouldIgnore(name)) - .map(({ name, label, ...option }) => ({ - name, - label: renderLabel(selectedOption === name, false, label), - topLabel: recommended?.name === name && recommended.render, - ...option, - ...extendOptions[name], - })); - - const renderWarning = () => { - if (!warning) return null; - if (typeof warning === 'string') - return ( - ( - - )} - style={styles.warningContainer} - > - {() => ( - - - {warning} - - - )} - - ); - - return warning; - }; - - const renderError = () => { - if (!error) return null; - if (typeof error === 'string') - return ( - ( - - )} - style={styles.warningContainer} - > - {() => ( - - - {error} - - - )} - - ); - - return error; - }; - - const isMainnet = isMainnetByChainId(chainId); - const nativeCurrencySelected = primaryCurrency === 'ETH' || !isMainnet; - let gasFeePrimary, gasFeeSecondary; - if (nativeCurrencySelected) { - gasFeePrimary = gasFeeNative; - gasFeeSecondary = gasFeeConversion; - } else { - gasFeePrimary = gasFeeConversion; - gasFeeSecondary = gasFeeNative; - } - - const valueToWatch = gasFeeNative; - - return ( - - - - - - - - - - - {strings('transaction.edit_network_fee')} - - - - - {renderWarning} - {renderError} - - - - - - ~ - - - - {gasFeePrimary} - - - - - - {gasFeeSecondary} - - - - {!onlyAdvanced && ( - - - - )} - - {!onlyAdvanced && ( - - - {strings('edit_gas_fee_eip1559.advanced_options')} - - - - - - )} - {showAdvancedOptions && ( - - - - - {strings('edit_gas_fee_eip1559.gas_limit')}{' '} - - - setShowRangeInfoModal('gas_limit')} - > - - - - } - value={gasFee.suggestedGasLimit} - onChangeValue={changedGasLimit} - min={GAS_LIMIT_MIN} - name={strings('edit_gas_fee_eip1559.gas_limit')} - increment={GAS_LIMIT_INCREMENT} - /> - - - - - {strings('edit_gas_fee_eip1559.gas_price')}{' '} - - - setShowRangeInfoModal('gas_price')} - > - - - - } - value={gasFee.suggestedGasPrice} - name={strings('edit_gas_fee_eip1559.gas_price')} - unit={'GWEI'} - increment={GAS_PRICE_INCREMENT} - min={GAS_PRICE_MIN} - inputInsideLabel={ - gasFeeConversion && `≈ ${gasFeeConversion}` - } - onChangeValue={changedGasPrice} - error={gasPriceError} - /> - - - )} - - - - - {strings('edit_gas_fee_eip1559.save')} - - - setShowRangeInfoModal(null)} - body={ - - - {showRangeInfoModal === 'gas_limit' && - strings( - 'edit_gas_fee_eip1559.learn_more_gas_limit_legacy', - )} - {showRangeInfoModal === 'gas_price' && - strings('edit_gas_fee_eip1559.learn_more_gas_price')} - - - } - /> - - - - - ); -}; - -EditGasFeeLegacy.defaultProps = { - ignoreOptions: [], - warningMinimumEstimateOption: AppConstants.GAS_OPTIONS.LOW, -}; - -EditGasFeeLegacy.propTypes = { - /** - * Gas option selected (low, medium, high) - */ - selected: PropTypes.string, - /** - * Gas fee currently active - */ - gasFee: PropTypes.object, - /** - * Gas fee options to select from - */ - gasOptions: PropTypes.object, - /** - * Function called when user selected or changed the gas - */ - onChange: PropTypes.func, - /** - * Function called when user cancels - */ - onCancel: PropTypes.func, - /** - * Function called when user saves the new gas - */ - onSave: PropTypes.func, - /** - * Gas fee in native currency - */ - gasFeeNative: PropTypes.string, - /** - * Gas fee converted to chosen currency - */ - gasFeeConversion: PropTypes.string, - /** - * Primary currency, either ETH or Fiat - */ - primaryCurrency: PropTypes.string, - /** - * A string representing the network chainId - */ - chainId: PropTypes.string, - /** - * Estimate type returned by the gas fee controller, can be market-fee, legacy or eth_gasPrice - */ - gasEstimateType: PropTypes.string, - /** - * Error message to show - */ - error: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.bool, - PropTypes.node, - ]), - /** - * Warning message to show - */ - warning: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.bool, - PropTypes.node, - ]), - /** - * Ignore option array - */ - ignoreOptions: PropTypes.array, - /** - * Extend options object. Object has option keys and properties will be spread - */ - extendOptions: PropTypes.object, - /** - * Recommended object with type and render function - */ - recommended: PropTypes.object, - /** - * Estimate option to compare with for too low warning - */ - warningMinimumEstimateOption: PropTypes.string, - /** - * Function to call when update animation starts - */ - onUpdatingValuesStart: PropTypes.func, - /** - * Function to call when update animation ends - */ - onUpdatingValuesEnd: PropTypes.func, - /** - * If the values should animate upon update or not - */ - animateOnChange: PropTypes.bool, - /** - * Boolean to determine if the animation is happening - */ - isAnimating: PropTypes.bool, - /** - * Extra analytics params to be send with the gas analytics - */ - analyticsParams: PropTypes.object, - /** - * (For analytics purposes) View (Approve, Transfer, Confirm) where this component is being used - */ - view: PropTypes.string.isRequired, -}; - -export default EditGasFeeLegacy; diff --git a/app/components/UI/EditGasFeeLegacy/index.test.tsx b/app/components/UI/EditGasFeeLegacy/index.test.tsx deleted file mode 100644 index 6977b8db548..00000000000 --- a/app/components/UI/EditGasFeeLegacy/index.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { shallow } from 'enzyme'; -import React from 'react'; - -import EditGasFeeLegacy from './'; - -describe('EditGasFeeLegacy', () => { - it('should render correctly', () => { - const wrapper = shallow( - , - ); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/app/components/UI/FoxScreen/index.js b/app/components/UI/FoxScreen/index.js index 7975b87c19e..25066e4c19f 100644 --- a/app/components/UI/FoxScreen/index.js +++ b/app/components/UI/FoxScreen/index.js @@ -22,7 +22,7 @@ const createStyles = (colors) => }, }); -const foxImage = require('../../../images/branding/fox.png'); // eslint-disable-line import/no-commonjs +const foxImage = require('../../../images/branding/fox.png'); // eslint-disable-line import-x/no-commonjs /** * View component that displays the MetaMask fox diff --git a/app/components/UI/HardwareWallet/AccountDetails/styles.tsx b/app/components/UI/HardwareWallet/AccountDetails/styles.tsx index 6dbcde9501b..ea00d7735e4 100644 --- a/app/components/UI/HardwareWallet/AccountDetails/styles.tsx +++ b/app/components/UI/HardwareWallet/AccountDetails/styles.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; import { fontStyles } from '../../../../styles/common'; import Device from '../../../../util/device'; diff --git a/app/components/UI/HardwareWallet/AccountSelector/styles.tsx b/app/components/UI/HardwareWallet/AccountSelector/styles.tsx index 556c29aab75..7dba84661fb 100644 --- a/app/components/UI/HardwareWallet/AccountSelector/styles.tsx +++ b/app/components/UI/HardwareWallet/AccountSelector/styles.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; import { fontStyles } from '../../../../styles/common'; import Device from '../../../../util/device'; diff --git a/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/__snapshots__/ConfirmTurnOnBackupAndSyncModal.test.tsx.snap b/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/__snapshots__/ConfirmTurnOnBackupAndSyncModal.test.tsx.snap index e0e2b0f56d2..9d3b45e09d9 100644 --- a/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/__snapshots__/ConfirmTurnOnBackupAndSyncModal.test.tsx.snap +++ b/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/__snapshots__/ConfirmTurnOnBackupAndSyncModal.test.tsx.snap @@ -189,45 +189,95 @@ exports[`ConfirmTurnOnBackupAndSyncModal renders correctly 1`] = ` } } > - Cancel - + - Turn on - + diff --git a/app/components/UI/LedgerModals/LedgerTransactionModal.tsx b/app/components/UI/LedgerModals/LedgerTransactionModal.tsx index 5ca95213815..84c07bb212d 100644 --- a/app/components/UI/LedgerModals/LedgerTransactionModal.tsx +++ b/app/components/UI/LedgerModals/LedgerTransactionModal.tsx @@ -64,7 +64,7 @@ const LedgerTransactionModal = () => { await TransactionController.stopTransaction(transactionId, gasFeeParams); } else { // This requires the user to confirm on the ledger device - await ApprovalController.accept(transactionId, undefined, { + await ApprovalController.acceptRequest(transactionId, undefined, { waitForResult: true, }); } diff --git a/app/components/UI/LedgerModals/Steps/ConfirmationStep.tsx b/app/components/UI/LedgerModals/Steps/ConfirmationStep.tsx index 10aa46ddb4f..e50e3f74fad 100644 --- a/app/components/UI/LedgerModals/Steps/ConfirmationStep.tsx +++ b/app/components/UI/LedgerModals/Steps/ConfirmationStep.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-require-imports */ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ /* eslint-disable @typescript-eslint/no-var-requires */ import React, { useMemo } from 'react'; import { ActivityIndicator, Image, StyleSheet, View } from 'react-native'; diff --git a/app/components/UI/LedgerModals/styles.ts b/app/components/UI/LedgerModals/styles.ts index 6de560c41a7..a3a8ad31956 100644 --- a/app/components/UI/LedgerModals/styles.ts +++ b/app/components/UI/LedgerModals/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; import { Colors } from '../../../util/theme/models'; diff --git a/app/components/UI/LoginOptionsSwitch/styles.ts b/app/components/UI/LoginOptionsSwitch/styles.ts index 41b6d96bf23..b11477b5bf6 100644 --- a/app/components/UI/LoginOptionsSwitch/styles.ts +++ b/app/components/UI/LoginOptionsSwitch/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { fontStyles } from '../../../styles/common'; import { StyleSheet } from 'react-native'; diff --git a/app/components/UI/MarketInsights/MarketInsights.testIds.ts b/app/components/UI/MarketInsights/MarketInsights.testIds.ts index 5cb679e0029..a4aefeaf3bd 100644 --- a/app/components/UI/MarketInsights/MarketInsights.testIds.ts +++ b/app/components/UI/MarketInsights/MarketInsights.testIds.ts @@ -9,6 +9,8 @@ export enum MarketInsightsSelectorsIDs { SOURCES_FOOTER = 'market-insights-sources-footer', THUMBS_UP_BUTTON = 'market-insights-thumbs-up-button', THUMBS_DOWN_BUTTON = 'market-insights-thumbs-down-button', + LONG_BUTTON = 'market-insights-long-button', + SHORT_BUTTON = 'market-insights-short-button', SWAP_BUTTON = 'market-insights-swap-button', BUY_BUTTON = 'market-insights-buy-button', FEEDBACK_BOTTOM_SHEET = 'market-insights-feedback-bottom-sheet', diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.test.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.test.tsx index 325fe7b2363..0315a5a763b 100644 --- a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.test.tsx +++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.test.tsx @@ -27,9 +27,18 @@ const mockUseSwapBridgeNavigation = jest.fn((_options: unknown) => ({ goToSwaps: mockGoToSwaps, })); -let mockRouteParams = { +let mockRouteParams: { + assetSymbol: string; + assetIdentifier: string; + tokenImageUrl?: string; + tokenAddress?: string; + tokenDecimals?: number; + tokenName?: string; + tokenChainId?: string; + isPerps?: boolean; +} = { assetSymbol: 'ETH', - caip19Id: 'eip155:1/erc20:0x123', + assetIdentifier: 'eip155:1/erc20:0x123', tokenImageUrl: 'https://example.com/eth.png', tokenAddress: '0x123', tokenDecimals: 18, @@ -57,7 +66,8 @@ jest.mock('react-native-safe-area-context', () => ({ })); jest.mock('../../hooks/useMarketInsights', () => ({ - useMarketInsights: (caip19Id: string) => mockUseMarketInsights(caip19Id), + useMarketInsights: (assetIdentifier: string) => + mockUseMarketInsights(assetIdentifier), })); jest.mock('../../../Bridge/hooks/useSwapBridgeNavigation', () => ({ @@ -218,7 +228,7 @@ describe('MarketInsightsView', () => { jest.clearAllMocks(); mockRouteParams = { assetSymbol: 'ETH', - caip19Id: 'eip155:1/erc20:0x123', + assetIdentifier: 'eip155:1/erc20:0x123', tokenImageUrl: 'https://example.com/eth.png', tokenAddress: '0x123', tokenDecimals: 18, @@ -621,7 +631,7 @@ describe('MarketInsightsView', () => { mockRouteParams = { ...mockRouteParams, assetSymbol: 'USDC', - caip19Id: 'eip155:1/erc20:0x456', + assetIdentifier: 'eip155:1/erc20:0x456', tokenAddress: '0x456', tokenName: 'USD Coin', }; @@ -637,4 +647,223 @@ describe('MarketInsightsView', () => { }), ); }); + + it('shows Long and Short buttons (not Trade) in perps context', () => { + mockRouteParams = { + assetSymbol: 'ETH', + assetIdentifier: 'ETH', + isPerps: true, + }; + mockUseMarketInsights.mockReturnValue({ + report: { + asset: 'eth', + generatedAt: '2026-02-17T11:55:00.000Z', + headline: 'ETH perps insight', + summary: 'Open interest rises', + trends: [], + sources: [], + }, + isLoading: false, + error: null, + timeAgo: '1m ago', + }); + + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); + + expect( + getByTestId(MarketInsightsSelectorsIDs.LONG_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(MarketInsightsSelectorsIDs.SHORT_BUTTON), + ).toBeOnTheScreen(); + expect(queryByTestId(MarketInsightsSelectorsIDs.SWAP_BUTTON)).toBeNull(); + expect(queryByTestId(MarketInsightsSelectorsIDs.BUY_BUTTON)).toBeNull(); + }); + + it('navigates to PerpsOrderRedirect with long direction when Long button is pressed', () => { + mockRouteParams = { + assetSymbol: 'ETH', + assetIdentifier: 'ETH', + isPerps: true, + }; + mockUseMarketInsights.mockReturnValue({ + report: { + asset: 'eth', + generatedAt: '2026-02-17T11:55:00.000Z', + headline: 'ETH perps insight', + summary: 'Open interest rises', + trends: [], + sources: [], + }, + isLoading: false, + error: null, + timeAgo: '1m ago', + }); + + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.LONG_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.PERPS.ROOT, + expect.objectContaining({ + screen: Routes.PERPS.ORDER_REDIRECT, + params: { direction: 'long', asset: 'ETH' }, + }), + ); + expect(mockGoToSwaps).not.toHaveBeenCalled(); + }); + + it('navigates to PerpsOrderRedirect with short direction when Short button is pressed', () => { + mockRouteParams = { + assetSymbol: 'ETH', + assetIdentifier: 'ETH', + isPerps: true, + }; + mockUseMarketInsights.mockReturnValue({ + report: { + asset: 'eth', + generatedAt: '2026-02-17T11:55:00.000Z', + headline: 'ETH perps insight', + summary: 'Open interest rises', + trends: [], + sources: [], + }, + isLoading: false, + error: null, + timeAgo: '1m ago', + }); + + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.SHORT_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.PERPS.ROOT, + expect.objectContaining({ + screen: Routes.PERPS.ORDER_REDIRECT, + params: { direction: 'short', asset: 'ETH' }, + }), + ); + expect(mockGoToSwaps).not.toHaveBeenCalled(); + }); + + it('navigates to swaps when swap button is pressed in token context', () => { + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); + + expect(queryByTestId(MarketInsightsSelectorsIDs.LONG_BUTTON)).toBeNull(); + expect(queryByTestId(MarketInsightsSelectorsIDs.SHORT_BUTTON)).toBeNull(); + + fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.SWAP_BUTTON)); + + expect(mockGoToSwaps).toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.PERPS.ROOT, + expect.anything(), + ); + }); + + it('sends perps_market analytics property (not caip19) in perps context', () => { + mockRouteParams = { + assetSymbol: 'ETH', + assetIdentifier: 'ETH', + isPerps: true, + }; + mockUseMarketInsights.mockReturnValue({ + report: { + asset: 'eth', + generatedAt: '2026-02-17T11:55:00.000Z', + headline: 'ETH perps gaining traction', + summary: 'Open interest rises as funding rates normalise', + trends: [ + { + title: 'Funding rates', + description: 'Funding rates returning to neutral', + articles: [], + tweets: [], + }, + ], + sources: [], + }, + isLoading: false, + error: null, + timeAgo: '2m ago', + }); + + const { getByTestId } = renderWithProvider(); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.MARKET_INSIGHTS_VIEWED, + properties: expect.objectContaining({ + perps_market: 'ETH', + }), + }), + ); + expect(mockTrackEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.MARKET_INSIGHTS_VIEWED, + properties: expect.objectContaining({ + caip19: expect.anything(), + }), + }), + ); + + // Long button carries perps_market and interaction_type 'long' + fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.LONG_BUTTON)); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.MARKET_INSIGHTS_INTERACTION, + properties: expect.objectContaining({ + perps_market: 'ETH', + interaction_type: 'long', + }), + }), + ); + expect(mockTrackEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.MARKET_INSIGHTS_INTERACTION, + properties: expect.objectContaining({ + caip19: expect.anything(), + interaction_type: 'long', + }), + }), + ); + + // Short button carries perps_market and interaction_type 'short' + fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.SHORT_BUTTON)); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.MARKET_INSIGHTS_INTERACTION, + properties: expect.objectContaining({ + perps_market: 'ETH', + interaction_type: 'short', + }), + }), + ); + + fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.THUMBS_UP_BUTTON)); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.MARKET_INSIGHTS_INTERACTION, + properties: expect.objectContaining({ + perps_market: 'ETH', + interaction_type: 'thumbs_up', + }), + }), + ); + expect(mockTrackEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.MARKET_INSIGHTS_INTERACTION, + properties: expect.objectContaining({ + caip19: expect.anything(), + interaction_type: 'thumbs_up', + }), + }), + ); + }); }); diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx index 3c6ac6e7a9b..dc761d86a29 100644 --- a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx +++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx @@ -15,13 +15,13 @@ import { useColorScheme, } from 'react-native'; import Video from 'react-native-video'; -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const MarketInsightsBackgroundVideoLight = require('../../animations/market-insights-background-light.mp4'); -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const MarketInsightsBackgroundVideoDark = require('../../animations/market-insights-background-dark.mp4'); -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const MarketInsightsBackgroundLastFrameLight = require('../../animations/market-insights-background-light-last-frame.png'); -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const MarketInsightsBackgroundLastFrameDark = require('../../animations/market-insights-background-dark-last-frame.png'); import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; @@ -62,7 +62,10 @@ import type { MarketInsightsTweet, MarketInsightsTrend, } from '@metamask/ai-controllers'; -import { selectMarketInsightsEnabled } from '../../../../../selectors/featureFlagController/marketInsights'; +import { + selectMarketInsightsEnabled, + selectMarketInsightsPerpsEnabled, +} from '../../../../../selectors/featureFlagController/marketInsights'; import { endTrace, TraceName } from '../../../../../util/trace'; import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; import MarketInsightsViewSkeleton from './MarketInsightsViewSkeleton'; @@ -137,7 +140,8 @@ const AnimatedSection: React.FC = ({ interface MarketInsightsRouteParams { assetSymbol: string; - caip19Id: string; + /** Asset identifier: CAIP-19 ID for tokens, or a perps market symbol (e.g. "ETH") */ + assetIdentifier: string; tokenImageUrl?: string; /** Token address for swap navigation */ tokenAddress?: string; @@ -147,6 +151,10 @@ interface MarketInsightsRouteParams { tokenName?: string; /** Token chainId for swap navigation */ tokenChainId?: string; + /** When true, indicates the view was opened from the Perps market details view */ + isPerps?: boolean; + /** When true, the user has an existing perps position for this asset */ + hasPerpsPosition?: boolean; } /** @@ -163,21 +171,28 @@ const MarketInsightsView: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const isMarketInsightsEnabled = useSelector(selectMarketInsightsEnabled); + const isTokenInsightsEnabled = useSelector(selectMarketInsightsEnabled); + const isPerpsInsightsEnabled = useSelector(selectMarketInsightsPerpsEnabled); const route = useRoute>(); const { assetSymbol, - caip19Id, + assetIdentifier, tokenImageUrl, tokenAddress, tokenDecimals, tokenName, tokenChainId, + isPerps = false, + hasPerpsPosition = false, } = route.params; + const isMarketInsightsEnabled = isPerps + ? isPerpsInsightsEnabled + : isTokenInsightsEnabled; + const { report, isLoading, error } = useMarketInsights( - caip19Id, + assetIdentifier, isMarketInsightsEnabled, ); @@ -249,6 +264,14 @@ const MarketInsightsView: React.FC = () => { sourceToken, }); + // Sends the identifier under the right analytics property name. + // Token flow uses caip19 (a real CAIP-19 ID); perps flow uses perps_market + // (a plain market symbol like "ETH") to keep the two dimensions clean. + const assetIdProperty = useMemo( + () => + isPerps ? { perps_market: assetIdentifier } : { caip19: assetIdentifier }, + [isPerps, assetIdentifier], + ); const { goToBuy } = useRampNavigation(); // Collect all tweets from all trends for the "What people are saying" section @@ -271,21 +294,40 @@ const MarketInsightsView: React.FC = () => { MetaMetricsEvents.MARKET_INSIGHTS_INTERACTION, ) .addProperties({ - caip19: caip19Id, + ...assetIdProperty, interaction_type: 'swap', }) .build(); trackEvent(event); - goToSwaps(); - }, [goToSwaps, trackEvent, createEventBuilder, caip19Id]); + }, [goToSwaps, trackEvent, createEventBuilder, assetIdProperty]); + + const handlePerpsDirectionPress = useCallback( + (direction: 'long' | 'short') => { + const event = createEventBuilder( + MetaMetricsEvents.MARKET_INSIGHTS_INTERACTION, + ) + .addProperties({ + ...assetIdProperty, + interaction_type: direction, + }) + .build(); + trackEvent(event); + + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.ORDER_REDIRECT, + params: { direction, asset: assetSymbol }, + }); + }, + [navigation, trackEvent, createEventBuilder, assetIdProperty, assetSymbol], + ); const handleBuyPress = useCallback(() => { const event = createEventBuilder( MetaMetricsEvents.MARKET_INSIGHTS_INTERACTION, ) .addProperties({ - caip19: caip19Id, + ...assetIdProperty, interaction_type: 'buy', }) .build(); @@ -310,7 +352,7 @@ const MarketInsightsView: React.FC = () => { goToBuy, trackEvent, createEventBuilder, - caip19Id, + assetIdProperty, tokenAddress, tokenChainId, ]); @@ -362,7 +404,7 @@ const MarketInsightsView: React.FC = () => { }, ) => { const properties = { - caip19: caip19Id, + ...assetIdProperty, interaction_type: interactionType, ...(options?.source ? { source: options.source } : {}), ...(options?.feedbackReason @@ -379,7 +421,7 @@ const MarketInsightsView: React.FC = () => { .build(); trackEvent(event); }, - [trackEvent, createEventBuilder, caip19Id], + [trackEvent, createEventBuilder, assetIdProperty], ); const showFeedbackSubmittedToast = useCallback(() => { @@ -395,7 +437,7 @@ const MarketInsightsView: React.FC = () => { useEffect(() => { hasTrackedViewRef.current = false; - }, [caip19Id]); + }, [assetIdentifier]); const handleThumbsUpPress = useCallback(() => { trackMarketInsightsInteraction('thumbs_up'); @@ -459,12 +501,12 @@ const MarketInsightsView: React.FC = () => { const event = createEventBuilder(MetaMetricsEvents.MARKET_INSIGHTS_VIEWED) .addProperties({ - caip19: caip19Id, + ...assetIdProperty, }) .build(); trackEvent(event); hasTrackedViewRef.current = true; - }, [report, caip19Id, trackEvent, createEventBuilder]); + }, [report, assetIdProperty, trackEvent, createEventBuilder]); if (showLoadingSkeleton && !report && !error) { return ( @@ -621,42 +663,79 @@ const MarketInsightsView: React.FC = () => { > {strings('market_insights.helpful_prompt')} + {isPerps && hasPerpsPosition && ( + + {strings('market_insights.footer_disclaimer')} + + )} - - - - - - - + + + ) : ( + + + + + + + + + )} + + - {strings('market_insights.buy_button')} - + {strings('market_insights.footer_disclaimer')} + - - - {strings('market_insights.footer_disclaimer')} - - - + )} {selectedTrend ? ( = ({ useEffect(() => { // End the trace started by the parent (AssetOverviewContent) to measure // how long it takes for the entry card to mount after navigation. - endTrace({ - name: TraceName.MarketInsightsEntryCardLoad, - id: caip19Id, - }); + // caip19Id is only provided when the parent started a matching trace. + if (caip19Id) { + endTrace({ + name: TraceName.MarketInsightsEntryCardLoad, + id: caip19Id, + }); + } }, [caip19Id]); return ( diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.types.ts b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.types.ts index ec95f84db6b..67b88ed2da3 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.types.ts +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.types.ts @@ -8,8 +8,11 @@ export interface MarketInsightsEntryCardProps { timeAgo: string; /** Callback when the card is pressed to open the full view */ onPress: () => void; - /** The CAIP-19 asset ID, used to match the trace started by the parent */ - caip19Id: CaipAssetType; + /** The CAIP-19 asset ID, used to match the trace started by the parent. + * Optional, only provide this when a corresponding startTrace was initiated + * by the parent component (AssetOverviewContent in the token details flow). + */ + caip19Id?: CaipAssetType; /** Optional test ID */ testID?: string; } diff --git a/app/components/UI/MarketInsights/hooks/useMarketInsights.test.ts b/app/components/UI/MarketInsights/hooks/useMarketInsights.test.ts index f515dacb821..50feee32ebc 100644 --- a/app/components/UI/MarketInsights/hooks/useMarketInsights.test.ts +++ b/app/components/UI/MarketInsights/hooks/useMarketInsights.test.ts @@ -25,7 +25,7 @@ describe('useMarketInsights', () => { jest.useRealTimers(); }); - it('does not fetch when caip19Id is missing', () => { + it('does not fetch when assetIdentifier is missing', () => { const { result } = renderHook(() => useMarketInsights(undefined)); expect(mockFetchMarketInsights).not.toHaveBeenCalled(); @@ -99,4 +99,26 @@ describe('useMarketInsights', () => { expect(result.current.error).toBe('fetch failed'); expect(result.current.timeAgo).toBe(''); }); + + it('fetches using a perps market symbol as assetIdentifier', async () => { + const report = { + version: '1.0', + asset: 'eth', + generatedAt: '2026-02-17T11:55:00.000Z', + headline: 'ETH perpetuals update', + summary: 'Perps funding rates normalizing.', + trends: [], + sources: [], + }; + + mockFetchMarketInsights.mockResolvedValue(report); + + const { result } = renderHook(() => useMarketInsights('ETH', true)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mockFetchMarketInsights).toHaveBeenCalledWith('ETH'); + expect(result.current.report).toEqual(report); + expect(result.current.error).toBeNull(); + }); }); diff --git a/app/components/UI/MarketInsights/hooks/useMarketInsights.ts b/app/components/UI/MarketInsights/hooks/useMarketInsights.ts index b49f228397d..a60acfececf 100644 --- a/app/components/UI/MarketInsights/hooks/useMarketInsights.ts +++ b/app/components/UI/MarketInsights/hooks/useMarketInsights.ts @@ -21,22 +21,25 @@ export interface UseMarketInsightsResult { * Hook to fetch market insights for a given asset. * * This hook reads market insights through AiDigestController, which caches - * insights per CAIP-19 ID and fetches them from the digest service as needed. + * insights per asset identifier and fetches them from the digest service as needed. * - * @param caip19Id - The CAIP-19 asset identifier. + * @param assetIdentifier - The asset identifier: either a CAIP-19 ID (e.g. "eip155:1/slip44:60") + * or a perps market symbol (e.g. "ETH"). * @param isEnabled - Whether market insights requests are enabled. * @returns Market insights report data with loading/error states */ export const useMarketInsights = ( - caip19Id: string | undefined | null, + assetIdentifier: string | undefined | null, isEnabled = false, ): UseMarketInsightsResult => { const [report, setReport] = useState(null); - const [isLoading, setIsLoading] = useState(Boolean(isEnabled && caip19Id)); + const [isLoading, setIsLoading] = useState( + Boolean(isEnabled && assetIdentifier), + ); const [error, setError] = useState(null); const fetchInsights = useCallback(async () => { - if (!isEnabled || !caip19Id) { + if (!isEnabled || !assetIdentifier) { setReport(null); setError(null); setIsLoading(false); @@ -48,7 +51,9 @@ export const useMarketInsights = ( try { const data = - await Engine.context.AiDigestController.fetchMarketInsights(caip19Id); + await Engine.context.AiDigestController.fetchMarketInsights( + assetIdentifier, + ); setReport(data as MarketInsightsReport | null); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch insights'); @@ -56,7 +61,7 @@ export const useMarketInsights = ( } finally { setIsLoading(false); } - }, [caip19Id, isEnabled]); + }, [assetIdentifier, isEnabled]); useEffect(() => { fetchInsights(); diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index 097cbdf9fe2..403e06288b6 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -34,7 +34,6 @@ import { } from '../../../component-library/components/Texts/Text'; import { CommonSelectorsIDs } from '../../../util/Common.testIds'; import { NetworksViewSelectorsIDs } from '../../Views/Settings/NetworksSettings/NetworksView.testIds'; -import { SendLinkViewSelectorsIDs } from '../ReceiveRequest/SendLinkView.testIds'; import Icon, { IconName, IconSize, @@ -47,7 +46,6 @@ import HeaderBase, { } from '../../../component-library/components/HeaderBase'; import getHeaderCompactStandardNavbarOptions from '../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions'; import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; -import { RequestPaymentViewSelectors } from '../ReceiveRequest/RequestPaymentView.testIds'; import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; import { @@ -330,120 +328,6 @@ export function getEditableOptions(title, navigation, route, themeColors) { }; } -/** - * Function that returns the navigation options - * This is used by payment request view showing close and back buttons - * - * @param {string} title - Title in string format - * @param {Object} navigation - Navigation object required to push new views - * @returns {Object} - Corresponding navbar options containing title, headerLeft and headerRight - */ -export function getPaymentRequestOptionsTitle( - title, - navigation, - route, - themeColors, -) { - const goBack = route.params?.dispatch; - const innerStyles = StyleSheet.create({ - headerTitleStyle: { - justifyContent: 'center', - alignItems: 'center', - }, - headerIcon: { - color: themeColors.primary.default, - }, - headerStyle: { - backgroundColor: themeColors.background.default, - shadowColor: importedColors.transparent, - elevation: 0, - }, - headerCloseButton: { - marginRight: 16, - }, - }); - - return { - headerTitleAlign: 'center', - headerTitle: () => ( - - {title} - - ), - headerLeft: () => - goBack ? ( - // eslint-disable-next-line react/jsx-no-bind - - - - ) : ( - - ), - headerRight: () => ( - navigation.pop()} - style={innerStyles.headerCloseButton} - testID={RequestPaymentViewSelectors.BACK_BUTTON_ID} - /> - ), - headerStyle: innerStyles.headerStyle, - headerTintColor: themeColors.primary.default, - }; -} - -/** - * Function that returns the navigation options - * This is used by payment request view showing close button - * - * @returns {Object} - Corresponding navbar options containing title, and headerRight - */ -export function getPaymentRequestSuccessOptionsTitle(navigation, themeColors) { - const innerStyles = StyleSheet.create({ - headerStyle: { - backgroundColor: themeColors.background.default, - shadowColor: importedColors.transparent, - elevation: 0, - }, - headerIcon: { - color: themeColors.primary.default, - }, - }); - - return { - headerStyle: innerStyles.headerStyle, - title: null, - headerLeft: () => , - headerRight: () => ( - navigation.pop()} - style={styles.closeButton} - {...generateTestId( - Platform, - SendLinkViewSelectorsIDs.CLOSE_SEND_LINK_VIEW_BUTTON, - )} - > - - - ), - headerTintColor: themeColors.primary.default, - }; -} - /** * Function that returns the navigation options * This is used by views that confirms transactions, showing current network @@ -1355,38 +1239,6 @@ export function getPerpsTransactionsDetailsNavbar(navigation, title) { }; } -export function getPerpsMarketDetailsNavbar(navigation, title) { - const innerStyles = StyleSheet.create({ - perpsMarketDetailsTitle: { - fontWeight: '700', - textAlign: 'center', - flex: 1, - }, - }); - // Always navigate back to markets page for consistent navigation - const leftAction = () => navigation.navigate(Routes.PERPS.PERPS_HOME); - - return { - headerTitle: () => ( - - ), - headerLeft: () => ( - - ), - }; -} - /** * Function that returns navigation options for deposit flow screens * diff --git a/app/components/UI/NetworkInfo/NetworkEducationModal.testIds.ts b/app/components/UI/NetworkInfo/NetworkEducationModal.testIds.ts index d73167babfa..3b25e6d39a6 100644 --- a/app/components/UI/NetworkInfo/NetworkEducationModal.testIds.ts +++ b/app/components/UI/NetworkInfo/NetworkEducationModal.testIds.ts @@ -2,6 +2,7 @@ import enContent from '../../../../locales/languages/en.json'; export const NetworkEducationModalSelectorsText = { ADD_TOKEN: enContent.network_information.add_token, + GOT_IT: enContent.network_information.got_it, }; export const NetworkEducationModalSelectorsIDs = { diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx index 8d97ae24468..329ec5984d1 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx @@ -31,6 +31,27 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); +// Avoid loading keyring-utils, keyring-api, and the network/Engine chain in this test +jest.mock('../../../selectors/accountsController', () => ({ + selectSelectedInternalAccountFormattedAddress: jest.fn(), +})); + +jest.mock('../../../util/address', () => ({ + isHardwareAccount: jest.fn(() => false), +})); + +jest.mock('@metamask/keyring-api', () => ({ + EntropySourceId: {}, + BtcMethod: {}, + EthMethod: {}, + SolAccountType: {}, + SolMethod: {}, + TrxMethod: {}, + isEvmAccountType: jest.fn(), + KeyringAccountType: {}, + EthScope: {}, +})); + jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: jest.fn(), })); diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx index 9a3afef91ee..0ef4472b59f 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx @@ -59,6 +59,8 @@ import { selectEvmChainId } from '../../../selectors/networkController'; import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { NETWORK_MULTI_SELECTOR_TEST_IDS } from '../NetworkMultiSelector/NetworkMultiSelector.constants'; import { getGasFeesSponsoredNetworkEnabled } from '../../../selectors/featureFlagController/gasFeesSponsored/index.ts'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; +import { isHardwareAccount } from '../../../util/address'; import { strings } from '../../../../locales/i18n'; import TagColored, { TagColor, @@ -106,6 +108,12 @@ const NetworkMultiSelectList = ({ const isGasFeesSponsoredNetworkEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, ); + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + const isHardwareWallet = Boolean( + selectedAddress && isHardwareAccount(selectedAddress), + ); const { styles } = useStyles(styleSheet, {}); @@ -269,7 +277,8 @@ const NetworkMultiSelectList = ({ const isDisabled = isLoading || isSelectionDisabled; const showButtonIcon = Boolean(networkTypeOrRpcUrl); - const isGasSponsored = isGasFeesSponsoredNetworkEnabled(chainId); + const isGasSponsored = + !isHardwareWallet && isGasFeesSponsoredNetworkEnabled(chainId); return ( @@ -342,6 +351,7 @@ const NetworkMultiSelectList = ({ isSelectAllNetworksSection, openRpcModal, isGasFeesSponsoredNetworkEnabled, + isHardwareWallet, styles.centeredNetworkCell, styles.noNetworkFeeContainer, ], @@ -351,11 +361,17 @@ const NetworkMultiSelectList = ({ if (!networks.length || !isAutoScrollEnabled) return; if (networksLengthRef.current !== networks.length) { const selectedNetwork = networks.find(({ isSelected }) => isSelected); - networkListRef?.current?.scrollToOffset({ - offset: selectedNetwork?.yOffset ?? 0, - animated: false, - }); + const offset = selectedNetwork?.yOffset ?? 0; networksLengthRef.current = networks.length; + // Defer scroll so FlashList has time to lay out items and avoid "index out of bounds" + requestAnimationFrame(() => { + if (networkListRef?.current?.scrollToOffset) { + networkListRef.current.scrollToOffset({ + offset, + animated: false, + }); + } + }); } }, [networks, isAutoScrollEnabled]); diff --git a/app/components/UI/NftGrid/NftGrid.test.tsx b/app/components/UI/NftGrid/NftGrid.test.tsx index 605614071e4..615f4f6dcd8 100644 --- a/app/components/UI/NftGrid/NftGrid.test.tsx +++ b/app/components/UI/NftGrid/NftGrid.test.tsx @@ -138,6 +138,14 @@ jest.mock('./NftGridSkeleton', () => { return () => ; }); +// Mock Skeleton to avoid animation/design-system dependencies +jest.mock('../../../component-library/components-temp/Skeleton', () => ({ + Skeleton: ({ testID }: { testID?: string }) => { + const { View } = jest.requireActual('react-native'); + return ; + }, +})); + // Mock CollectiblesEmptyState - has complex dependencies jest.mock('../CollectiblesEmptyState', () => ({ CollectiblesEmptyState: ({ diff --git a/app/components/UI/NftGrid/NftGrid.tsx b/app/components/UI/NftGrid/NftGrid.tsx index 4bc8c6add45..ecfc6b89879 100644 --- a/app/components/UI/NftGrid/NftGrid.tsx +++ b/app/components/UI/NftGrid/NftGrid.tsx @@ -270,9 +270,10 @@ const NftGrid = forwardRef( /> )} - keyExtractor={(_, index) => `nft-row-${index}`} + keyExtractor={(item) => + `${item.chainId}-${item.address}-${item.tokenId}` + } testID={RefreshTestId} - decelerationRate="fast" refreshControl={ ({ + useSelector: () => mockDisplayNftMedia, +})); + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: mockNavigate, + }), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => { + const styleFunc = (className: string | string[]) => { + if (Array.isArray(className)) { + return className.reduce((acc, cls) => ({ ...acc, [cls]: true }), {}); + } + return { [className]: true }; + }; + styleFunc.style = styleFunc; + return styleFunc; + }, +})); + +jest.mock('@metamask/design-system-react-native', () => ({ + Text: ({ + children, + ...props + }: { + children: React.ReactNode; + [key: string]: unknown; + }) => { + const { Text: RNText } = jest.requireActual('react-native'); + return {children}; + }, + TextVariant: { BodyMd: 'BodyMd', BodySm: 'BodySm' }, + FontWeight: { Medium: 'Medium' }, + Box: ({ + children, + testID, + }: { + children: React.ReactNode; + testID?: string; + }) => { + const { View } = jest.requireActual('react-native'); + return {children}; + }, +})); + +jest.mock('lodash', () => ({ + ...jest.requireActual('lodash'), + debounce: (fn: (...args: unknown[]) => unknown) => fn, +})); + +let mockOnLoad: (() => void) | undefined; +jest.mock('../CollectibleMedia', () => ({ + __esModule: true, + default: ({ onLoad }: { onLoad?: () => void }) => { + mockOnLoad = onLoad; + const { View } = jest.requireActual('react-native'); + return ; + }, +})); + +jest.mock('../../../component-library/components-temp/Skeleton', () => ({ + Skeleton: () => { + const { View } = jest.requireActual('react-native'); + return ; + }, +})); + +describe('NftGridItem', () => { + const mockNft: Nft = { + address: '0x123', + tokenId: '456', + name: 'Test NFT', + image: 'https://example.com/nft.png', + collection: { name: 'Test Collection' }, + chainId: 1, + isCurrentlyOwned: true, + standard: 'ERC721', + } as Nft; + + const mockOnLongPress = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockOnLoad = undefined; + mockDisplayNftMedia = true; + }); + + it('shows skeleton while image is loading when NFT has an image', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('nft-skeleton')).toBeOnTheScreen(); + }); + + it('hides skeleton after image loads', async () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('nft-skeleton')).toBeOnTheScreen(); + + await act(async () => { + mockOnLoad?.(); + }); + + await waitFor(() => { + expect(queryByTestId('nft-skeleton')).toBeNull(); + }); + }); + + it('does not show skeleton when NFT has no image', () => { + const nftWithoutImage: Nft = { + ...mockNft, + image: null, + imageOriginal: undefined, + }; + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('nft-skeleton')).toBeNull(); + }); + + it('does not show skeleton when NFT media display is disabled, even if NFT has an image', () => { + mockDisplayNftMedia = false; + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('nft-skeleton')).toBeNull(); + }); + + it('resets skeleton loading state when NFT item changes to one with an image', async () => { + const nftWithoutImage: Nft = { + ...mockNft, + image: null, + imageOriginal: undefined, + }; + + const { queryByTestId, rerender } = render( + , + ); + + expect(queryByTestId('nft-skeleton')).toBeNull(); + + await act(async () => { + rerender( + , + ); + }); + + await waitFor(() => { + expect(queryByTestId('nft-skeleton')).not.toBeNull(); + }); + }); +}); diff --git a/app/components/UI/NftGrid/NftGridItem.tsx b/app/components/UI/NftGrid/NftGridItem.tsx index 1e3a8605b05..8a72d757051 100644 --- a/app/components/UI/NftGrid/NftGridItem.tsx +++ b/app/components/UI/NftGrid/NftGridItem.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Nft } from '@metamask/assets-controllers'; import { debounce } from 'lodash'; import { useNavigation } from '@react-navigation/native'; @@ -10,7 +10,10 @@ import { FontWeight, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useSelector } from 'react-redux'; +import { selectDisplayNftMedia } from '../../../selectors/preferencesController'; import CollectibleMedia from '../CollectibleMedia'; +import { Skeleton } from '../../../component-library/components-temp/Skeleton'; const debouncedNavigation = debounce((navigation, collectible, source) => { navigation.navigate('NftDetails', { collectible, source }); @@ -27,14 +30,32 @@ const NftGridItem = ({ }) => { const navigation = useNavigation(); const tw = useTailwind(); + const displayNftMedia = useSelector(selectDisplayNftMedia); + const [isImageLoading, setIsImageLoading] = useState( + () => displayNftMedia && !!(item.image || item.imageOriginal), + ); + + useEffect(() => { + setIsImageLoading(displayNftMedia && !!(item.image || item.imageOriginal)); + }, [ + item.address, + item.tokenId, + item.image, + item.imageOriginal, + displayNftMedia, + ]); const onPress = useCallback(() => { debouncedNavigation(navigation, item, source); }, [navigation, item, source]); + const handleImageLoad = useCallback(() => setIsImageLoading(false), []); + return ( + tw.style('self-stretch mb-3', pressed && 'opacity-50') + } onPress={onPress} onLongPress={() => onLongPress(item)} testID={`collectible-${item.name}-${item.tokenId}`} @@ -44,7 +65,11 @@ const NftGridItem = ({ style={tw.style('self-stretch aspect-square')} collectible={item} isTokenImage + onLoad={handleImageLoad} /> + {isImageLoading && ( + + )} diff --git a/app/components/UI/Notification/NotificationMenuItem/index.tsx b/app/components/UI/Notification/NotificationMenuItem/index.tsx index 90e38745b35..cb690a3bcb9 100644 --- a/app/components/UI/Notification/NotificationMenuItem/index.tsx +++ b/app/components/UI/Notification/NotificationMenuItem/index.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import NotificationRoot from './Root'; import NotificationIcon from './Icon'; import NotificationContent from './Content'; diff --git a/app/components/UI/Notification/SwitchLoadingModal/Loader.tsx b/app/components/UI/Notification/SwitchLoadingModal/Loader.tsx index abbee535eee..598532bd779 100644 --- a/app/components/UI/Notification/SwitchLoadingModal/Loader.tsx +++ b/app/components/UI/Notification/SwitchLoadingModal/Loader.tsx @@ -13,11 +13,11 @@ import Icon, { } from '../../../../component-library/components/Icons/Icon'; import Spinner from '../../AnimatedSpinner'; -import Button, { +import { + Button, + ButtonVariant, ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../component-library/components/Buttons/Button'; +} from '@metamask/design-system-react-native'; import { strings } from '../../../../../locales/i18n'; import type { ThemeColors } from '@metamask/design-tokens'; @@ -44,6 +44,7 @@ const createStyles = (colors: ThemeColors) => }, button: { alignSelf: 'center', + width: '90%', }, }); @@ -79,13 +80,13 @@ const Loader = ({ {!!errorText && ( )} ); diff --git a/app/components/UI/Notification/__mocks__/mock_notifications.ts b/app/components/UI/Notification/__mocks__/mock_notifications.ts index 92588632adf..2d40cea3e2c 100644 --- a/app/components/UI/Notification/__mocks__/mock_notifications.ts +++ b/app/components/UI/Notification/__mocks__/mock_notifications.ts @@ -1,5 +1,5 @@ import { processNotification } from '@metamask/notification-services-controller/notification-services'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as Mocks from '@metamask/notification-services-controller/notification-services/mocks'; export const MOCK_ON_CHAIN_NOTIFICATIONS = [ diff --git a/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.tsx b/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.tsx index 57897835100..fc872f5ab82 100644 --- a/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.tsx +++ b/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.tsx @@ -23,7 +23,7 @@ import BottomSheet, { } from '../../../component-library/components/BottomSheets/BottomSheet'; import HeaderCompactStandard from '../../../component-library/components-temp/HeaderCompactStandard'; -/* eslint-disable import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ +/* eslint-disable import-x/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ const foxLogo = require('../../../images/branding/fox.png'); const metamaskNameLightMode = require('../../../images/branding/metamask-name.png'); const metamaskNameDarkMode = require('../../../images/branding/metamask-name-white.png'); diff --git a/app/components/UI/OTAUpdatesModal/index.ts b/app/components/UI/OTAUpdatesModal/index.ts index 973b7c7da3a..da33a841d8a 100644 --- a/app/components/UI/OTAUpdatesModal/index.ts +++ b/app/components/UI/OTAUpdatesModal/index.ts @@ -1,2 +1,2 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ export { default as OTAUpdatesModal } from './OTAUpdatesModal'; diff --git a/app/components/UI/OptinMetrics/OptinMetrics.styles.ts b/app/components/UI/OptinMetrics/OptinMetrics.styles.ts deleted file mode 100644 index 2e4e7c712e1..00000000000 --- a/app/components/UI/OptinMetrics/OptinMetrics.styles.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { StyleSheet, Platform, StatusBar } from 'react-native'; -import { baseStyles } from '../../../styles/common'; -import Device from '../../../util/device'; -import type { Colors } from '../../../util/theme/models'; - -const createStyles = (colors: Colors) => - StyleSheet.create({ - root: { - ...baseStyles.flexGrow, - backgroundColor: colors.background.default, - paddingTop: - Platform.OS === 'android' ? StatusBar.currentHeight || 40 : 40, - }, - checkbox: { - display: 'flex', - flexDirection: 'row', - alignItems: 'flex-start', - justifyContent: 'space-between', - gap: 16, - }, - action: { - flex: 0, - flexDirection: 'row', - alignItems: 'flex-start', - gap: 16, - }, - description: { - flex: 1, - }, - wrapper: { - marginHorizontal: 20, - flex: 1, - flexDirection: 'column', - rowGap: 16, - paddingBottom: 80, - }, - actionContainer: { - flexDirection: 'row', - paddingHorizontal: 16, - paddingTop: 16, - }, - button: { - flex: 1, - }, - title: { - fontWeight: '700', - marginTop: 8, - }, - sectionContainer: { - backgroundColor: colors.background.section, - borderRadius: 12, - padding: 16, - marginBottom: 16, - }, - imageContainer: { - alignItems: 'center', - marginVertical: Device.isMediumDevice() ? 8 : 12, - }, - illustration: { - width: Device.isMediumDevice() ? 160 : 200, - height: Device.isMediumDevice() ? 120 : 180, - alignSelf: 'center', - }, - flexContainer: { - flex: 1, - }, - descriptionText: { - marginTop: 4, - marginLeft: 0, - }, - disabledContainer: { - opacity: 0.5, - }, - }); - -export default createStyles; diff --git a/app/components/UI/OptinMetrics/__snapshots__/index.test.tsx.snap b/app/components/UI/OptinMetrics/__snapshots__/index.test.tsx.snap index 8fbe0760b37..187720aa95a 100644 --- a/app/components/UI/OptinMetrics/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/OptinMetrics/__snapshots__/index.test.tsx.snap @@ -212,7 +212,9 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 40, } } @@ -224,9 +226,9 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` scrollEventThrottle={150} style={ { - "backgroundColor": "#ffffff", - "flex": 1, - "paddingTop": 40, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } testID="meta-metrics-container" @@ -234,21 +236,32 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` @@ -283,60 +299,116 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` We’d like to request these permissions. You can opt out or delete your usage data at any time. - - + Gather basic usage data @@ -394,15 +466,18 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` We'll collect basic product usage data like general location, clicks, and views. No other information will be stored. @@ -410,63 +485,106 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, + }, + undefined, + ] } > Learn more - - + Marketing updates @@ -506,71 +624,132 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` We'll use this data to learn how you interact with our marketing communications. We may share relevant news (like product features). - + - Continue - + @@ -903,7 +1082,9 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 40, } } @@ -915,9 +1096,9 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar scrollEventThrottle={150} style={ { - "backgroundColor": "#ffffff", - "flex": 1, - "paddingTop": 40, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } testID="meta-metrics-container" @@ -925,21 +1106,32 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar @@ -974,60 +1169,116 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar We’d like to request these permissions. You can opt out or delete your usage data at any time. - - + Gather basic usage data @@ -1085,15 +1336,18 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar We'll collect basic product usage data like general location, clicks, and views. No other information will be stored. @@ -1101,63 +1355,106 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, + }, + undefined, + ] } > Learn more - - + Marketing updates @@ -1197,71 +1494,132 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar We'll use this data to learn how you interact with our marketing communications. We may share relevant news (like product features). - + - Continue - + @@ -1706,7 +2064,9 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 40, } } @@ -1718,9 +2078,9 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` scrollEventThrottle={150} style={ { - "backgroundColor": "#ffffff", - "flex": 1, - "paddingTop": 40, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } testID="meta-metrics-container" @@ -1728,21 +2088,32 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` @@ -1777,60 +2151,116 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` We’d like to request these permissions. You can opt out or delete your usage data at any time. - - + Gather basic usage data @@ -1888,15 +2318,18 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` We'll collect basic product usage data like general location, clicks, and views. No other information will be stored. @@ -1904,63 +2337,106 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, + }, + undefined, + ] } > Learn more - - + Marketing updates @@ -2000,71 +2476,132 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` We'll use this data to learn how you interact with our marketing communications. We may share relevant news (like product features). - + - Continue - + diff --git a/app/components/UI/OptinMetrics/index.test.tsx b/app/components/UI/OptinMetrics/index.test.tsx index f40b25cf32c..a8c5373eadc 100644 --- a/app/components/UI/OptinMetrics/index.test.tsx +++ b/app/components/UI/OptinMetrics/index.test.tsx @@ -146,6 +146,16 @@ describe('OptinMetrics', () => { await waitFor(() => { expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + location: 'onboarding_metametrics', + updated_after_onboarding: false, + }), + }), + ); + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ name: 'Analytics Preference Selected', properties: expect.objectContaining({ @@ -177,6 +187,16 @@ describe('OptinMetrics', () => { await waitFor(() => { expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + location: 'onboarding_metametrics', + updated_after_onboarding: false, + }), + }), + ); + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ name: 'Analytics Preference Selected', properties: expect.objectContaining({ @@ -212,6 +232,16 @@ describe('OptinMetrics', () => { ); await waitFor(() => { + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + location: 'onboarding_metametrics', + updated_after_onboarding: false, + account_type: AccountType.Imported, + }), + }), + ); expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( expect.objectContaining({ name: 'Analytics Preference Selected', diff --git a/app/components/UI/OptinMetrics/index.tsx b/app/components/UI/OptinMetrics/index.tsx index a4f8d611d13..7485dff5b08 100644 --- a/app/components/UI/OptinMetrics/index.tsx +++ b/app/components/UI/OptinMetrics/index.tsx @@ -1,17 +1,31 @@ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect, useMemo } from 'react'; import { - View, ScrollView, BackHandler, Alert, - TouchableOpacity, + Pressable, Platform, Image, + StatusBar, NativeScrollEvent, NativeSyntheticEvent, LayoutChangeEvent, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + Text, + Button, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + TextVariant, + TextColor, + FontWeight, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import { strings } from '../../../../locales/i18n'; import { useDispatch, useSelector } from 'react-redux'; import { clearOnboardingEvents } from '../../../actions/onboarding'; @@ -19,22 +33,13 @@ import { selectOnboardingAccountType } from '../../../selectors/onboarding'; import { setDataCollectionForMarketing } from '../../../actions/security'; import { MetaMetricsEvents, useMetrics } from '../../hooks/useMetrics'; import { markMetricsOptInUISeen } from '../../../util/metrics/metricsOptInUIUtils'; -import { useTheme } from '../../../util/theme'; import { MetaMetricsOptInSelectorsIDs } from './MetaMetricsOptIn.testIds'; import Checkbox from '../../../component-library/components/Checkbox'; -import Button, { - ButtonVariants, - ButtonSize, -} from '../../../component-library/components/Buttons/Button'; import Routes from '../../../constants/navigation/Routes'; import generateDeviceAnalyticsMetaData, { UserSettingsAnalyticsMetaData as generateUserSettingsAnalyticsMetaData, } from '../../../util/metrics'; import { UserProfileProperty } from '../../../util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; -import Text, { - TextColor, - TextVariant, -} from '../../../component-library/components/Texts/Text'; import { getConfiguredCaipChainIds } from '../../../util/metrics/MultichainAPI/networkMetricUtils'; import { updateCachedConsent, @@ -44,7 +49,7 @@ import { import { setupSentry } from '../../../util/sentry/utils'; import PrivacyIllustration from '../../../images/privacy_metrics_illustration.png'; import { selectIsPna25FlagEnabled } from '../../../selectors/featureFlagController/legalNotices'; -import createStyles from './OptinMetrics.styles'; +import Device from '../../../util/device'; import type { OptinMetricsRouteParams } from './OptinMetrics.types'; import { useNavigation, @@ -67,7 +72,7 @@ const OptinMetrics = () => { 'OptinMetrics' > >(); - const { colors } = useTheme(); + const tw = useTailwind(); const metrics = useMetrics(); // Redux state selectors @@ -86,7 +91,14 @@ const OptinMetrics = () => { const [isMarketingChecked, setIsMarketingChecked] = useState(false); const [isBasicUsageChecked, setIsBasicUsageChecked] = useState(true); - const styles = createStyles(colors); + const isMediumDevice = useMemo(() => Device.isMediumDevice(), []); + const illustrationSize = useMemo( + () => + isMediumDevice + ? { width: 160, height: 120 } + : { width: 200, height: 180 }, + [isMediumDevice], + ); /** * Temporary disabling the back button so users can't go back @@ -156,19 +168,21 @@ const OptinMetrics = () => { dispatch(setDataCollectionForMarketing(isMarketingChecked)); - // Track opt-out event if user opted out of metrics - if (!isBasicUsageChecked) { - metrics.trackEvent( - metrics - .createEventBuilder(MetaMetricsEvents.METRICS_OPT_OUT) - .addProperties({ - updated_after_onboarding: false, - location: 'onboarding_metametrics', - ...(accountType && { account_type: accountType }), - }) - .build(), - ); - } + // Track opt-in / opt-out for metrics + metrics.trackEvent( + metrics + .createEventBuilder( + isBasicUsageChecked + ? MetaMetricsEvents.METRICS_OPT_IN + : MetaMetricsEvents.METRICS_OPT_OUT, + ) + .addProperties({ + updated_after_onboarding: false, + location: 'onboarding_metametrics', + ...(accountType && { account_type: accountType }), + }) + .build(), + ); metrics.trackEvent( metrics @@ -266,18 +280,19 @@ const OptinMetrics = () => { const renderActionButtons = useCallback( () => ( - + + ), - [styles, onConfirm], + [onConfirm, tw], ); /** @@ -326,70 +341,96 @@ const OptinMetrics = () => { [isEndReached], ); + const rootStyle = useMemo( + () => + tw.style('flex-1 bg-default', { + paddingTop: + Platform.OS === 'android' ? StatusBar.currentHeight || 40 : 40, + }), + [tw], + ); + return ( - + - - + + - + {strings('privacy_policy.description_title')} {strings('privacy_policy.description_content_1')} - - + + tw.style( + 'bg-background-alternative rounded-xl p-4 mb-4', + pressed && 'opacity-70', + ) + } onPress={handleBasicUsageToggle} testID={ MetaMetricsOptInSelectorsIDs.OPTIN_METRICS_METRICS_CHECKBOX } - activeOpacity={0.7} > - - + + {strings('privacy_policy.gather_basic_usage_title')} - + - + {isPna25FlagEnabled ? strings( @@ -398,8 +439,8 @@ const OptinMetrics = () => { : strings('privacy_policy.gather_basic_usage_description') + ' '} { e?.stopPropagation?.(); openLearnMore(); @@ -408,27 +449,37 @@ const OptinMetrics = () => { {strings('privacy_policy.gather_basic_usage_learn_more')} - - + + tw.style( + 'bg-background-alternative rounded-xl p-4 mb-4', + isMarketingDisabled && 'opacity-50', + pressed && !isMarketingDisabled && 'opacity-70', + ) + } onPress={handleMarketingToggle} - activeOpacity={isMarketingDisabled ? 1 : 0.7} disabled={isMarketingDisabled} > - - + + {strings('privacy_policy.checkbox_marketing')} - + { accessible disabled={isMarketingDisabled} /> - + {strings('privacy_policy.checkbox')} - - - + + + {renderActionButtons()} diff --git a/app/components/UI/PaymentRequest/AssetList/__snapshots__/index.test.tsx.snap b/app/components/UI/PaymentRequest/AssetList/__snapshots__/index.test.tsx.snap deleted file mode 100644 index d267a0cf6cf..00000000000 --- a/app/components/UI/PaymentRequest/AssetList/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,36 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AssetList should render correctly 1`] = ` - - - -`; diff --git a/app/components/UI/PaymentRequest/AssetList/index.test.tsx b/app/components/UI/PaymentRequest/AssetList/index.test.tsx deleted file mode 100644 index 02a3a1c73ea..00000000000 --- a/app/components/UI/PaymentRequest/AssetList/index.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import AssetList 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, - }, -}; -const store = mockStore(initialState); - -describe('AssetList', () => { - it('should render correctly', () => { - const wrapper = shallow( - - null} - emptyMessage={'Enpty Message'} - searchResults={[]} - /> - , - ); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/app/components/UI/PaymentRequest/AssetList/index.tsx b/app/components/UI/PaymentRequest/AssetList/index.tsx deleted file mode 100644 index 8c2ecefaf32..00000000000 --- a/app/components/UI/PaymentRequest/AssetList/index.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React, { useCallback } from 'react'; -import { Text, View, StyleSheet } from 'react-native'; -import StyledButton from '../../StyledButton'; -import AssetIcon from '../../AssetIcon'; -import { fontStyles } from '../../../../styles/common'; -import Identicon from '../../Identicon'; -import NetworkMainAssetLogo from '../../NetworkMainAssetLogo'; -import { useSelector } from 'react-redux'; -import { useTheme } from '../../../../util/theme'; -import { selectTokenList } from '../../../../selectors/tokenListController'; -import { ImportTokenViewSelectorsIDs } from '../../../Views/AddAsset/ImportAssetView.testIds'; -import { toChecksumAddress } from '../../../../util/address'; - -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const createStyles = (colors: any) => - StyleSheet.create({ - item: { - borderWidth: 1, - borderColor: colors.border.default, - padding: 8, - marginBottom: 8, - borderRadius: 8, - }, - assetListElement: { - flex: 1, - flexDirection: 'row', - alignItems: 'flex-start', - }, - text: { - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...(fontStyles.normal as any), - color: colors.text.default, - }, - textSymbol: { - ...fontStyles.normal, - paddingBottom: 4, - fontSize: 16, - color: colors.text.default, - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, - assetInfo: { - flex: 1, - flexDirection: 'column', - alignSelf: 'center', - padding: 4, - }, - assetIcon: { - flexDirection: 'column', - alignSelf: 'center', - marginRight: 12, - }, - ethLogo: { - width: 50, - height: 50, - }, - listContainer: { - flex: 1, - }, - }); - -interface Props { - /** - * Array of assets objects returned from the search - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - searchResults: any; - /** - * Callback triggered when a token is selected - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - handleSelectAsset: any; - /** - * Message string to display when searchResults is empty - */ - emptyMessage: string; -} - -const AssetList = ({ - searchResults, - handleSelectAsset, - emptyMessage, -}: Props) => { - const tokenList = useSelector(selectTokenList); - const { colors } = useTheme(); - const styles = createStyles(colors); - - /** - * Render logo according to asset. Could be ETH, Identicon or contractMap logo - * - * @param {object} asset - Asset to generate the logo to render - */ - const renderLogo = useCallback( - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (asset: any) => { - const { address, isETH } = asset; - if (isETH) { - return ; - } - const token = - tokenList?.[toChecksumAddress(address)] || - tokenList?.[address.toLowerCase()]; - const iconUrl = token?.iconUrl; - if (!iconUrl) { - return ; - } - return ; - }, - [tokenList, styles], - ); - - if (searchResults.length === 0) { - return {emptyMessage}; - } - - return ( - - {/* Use simple rendering like token import for better performance */} - {searchResults - .slice(0, 6) - .map( - ( - item: { symbol?: string; name?: string; address?: string }, - index: number, - ) => { - const { symbol, name } = item || {}; - return ( - handleSelectAsset(item)} - > - - {renderLogo(item)} - - {symbol} - {!!name && {name}} - - - - ); - }, - )} - - ); -}; - -export default AssetList; diff --git a/app/components/UI/PaymentRequest/__snapshots__/index.test.tsx.snap b/app/components/UI/PaymentRequest/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 86cc6ba76a9..00000000000 --- a/app/components/UI/PaymentRequest/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,945 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PaymentRequest renders correctly 1`] = ` - - - - - - - - - - - - - - - - - - Choose an asset to request - - - - -  - - - - - - Top picks - - - - - - - - ETH - - - Ether - - - - - - - - - - - - - - - - - - - SAI - - - Sai Stablecoin v1.0 - - - - - - - - - - -`; - -exports[`PaymentRequest renders correctly with network picker when feature flag is enabled 1`] = ` - - - - - - - - - - - - - - - - - - Choose an asset to request - - - - -  - - - - - - Top picks - - - - - - - - ETH - - - Ether - - - - - - - - - - - - - - - - - - - SAI - - - Sai Stablecoin v1.0 - - - - - - - - - - -`; diff --git a/app/components/UI/PaymentRequest/index.js b/app/components/UI/PaymentRequest/index.js deleted file mode 100644 index fb79e5614d8..00000000000 --- a/app/components/UI/PaymentRequest/index.js +++ /dev/null @@ -1,968 +0,0 @@ -import React, { PureComponent } from 'react'; -import { - SafeAreaView, - TextInput, - Text, - StyleSheet, - View, - TouchableOpacity, - KeyboardAvoidingView, - InteractionManager, -} from 'react-native'; -import { connect } from 'react-redux'; -import { fontStyles, baseStyles } from '../../../styles/common'; -import { getPaymentRequestOptionsTitle } from '../../UI/Navbar'; -import FeatherIcon from 'react-native-vector-icons/Feather'; -import Fuse from 'fuse.js'; -import AssetList from './AssetList'; -import PropTypes from 'prop-types'; -import { debounce } from 'lodash'; -import { - weiToFiat, - toWei, - balanceToFiat, - renderFromWei, - fiatNumberToWei, - fromWei, - isDecimal, - fiatNumberToTokenMinimalUnit, - renderFromTokenMinimalUnit, - fromTokenMinimalUnit, - toTokenMinimalUnit, -} from '../../../util/number'; -import { strings } from '../../../../locales/i18n'; -import FontAwesome from 'react-native-vector-icons/FontAwesome'; -import StyledButton from '../StyledButton'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; -import { - generateETHLink, - generateERC20Link, - generateUniversalLinkRequest, -} from '../../../util/payment-link-generator'; -import Device from '../../../util/device'; -import currencySymbols from '../../../util/currency-symbols.json'; -import { ChainId } from '@metamask/controller-utils'; -import { getTicker } from '../../../util/transactions'; -import { toLowerCaseEquals } from '../../../util/general'; -import { utils as ethersUtils } from 'ethers'; -import { ThemeContext, mockTheme } from '../../../util/theme'; -import { isTestNet, getDecimalChainId } from '../../../util/networks'; -import { isTokenDetectionSupportedForNetwork } from '@metamask/assets-controllers'; -import { - selectChainId, - selectEvmTicker, - selectNetworkConfigurations, -} from '../../../selectors/networkController'; -import { selectNetworkImageSource } from '../../../selectors/networkInfos'; -import { - selectConversionRate, - selectCurrentCurrency, -} from '../../../selectors/currencyRateController'; -import { selectTokenListArray } from '../../../selectors/tokenListController'; -import { selectTokens } from '../../../selectors/tokensController'; -import { selectContractExchangeRates } from '../../../selectors/tokenRatesController'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; -import PickerNetwork from '../../../component-library/components/Pickers/PickerNetwork/PickerNetwork'; -import Routes from '../../../constants/navigation/Routes'; -import { RequestPaymentViewSelectors } from '../ReceiveRequest/RequestPaymentView.testIds'; -import { MetaMetricsEvents } from '../../../core/Analytics'; -import { analytics } from '../../../util/analytics/analytics'; -import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; - -const KEYBOARD_OFFSET = 120; -const createStyles = (colors) => - StyleSheet.create({ - wrapper: { - backgroundColor: colors.background.default, - flex: 1, - }, - title: { - ...fontStyles.normal, - fontSize: 16, - color: colors.text.default, - }, - amountWrapper: { - marginVertical: 8, - }, - searchWrapper: { - marginVertical: 8, - borderColor: colors.border.default, - borderWidth: 1, - borderRadius: 8, - flexDirection: 'row', - backgroundColor: colors.background.default, - }, - searchInput: { - paddingTop: Device.isAndroid() ? 12 : 0, - paddingLeft: 8, - fontSize: 16, - height: 40, - flex: 1, - color: colors.text.default, - ...fontStyles.normal, - }, - searchIcon: { - textAlignVertical: 'center', - marginLeft: 12, - alignSelf: 'center', - }, - clearButton: { paddingHorizontal: 12, justifyContent: 'center' }, - input: { - ...fontStyles.normal, - backgroundColor: colors.background.default, - borderWidth: 0, - fontSize: 24, - paddingBottom: 0, - paddingRight: 0, - paddingLeft: 0, - paddingTop: 0, - color: colors.text.default, - }, - eth: { - ...fontStyles.normal, - fontSize: 24, - paddingTop: Device.isAndroid() ? 3 : 0, - paddingLeft: 10, - textTransform: 'uppercase', - color: colors.text.default, - }, - testNetEth: { - ...fontStyles.normal, - fontSize: 24, - paddingTop: Device.isAndroid() ? 3 : 0, - paddingLeft: 10, - color: colors.text.default, - }, - fiatValue: { - ...fontStyles.normal, - fontSize: 18, - color: colors.text.default, - }, - split: { - flex: 1, - flexDirection: 'row', - }, - ethContainer: { - flex: 1, - flexDirection: 'row', - paddingLeft: 6, - paddingRight: 10, - }, - container: { - flex: 1, - flexDirection: 'row', - paddingRight: 10, - paddingVertical: 10, - paddingLeft: 14, - position: 'relative', - backgroundColor: colors.background.default, - borderColor: colors.border.default, - borderRadius: 4, - borderWidth: 1, - }, - amounts: { - maxWidth: '70%', - }, - switchContainer: { - flex: 1, - flexDirection: 'column', - alignSelf: 'center', - right: 0, - }, - switchTouchable: { - flexDirection: 'row', - alignSelf: 'flex-end', - right: 0, - }, - enterAmountWrapper: { - flex: 1, - flexDirection: 'column', - }, - button: { - marginBottom: 16, - }, - buttonsWrapper: { - flex: 1, - flexDirection: 'row', - alignSelf: 'center', - }, - buttonsContainer: { - flex: 1, - flexDirection: 'column', - alignSelf: 'flex-end', - }, - scrollViewContainer: { - padding: 24, - }, - errorWrapper: { - backgroundColor: colors.error.muted, - borderRadius: 4, - marginTop: 8, - }, - errorText: { - color: colors.text.default, - alignSelf: 'center', - }, - assetsWrapper: { - marginTop: 16, - }, - assetsTitle: { - ...fontStyles.normal, - fontSize: 16, - marginBottom: 8, - color: colors.text.default, - }, - secondaryAmount: { - flexDirection: 'row', - }, - currencySymbol: { - ...fontStyles.normal, - fontSize: 24, - color: colors.text.default, - }, - currencySymbolSmall: { - ...fontStyles.normal, - fontSize: 18, - color: colors.text.default, - }, - }); - -const fuse = new Fuse([], { - shouldSort: true, - threshold: 0.45, - location: 0, - distance: 100, - maxPatternLength: 32, - minMatchCharLength: 1, - keys: [ - { name: 'name', weight: 0.5 }, - { name: 'symbol', weight: 0.5 }, - ], -}); - -const defaultEth = { - symbol: 'ETH', - name: 'Ether', - isETH: true, -}; -const defaultAssets = [ - defaultEth, - { - address: '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359', - decimals: 18, - erc20: true, - logo: 'sai.svg', - name: 'Sai Stablecoin v1.0', - symbol: 'SAI', - }, -]; - -const MODE_SELECT = 'select'; -const MODE_AMOUNT = 'amount'; - -/** - * View to generate a payment request link - */ -class PaymentRequest extends PureComponent { - static propTypes = { - /** - * Object that represents the navigator - */ - navigation: PropTypes.object, - /** - * ETH-to-current currency conversion rate from CurrencyRateController - */ - conversionRate: PropTypes.number, - /** - * Currency code for currently-selected currency from CurrencyRateController - */ - currentCurrency: PropTypes.string, - /** - * Object containing token exchange rates in the format address => exchangeRate - */ - contractExchangeRates: PropTypes.object, - /** - * Primary currency, either ETH or Fiat - */ - primaryCurrency: PropTypes.string, - /** - * A string that represents the selected address - */ - selectedAddress: PropTypes.string, - /** - * Array of ERC20 assets - */ - tokens: PropTypes.array, - /** - * A string representing the chainId - */ - chainId: PropTypes.string, - /** - * Current provider ticker - */ - ticker: PropTypes.string, - /** - * List of tokens from TokenListController (Formatted into array) - */ - tokenList: PropTypes.array, - /** - * Object that represents the current route info like params passed to it - */ - route: PropTypes.object, - /** - * Network configurations - */ - networkConfigurations: PropTypes.object, - /** - * Network image source - */ - networkImageSource: PropTypes.string, - }; - - amountInput = React.createRef(); - searchInput = React.createRef(); - - state = { - searchInputValue: '', - results: [], - selectedAsset: undefined, - mode: MODE_SELECT, - internalPrimaryCurrency: '', - cryptoAmount: undefined, - amount: undefined, - secondaryAmount: undefined, - symbol: undefined, - showError: false, - inputWidth: { width: '99%' }, - }; - - /** - * Handle token search based on user input - * debounced by 300ms to prevent calls on every keystroke - * - * @param {string} searchInputValue - String containing assets query - */ - debouncedTokenSearch = debounce((searchInputValue) => { - const { tokenList } = this.props; - if (typeof searchInputValue !== 'string') { - searchInputValue = this.state.searchInputValue; - } - - const fuseSearchResult = fuse.search(searchInputValue); - const addressSearchResult = tokenList.filter((token) => - toLowerCaseEquals(token.address, searchInputValue), - ); - const results = [...addressSearchResult, ...fuseSearchResult]; - this.setState({ results }); - }, 300); - - updateNavBar = () => { - const { navigation, route } = this.props; - const colors = this.context.colors || mockTheme.colors; - navigation.setOptions( - getPaymentRequestOptionsTitle( - strings('payment_request.title'), - navigation, - route, - colors, - ), - ); - }; - - /** - * Set chainId, internalPrimaryCurrency and receiveAssets, if there is an asset set to this payment request chose it automatically, to state - */ - componentDidMount = () => { - const { primaryCurrency, route, tokenList } = this.props; - this.updateNavBar(); - const receiveAsset = route?.params?.receiveAsset; - this.setState({ - internalPrimaryCurrency: primaryCurrency, - inputWidth: { width: '100%' }, - }); - if (receiveAsset) { - this.goToAmountInput(receiveAsset); - } - // TODO: Fuse will only be updated once on mount. When we convert this component to hooks, we can utilize useEffect to update fuse. - // Update fuse collection with token list - fuse.setCollection(tokenList); - }; - - componentDidUpdate = () => { - this.updateNavBar(); - InteractionManager.runAfterInteractions(() => { - this.amountInput.current && this.amountInput.current.focus(); - }); - }; - - componentWillUnmount = () => { - // Cancel any pending debounced search - this.debouncedTokenSearch.cancel(); - }; - - /** - * Go to asset selection view and modify navbar accordingly - */ - goToAssetSelection = () => { - const { navigation } = this.props; - navigation && - navigation.setParams({ mode: MODE_SELECT, dispatch: undefined }); - this.setState({ - mode: MODE_SELECT, - amount: undefined, - cryptoAmount: undefined, - secondaryAmount: undefined, - symbol: undefined, - }); - }; - - /** - * Go to enter amount view, with selectedAsset and modify navbar accordingly - * - * @param {object} selectedAsset - Asset selected to build the payment request - */ - goToAmountInput = async (selectedAsset) => { - const { navigation } = this.props; - navigation && - navigation.setParams({ - mode: MODE_AMOUNT, - dispatch: this.goToAssetSelection, - }); - await this.setState({ selectedAsset, mode: MODE_AMOUNT }); - this.updateAmount(); - }; - - handleSearchTokenList = (searchInputValue) => { - if (typeof searchInputValue !== 'string') { - searchInputValue = this.state.searchInputValue; - } - this.setState({ searchInputValue }); - - this.debouncedTokenSearch(searchInputValue); - }; - - /** Clear search input and focus */ - clearSearchInput = () => { - // Cancel any pending debounced search - this.debouncedTokenSearch.cancel(); - this.setState({ searchInputValue: '', results: [] }); - this.searchInput.current?.focus?.(); - }; - - /** - * Renders a view that allows user to select assets to build the payment request - * Either top picks and user's assets are available to select - */ - renderSelectAssets = () => { - const { tokens, chainId, ticker, tokenList } = this.props; - const { inputWidth } = this.state; - let results; - const colors = this.context.colors || mockTheme.colors; - const themeAppearance = this.context.themeAppearance || 'light'; - const styles = createStyles(colors); - const isTDSupportedForNetwork = - isTokenDetectionSupportedForNetwork(chainId); - - if (isTDSupportedForNetwork) { - const defaults = - chainId === ChainId.mainnet - ? defaultAssets - : [{ ...defaultEth, symbol: getTicker(ticker), name: '' }]; - results = this.state.searchInputValue ? this.state.results : defaults; - } else if ( - //Check to see if it is not a test net ticker symbol - Object.values(ChainId).find((value) => value === chainId) && - !(parseInt(chainId, 10) > 1 && parseInt(chainId, 10) < 6) - ) { - results = [defaultEth]; - } else { - results = [{ ...defaultEth, symbol: getTicker(ticker), name: '' }]; - } - - const userTokens = tokens.map((token) => { - const contract = tokenList.find( - (contractToken) => contractToken.address === token.address, - ); - if (contract) return contract; - return token; - }); - return ( - - - - {strings('payment_request.choose_asset')} - - - {isTDSupportedForNetwork && ( - - - - {this.state.searchInputValue ? ( - - - - ) : null} - - )} - - - {this.state.searchInputValue - ? strings('payment_request.search_results') - : strings('payment_request.search_top_picks')} - - - - {userTokens.length > 0 && ( - - - {strings('payment_request.your_tokens')} - - - - )} - - ); - }; - - /** - * Handles payment request parameters for ETH as primaryCurrency - * - * @param {string} amount - String containing amount number from input, as token value - * @returns {object} - Object containing respective symbol, secondaryAmount and cryptoAmount according to amount and selectedAsset - */ - handleETHPrimaryCurrency = (amount) => { - const { conversionRate, currentCurrency, contractExchangeRates } = - this.props; - const { selectedAsset } = this.state; - let secondaryAmount; - const symbol = selectedAsset.symbol; - const undefAmount = - isDecimal(amount) && !ethersUtils.isHexString(amount) ? amount : 0; - const cryptoAmount = amount; - const exchangeRate = - selectedAsset && - selectedAsset.address && - contractExchangeRates?.[selectedAsset.address]?.price; - if (selectedAsset.symbol !== 'ETH') { - secondaryAmount = exchangeRate - ? balanceToFiat( - undefAmount, - conversionRate, - exchangeRate, - currentCurrency, - ) - : undefined; - } else { - secondaryAmount = weiToFiat( - toWei(undefAmount), - conversionRate, - currentCurrency, - ); - } - return { symbol, secondaryAmount, cryptoAmount }; - }; - - /** - * Handles payment request parameters for Fiat as primaryCurrency - * - * @param {string} amount - String containing amount number from input, as fiat value - * @returns {object} - Object containing respective symbol, secondaryAmount and cryptoAmount according to amount and selectedAsset - */ - handleFiatPrimaryCurrency = (amount) => { - const { conversionRate, currentCurrency, contractExchangeRates } = - this.props; - const { selectedAsset } = this.state; - const symbol = currentCurrency; - const exchangeRate = - selectedAsset && - selectedAsset.address && - contractExchangeRates && - contractExchangeRates[selectedAsset.address]?.price; - const undefAmount = (isDecimal(amount) && amount) || 0; - let secondaryAmount, cryptoAmount; - if (selectedAsset.symbol !== 'ETH' && exchangeRate && exchangeRate !== 0) { - const secondaryMinimalUnit = fiatNumberToTokenMinimalUnit( - undefAmount, - conversionRate, - exchangeRate, - selectedAsset.decimals, - ); - secondaryAmount = - renderFromTokenMinimalUnit( - secondaryMinimalUnit, - selectedAsset.decimals, - ) + - ' ' + - selectedAsset.symbol; - cryptoAmount = fromTokenMinimalUnit( - secondaryMinimalUnit, - selectedAsset.decimals, - ); - } else { - secondaryAmount = - renderFromWei(fiatNumberToWei(undefAmount, conversionRate)) + - ' ' + - strings('unit.eth'); - cryptoAmount = fromWei(fiatNumberToWei(undefAmount, conversionRate)); - } - return { symbol, secondaryAmount, cryptoAmount }; - }; - - /** - * Handles amount update, setting amount related state parameters, it handles state according to internalPrimaryCurrency - * - * @param {string} amount - String containing amount number from input - */ - updateAmount = (amount) => { - const { internalPrimaryCurrency, selectedAsset } = this.state; - const { conversionRate, contractExchangeRates, currentCurrency } = - this.props; - const currencySymbol = currencySymbols[currentCurrency]; - // Normalize amount: trim whitespace and replace comma with period - amount = amount?.replace(',', '.')?.trim(); - const exchangeRate = - selectedAsset && - selectedAsset.address && - contractExchangeRates && - contractExchangeRates[selectedAsset.address]?.price; - let res; - // If primary currency is not crypo we need to know if there are conversion and exchange rates to handle0, - // fiat conversion for the payment request - if ( - internalPrimaryCurrency !== 'ETH' && - conversionRate && - (exchangeRate || selectedAsset.isETH) - ) { - res = this.handleFiatPrimaryCurrency(amount); - } else { - res = this.handleETHPrimaryCurrency(amount); - } - const { cryptoAmount, symbol } = res; - if (amount && amount[0] === currencySymbol) amount = amount.substr(1); - if (res.secondaryAmount && res.secondaryAmount[0] === currencySymbol) - res.secondaryAmount = res.secondaryAmount.substr(1); - if (amount && amount === '0') amount = undefined; - this.setState({ - amount, - cryptoAmount, - secondaryAmount: res.secondaryAmount, - symbol, - showError: false, - }); - }; - - /** - * Updates internalPrimaryCurrency - */ - switchPrimaryCurrency = async () => { - const { internalPrimaryCurrency, secondaryAmount } = this.state; - const primarycurrencies = { - ETH: 'Fiat', - Fiat: 'ETH', - }; - await this.setState({ - internalPrimaryCurrency: primarycurrencies[internalPrimaryCurrency], - }); - this.updateAmount(secondaryAmount.split(' ')[0]); - }; - - /** - * Resets amount on payment request - */ - onReset = () => { - this.updateAmount(); - }; - - /** - * Generates payment request link and redirects to PaymentRequestSuccess view with it - * If there is an error, an error message will be set to display on the view - */ - onNext = () => { - const { selectedAddress, navigation, chainId } = this.props; - const { cryptoAmount, selectedAsset } = this.state; - - try { - if (cryptoAmount && cryptoAmount > '0') { - let eth_link; - if (selectedAsset.isETH) { - const amount = toWei(cryptoAmount).toString(); - eth_link = generateETHLink(selectedAddress, amount, chainId); - } else { - const amount = toTokenMinimalUnit( - cryptoAmount, - selectedAsset.decimals, - ).toString(); - eth_link = generateERC20Link( - selectedAddress, - selectedAsset.address, - amount, - chainId, - ); - } - - // Convert to universal link / app link - const link = generateUniversalLinkRequest(eth_link); - - navigation && - navigation.replace('PaymentRequestSuccess', { - link, - qrLink: eth_link, - amount: cryptoAmount, - symbol: selectedAsset.symbol, - }); - } else { - this.setState({ showError: true }); - } - } catch (e) { - this.setState({ showError: true }); - } - }; - - /** - * Renders a view that allows user to set payment request amount - */ - renderEnterAmount = () => { - const { conversionRate, contractExchangeRates, currentCurrency } = - this.props; - const { - amount, - secondaryAmount, - symbol, - cryptoAmount, - showError, - selectedAsset, - internalPrimaryCurrency, - chainId, - } = this.state; - const currencySymbol = currencySymbols[currentCurrency]; - const exchangeRate = - selectedAsset && - selectedAsset.address && - contractExchangeRates && - contractExchangeRates[selectedAsset.address]?.price; - let switchable = true; - const colors = this.context.colors || mockTheme.colors; - const themeAppearance = this.context.themeAppearance || 'light'; - const styles = createStyles(colors); - - if (!conversionRate) { - switchable = false; - } else if (selectedAsset.symbol !== 'ETH' && !exchangeRate) { - switchable = false; - } - return ( - - - - {strings('payment_request.enter_amount')} - - - - - - - - {internalPrimaryCurrency !== 'ETH' && ( - {currencySymbol} - )} - - - {symbol} - - - - {secondaryAmount && internalPrimaryCurrency === 'ETH' && ( - - {currencySymbol} - - )} - {secondaryAmount && ( - - {secondaryAmount} - - )} - - - {switchable && ( - - - - - - )} - - - {showError && ( - - - {strings('payment_request.request_error')} - - - )} - - - - - {strings('payment_request.reset')} - - - {strings('payment_request.next')} - - - - - ); - }; - - handleNetworkPickerPress = () => { - this.props.navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.NETWORK_SELECTOR, - }); - analytics.trackEvent( - AnalyticsEventBuilder.createEventBuilder( - MetaMetricsEvents.NETWORK_SELECTOR_PRESSED, - ) - .addProperties({ - chain_id: getDecimalChainId(this.props.chainId), - }) - .build(), - ); - }; - - render() { - const { mode } = this.state; - const colors = this.context.colors || mockTheme.colors; - const styles = createStyles(colors); - const networkName = - this.props.networkConfigurations?.[this.props.chainId]?.name; - const networkImageSource = this.props.networkImageSource; - - return ( - - - - - - {mode === MODE_SELECT - ? this.renderSelectAssets() - : this.renderEnterAmount()} - - - ); - } -} - -PaymentRequest.contextType = ThemeContext; - -const mapStateToProps = (state) => ({ - conversionRate: selectConversionRate(state), - currentCurrency: selectCurrentCurrency(state), - contractExchangeRates: selectContractExchangeRates(state), - searchEngine: state.settings.searchEngine, - tokens: selectTokens(state), - selectedAddress: selectSelectedInternalAccountFormattedAddress(state), - primaryCurrency: state.settings.primaryCurrency, - ticker: selectEvmTicker(state), - chainId: selectChainId(state), - tokenList: selectTokenListArray(state), - networkConfigurations: selectNetworkConfigurations(state), - networkImageSource: selectNetworkImageSource(state), -}); - -export default connect(mapStateToProps)(PaymentRequest); diff --git a/app/components/UI/PaymentRequest/index.test.tsx b/app/components/UI/PaymentRequest/index.test.tsx deleted file mode 100644 index e8804deb5ce..00000000000 --- a/app/components/UI/PaymentRequest/index.test.tsx +++ /dev/null @@ -1,814 +0,0 @@ -import React from 'react'; -import { - render, - fireEvent, - act, - userEvent, - waitFor, -} from '@testing-library/react-native'; -import PaymentRequestConnected from './index'; - -// Workaround: source is a .js file so TypeScript can't infer PropTypes; -// connect() produces a narrow type that rejects valid ownProps like chainId. -const PaymentRequest = PaymentRequestConnected as React.ComponentType< - Record ->; -import { Provider } from 'react-redux'; -import configureMockStore from 'redux-mock-store'; -import { SolScope } from '@metamask/keyring-api'; -import { ThemeContext, mockTheme } from '../../../util/theme'; -import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../util/test/accountsControllerTestUtils'; -import Routes from '../../../constants/navigation/Routes'; -import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds'; -import ethLogo from '../../../assets/images/eth-logo.png'; - -jest.mock('react', () => ({ - ...jest.requireActual('react'), - useState: jest.fn(), -})); - -const mockTrackEvent = jest.fn(); -jest.mock('../../../util/analytics/analytics', () => ({ - analytics: { - trackEvent: (event: Record) => mockTrackEvent(event), - }, -})); - -// Enable fake timers globally for this test file -jest.useFakeTimers(); - -const mockStore = configureMockStore(); - -const initialState = { - engine: { - backgroundState: { - CurrencyRateController: { - conversionRate: 1, - currentCurrency: 'USD', - }, - TokenRatesController: { - contractExchangeRates: {}, - marketData: { - '0x1': { - '0x0d8775f59023cbe76e541b6497bbed3cd21acbdc': { - price: 1, - }, - }, - }, - }, - TokensController: { - marketData: { - '0x1': { - '0x0d8775f59023cbe76e541b6497bbed3cd21acbdc': { - price: 1, - }, - }, - }, - allTokens: { - '0x1': { - '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272': [], - }, - }, - }, - NetworkController: { - provider: { - ticker: 'ETH', - chainId: '1', - }, - }, - MultichainNetworkController: { - isEvmSelected: true, - selectedMultichainNetworkChainId: SolScope.Mainnet, - - multichainNetworkConfigurationsByChainId: {}, - }, - AccountsController: { - ...MOCK_ACCOUNTS_CONTROLLER_STATE, - internalAccounts: { - ...MOCK_ACCOUNTS_CONTROLLER_STATE.internalAccounts, - selectedAccount: '30786334-3935-4563-b064-363339643939', - }, - }, - TokenListController: { - tokensChainsCache: { - '0x1': { - data: [ - { - address: '0x0d8775f59023cbe76e541b6497bbed3cd21acbdc', - symbol: 'BAT', - decimals: 18, - name: 'Basic Attention Token', - iconUrl: - 'https://assets.coingecko.com/coins/images/677/thumb/basic-attention-token.png?1547034427', - type: 'erc20', - }, - { - address: '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359', - symbol: 'SAI', - decimals: 18, - name: 'Sai Stablecoin v1.0', - iconUrl: 'sai.svg', - type: 'erc20', - }, - ], - }, - }, - }, - PreferencesController: { - ipfsGateway: {}, - }, - }, - }, - settings: { - primaryCurrency: 'ETH', - }, -}; - -let mockSetShowError: jest.Mock; -let mockShowError = false; - -beforeEach(() => { - mockTrackEvent.mockClear(); - mockSetShowError = jest.fn((value) => { - mockShowError = value; - }); - (React.useState as jest.Mock).mockImplementation((state) => [ - state, - mockSetShowError, - ]); -}); - -afterEach(() => { - act(() => { - jest.runOnlyPendingTimers(); - }); -}); - -const store = mockStore(initialState); - -const mockNavigation = { - setOptions: jest.fn(), - setParams: jest.fn(), - navigate: jest.fn(), - goBack: jest.fn(), -}; - -const mockRoute = { - params: { - dispatch: jest.fn(), - }, -}; - -const renderComponent = (props = {}) => - render( - - - - - , - ); - -describe('PaymentRequest', () => { - it('renders correctly', () => { - const { toJSON } = renderComponent(); - expect(toJSON()).toMatchSnapshot(); - }); - - it('renders correctly with network picker when feature flag is enabled', () => { - const { toJSON } = renderComponent({ - chainId: '0x1', - networkImageSource: ethLogo, - }); - expect(toJSON()).toMatchSnapshot(); - }); - - it('displays the correct title for asset selection', () => { - const { getByText } = renderComponent(); - expect(getByText('Choose an asset to request')).toBeTruthy(); - }); - - it('allows searching for assets', () => { - const { getByPlaceholderText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - fireEvent.changeText(searchInput, 'ETH'); - expect(searchInput.props.value).toBe('ETH'); - }); - - it('switches to amount input mode when an asset is selected', async () => { - const { getByText } = renderComponent({ navigation: mockNavigation }); - - await userEvent.press(getByText('ETH')); - - expect(getByText('Enter amount')).toBeTruthy(); - expect(mockNavigation.setParams).toHaveBeenCalledWith({ - mode: 'amount', - dispatch: expect.any(Function), - }); - }); - - it('updates amount when input changes', async () => { - const { getByText, getByPlaceholderText } = renderComponent(); - - // First, select an asset - await userEvent.press(getByText('ETH')); - - const amountInput = getByPlaceholderText('0.00'); - await userEvent.type(amountInput, '1.5'); - - expect(amountInput.props.value).toBe('1.5'); - }); - - it('trims leading and trailing spaces from amount input', async () => { - const { getByText, getByPlaceholderText } = renderComponent(); - - await userEvent.press(getByText('ETH')); - - const amountInput = getByPlaceholderText('0.00'); - fireEvent.changeText(amountInput, ' 1.5 '); - - expect(amountInput.props.value).toBe('1.5'); - }); - - it('handles whitespace-only input without throwing', async () => { - const { getByText, getByPlaceholderText } = renderComponent(); - - await userEvent.press(getByText('ETH')); - - const amountInput = getByPlaceholderText('0.00'); - - expect(() => { - fireEvent.changeText(amountInput, ' '); - }).not.toThrow(); - }); - - it('displays an error when an invalid amount is entered', async () => { - const { getByText, getByPlaceholderText, queryByText } = renderComponent(); - - (React.useState as jest.Mock).mockImplementation(() => [ - mockShowError, - mockSetShowError, - ]); - - mockSetShowError(true); - - await userEvent.press(getByText('ETH')); - - const amountInput = getByPlaceholderText('0.00'); - const nextButton = getByText('Next'); - - await act(async () => { - fireEvent.changeText(amountInput, '0'); - fireEvent.press(nextButton); - }); - - expect(mockSetShowError).toHaveBeenCalledWith(true); - expect(queryByText('Invalid request, please try again')).toBeTruthy(); - }); - - describe('handleNetworkPickerPress', () => { - it('should navigate to network selector modal when feature flag is enabled', () => { - const { getByTestId } = renderComponent({ - chainId: '0x1', - networkImageSource: ethLogo, - }); - - const networkPicker = getByTestId( - WalletViewSelectorsIDs.NAVBAR_NETWORK_PICKER, - ); - - act(() => { - fireEvent.press(networkPicker); - }); - - expect(mockNavigation.navigate).toHaveBeenCalledWith( - Routes.MODAL.ROOT_MODAL_FLOW, - { - screen: Routes.SHEET.NETWORK_SELECTOR, - }, - ); - }); - }); - - describe('Clear Search Input Functionality', () => { - it('clears search input and resets results when clear button is pressed', async () => { - // Given a PaymentRequest component with search input - const { getByPlaceholderText, getByText, queryByText, getByTestId } = - renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types and then presses clear button - fireEvent.changeText(searchInput, 'BAT'); - - // Wait for debounce to complete - act(() => { - jest.advanceTimersByTime(300); - }); - - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - }); - - // Find and press clear button using testID - const clearButton = getByTestId('clear-search-input-button'); - fireEvent.press(clearButton); - - // Then input should be cleared and results reset - expect(searchInput.props.value).toBe(''); - expect(getByText('Top picks')).toBeTruthy(); - expect(queryByText('BAT')).toBeNull(); - }); - - it('focuses search input after clearing', async () => { - // Given a PaymentRequest component with search input - const { getByPlaceholderText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types and clears - fireEvent.changeText(searchInput, 'BAT'); - fireEvent.changeText(searchInput, ''); - - // Then search input should maintain focus - expect(searchInput.props.value).toBe(''); - }); - }); - - describe('Component Lifecycle and Cleanup', () => { - it('cancels debounced search on component unmount', async () => { - // Given a PaymentRequest component with active search - const { getByPlaceholderText, unmount } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types and component unmounts before debounce completes - fireEvent.changeText(searchInput, 'ETH'); - - // Then unmount the component - unmount(); - - // When advancing timers after unmount - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then no errors should occur (debounced function should be cancelled) - expect(() => { - act(() => { - jest.runOnlyPendingTimers(); - }); - }).not.toThrow(); - }); - - it('initializes with correct state on mount', () => { - // Given a PaymentRequest component - const { getByText } = renderComponent(); - - // Then it should show top picks by default - expect(getByText('Top picks')).toBeTruthy(); - expect(getByText('Choose an asset to request')).toBeTruthy(); - }); - - it('handles route params with receiveAsset on mount', () => { - // Given a route with receiveAsset parameter - const mockRouteWithAsset = { - params: { - receiveAsset: { - symbol: 'ETH', - name: 'Ether', - isETH: true, - }, - }, - }; - - // When component mounts with receiveAsset - const { getByText } = renderComponent({ route: mockRouteWithAsset }); - - // Then it should switch to amount input mode - expect(getByText('Enter amount')).toBeTruthy(); - }); - }); - - describe('Search Edge Cases', () => { - it('handles search with non-string input gracefully', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types invalid input (simulating edge case) - fireEvent.changeText(searchInput, '123'); - - // Then it should handle the search without errors - act(() => { - jest.advanceTimersByTime(300); - }); - - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - }); - }); - - it('handles search with special characters', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types special characters - fireEvent.changeText(searchInput, '!@#$%'); - - // Then it should handle the search gracefully - act(() => { - jest.advanceTimersByTime(300); - }); - - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('No tokens found')).toBeTruthy(); - }); - }); - - it('handles search with very long input', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types a very long search term - const longSearchTerm = 'a'.repeat(100); - fireEvent.changeText(searchInput, longSearchTerm); - - // Then it should handle the search without performance issues - act(() => { - jest.advanceTimersByTime(300); - }); - - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('No tokens found')).toBeTruthy(); - }); - }); - }); - - describe('Network Configuration Tests', () => { - it('shows correct assets for mainnet', () => { - // Given a PaymentRequest component on mainnet - const { getByText } = renderComponent({ chainId: '0x1' }); - - // Then it should show default assets including SAI - expect(getByText('ETH')).toBeTruthy(); - expect(getByText('SAI')).toBeTruthy(); - }); - - it('shows correct assets for non-mainnet networks', () => { - // Given a PaymentRequest component on non-mainnet - const { getByText } = renderComponent({ chainId: '0x5' }); // Goerli - - // Then it should show only ETH with network-specific ticker - expect(getByText('ETH')).toBeTruthy(); - }); - - it('handles networks without token detection support', () => { - // Given a PaymentRequest component on network without token detection - const { getByText } = renderComponent({ chainId: '0x2' }); - - // Then it should show only ETH - expect(getByText('ETH')).toBeTruthy(); - }); - }); - - describe('Debounced Search Functionality', () => { - it('debounces search input to reduce excessive calls', async () => { - // Given a PaymentRequest component with search functionality - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // Initially should show top picks - expect(getByText('Top picks')).toBeTruthy(); - - // When user types rapidly - fireEvent.changeText(searchInput, 'E'); - fireEvent.changeText(searchInput, 'ET'); - fireEvent.changeText(searchInput, 'ETH'); - - // Then the input value should update immediately - expect(searchInput.props.value).toBe('ETH'); - - // The search should execute after debounce delay - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then the search should execute and show search results - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - // Should show "No tokens found" for ETH search since it's not in the test data - expect(getByText('No tokens found')).toBeTruthy(); - }); - }); - - it('cancels pending search when user clears input', async () => { - // Given a PaymentRequest component with search input - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types and then clears immediately - fireEvent.changeText(searchInput, 'ETH'); - fireEvent.changeText(searchInput, ''); - - // Then the input should be cleared immediately - expect(searchInput.props.value).toBe(''); - - // And the search should not execute even after delay - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then it should show top picks instead of search results - expect(getByText('Top picks')).toBeTruthy(); - }); - - it('updates search results after debounce delay', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // Initially should show top picks - expect(getByText('Top picks')).toBeTruthy(); - - // When user types a search term - fireEvent.changeText(searchInput, 'BAT'); - - // Then the input value should update immediately - expect(searchInput.props.value).toBe('BAT'); - - // The search should execute after debounce delay - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then search results should appear with specific token details - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - }); - - it('handles multiple rapid search inputs correctly', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types rapidly with different terms - fireEvent.changeText(searchInput, 'E'); - fireEvent.changeText(searchInput, 'ET'); - fireEvent.changeText(searchInput, 'ETH'); - - // Then only the final search should execute - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then it should search for the final term - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - // Should show "No tokens found" for ETH search since it's not in the test data - expect(getByText('No tokens found')).toBeTruthy(); - }); - }, 10000); - - it('cancels debounced search on component unmount', async () => { - // Given a PaymentRequest component with active search - const { getByPlaceholderText, unmount } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types and component unmounts before debounce completes - fireEvent.changeText(searchInput, 'ETH'); - - // Then unmount the component - unmount(); - - // When advancing timers after unmount - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then no errors should occur (debounced function should be cancelled) - expect(() => { - act(() => { - jest.runOnlyPendingTimers(); - }); - }).not.toThrow(); - }); - - it('handles empty search input correctly', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types and then clears to empty - fireEvent.changeText(searchInput, 'ETH'); - fireEvent.changeText(searchInput, ''); - - // Then it should show top picks immediately - expect(getByText('Top picks')).toBeTruthy(); - - // And should not show search results after delay - act(() => { - jest.advanceTimersByTime(300); - }); - - expect(getByText('Top picks')).toBeTruthy(); - }); - - it('debounces handleSearchTokenList calls', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types and submits (which calls handleSearchTokenList) - fireEvent.changeText(searchInput, 'BAT'); - fireEvent(searchInput, 'submitEditing'); - - // Then the search should not execute immediately - expect(searchInput.props.value).toBe('BAT'); - - // When the debounce delay passes - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then the search should execute with specific token details - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - }); - - it('filters search results based on different search terms', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText, queryByText } = - renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When searching for token symbol - fireEvent.changeText(searchInput, 'BAT'); - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should find BAT token - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - - // When searching for token name - fireEvent.changeText(searchInput, 'Basic Attention'); - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should still find BAT token - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - - // When searching for non-existent token - fireEvent.changeText(searchInput, 'NONEXISTENT'); - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should show no results - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('No tokens found')).toBeTruthy(); - expect(queryByText('BAT')).toBeNull(); - }); - }); - - it('shows correct search results for partial matches', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When searching with partial symbol - fireEvent.changeText(searchInput, 'BA'); - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should find BAT token - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - - // When searching with partial name - fireEvent.changeText(searchInput, 'Basic'); - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should still find BAT token - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - }); - - it('maintains search results state during rapid typing', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText, queryByText } = - renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When typing rapidly with valid search terms - fireEvent.changeText(searchInput, 'B'); - fireEvent.changeText(searchInput, 'BA'); - fireEvent.changeText(searchInput, 'BAT'); - - // Then should not show results immediately - expect(queryByText('BAT')).toBeNull(); - - // When debounce delay passes - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should show final search results - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - - // When typing rapidly with invalid then valid terms - fireEvent.changeText(searchInput, 'INVALID'); - fireEvent.changeText(searchInput, 'BAT'); - - // When debounce delay passes - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should show valid results - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - }); - - it('handles address-based search correctly', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When searching by token address - fireEvent.changeText( - searchInput, - '0x0d8775f59023cbe76e541b6497bbed3cd21acbdc', - ); - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should find BAT token by address - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - }); - - it('handles case-insensitive search', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When searching with different cases - fireEvent.changeText(searchInput, 'bat'); - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should find BAT token regardless of case - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - }); - }); -}); diff --git a/app/components/UI/PaymentRequestSuccess/index.js b/app/components/UI/PaymentRequestSuccess/index.js deleted file mode 100644 index cc861fb083a..00000000000 --- a/app/components/UI/PaymentRequestSuccess/index.js +++ /dev/null @@ -1,421 +0,0 @@ -import React, { PureComponent } from 'react'; -import { - Dimensions, - SafeAreaView, - View, - ScrollView, - Text, - StyleSheet, - InteractionManager, - TouchableOpacity, - Platform, -} from 'react-native'; -import { connect } from 'react-redux'; -import { fontStyles } from '../../../styles/common'; -import { getPaymentRequestSuccessOptionsTitle } from '../../UI/Navbar'; -import PropTypes from 'prop-types'; -import EvilIcons from 'react-native-vector-icons/EvilIcons'; -import StyledButton from '../StyledButton'; -import FontAwesome from 'react-native-vector-icons/FontAwesome'; -import IonicIcon from 'react-native-vector-icons/Ionicons'; -import { showAlert } from '../../../actions/alert'; -import Logger from '../../../util/Logger'; -import Share from 'react-native-share'; // eslint-disable-line import/default -import Modal from 'react-native-modal'; -import QRCode from 'react-native-qrcode-svg'; -import { renderNumber } from '../../../util/number'; -import Device from '../../../util/device'; -import { strings } from '../../../../locales/i18n'; -import { protectWalletModalVisible } from '../../../actions/user'; -import ClipboardManager from '../../../core/ClipboardManager'; -import { ThemeContext, mockTheme } from '../../../util/theme'; -import generateTestId from '../../../../wdio/utils/generateTestId'; -import { SendLinkViewSelectorsIDs } from '../ReceiveRequest/SendLinkView.testIds'; - -const isIos = Device.isIos(); - -const createStyles = (theme) => - StyleSheet.create({ - wrapper: { - backgroundColor: theme.colors.background.default, - flex: 1, - }, - contentWrapper: { - padding: 24, - }, - button: { - marginBottom: 16, - }, - titleText: { - ...fontStyles.bold, - fontSize: 24, - marginVertical: 16, - alignSelf: 'center', - color: theme.colors.text.default, - }, - descriptionText: { - ...fontStyles.normal, - fontSize: 14, - alignSelf: 'center', - textAlign: 'center', - marginVertical: 8, - color: theme.colors.text.default, - }, - linkText: { - ...fontStyles.normal, - fontSize: 14, - color: theme.colors.primary.default, - alignSelf: 'center', - textAlign: 'center', - marginVertical: 16, - }, - buttonsWrapper: { - flex: 1, - flexDirection: 'row', - alignSelf: 'center', - }, - buttonsContainer: { - flex: 1, - flexDirection: 'column', - alignSelf: 'flex-end', - }, - scrollViewContainer: { - flexGrow: 1, - }, - icon: { - color: theme.colors.primary.default, - marginBottom: 16, - }, - blueIcon: { - color: theme.colors.primary.inverse, - }, - iconWrapper: { - alignItems: 'center', - }, - buttonText: { - ...fontStyles.bold, - color: theme.colors.primary.default, - fontSize: 14, - marginLeft: 8, - }, - blueButtonText: { - ...fontStyles.bold, - color: theme.colors.primary.inverse, - fontSize: 14, - marginLeft: 8, - }, - buttonContent: { - flexDirection: 'row', - alignSelf: 'center', - }, - buttonIconWrapper: { - flexDirection: 'column', - alignSelf: 'center', - }, - buttonTextWrapper: { - flexDirection: 'column', - alignSelf: 'center', - }, - detailsWrapper: { - padding: 10, - alignItems: 'center', - }, - addressTitle: { - fontSize: 16, - ...fontStyles.normal, - color: theme.colors.text.default, - }, - informationWrapper: { - paddingHorizontal: 40, - }, - linkWrapper: { - paddingHorizontal: 24, - }, - titleQr: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: isIos ? 8 : 10, - }, - closeIcon: { - right: isIos ? -20 : -40, - alignItems: 'center', - paddingHorizontal: 10, - }, - qrCode: { - marginBottom: 16, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 36, - paddingBottom: 24, - paddingTop: 16, - backgroundColor: theme.colors.background.default, - borderRadius: 8, - }, - qrCodeWrapper: { - marginVertical: 8, - padding: 8, - backgroundColor: theme.brandColors.white, - }, - }); - -/** - * View to interact with a previously generated payment request link - */ -class PaymentRequestSuccess extends PureComponent { - static propTypes = { - /** - * Navigation object - */ - navigation: PropTypes.object, - /** - * Object that represents the current route info like params passed to it - */ - route: PropTypes.object, - /** - /* Triggers global alert - */ - showAlert: PropTypes.func, - /** - /* Prompts protect wallet modal - */ - protectWalletModalVisible: PropTypes.func, - }; - - state = { - link: '', - qrLink: '', - amount: '', - symbol: '', - qrModalVisible: false, - }; - - updateNavBar = () => { - const { navigation } = this.props; - const colors = this.context.colors || mockTheme.colors; - navigation.setOptions( - getPaymentRequestSuccessOptionsTitle(navigation, colors), - ); - }; - - /** - * Sets payment request link, amount and symbol of the asset to state - */ - componentDidMount = () => { - const { route } = this.props; - this.updateNavBar(); - const link = route?.params?.link ?? ''; - const qrLink = route?.params?.qrLink ?? ''; - const amount = route?.params?.amount ?? ''; - const symbol = route?.params?.symbol ?? ''; - this.setState({ link, qrLink, amount, symbol }); - }; - - componentDidUpdate = () => { - this.updateNavBar(); - }; - - componentWillUnmount = () => { - this.props.protectWalletModalVisible(); - }; - - /** - * Copies payment request link to clipboard - */ - copyAccountToClipboard = async () => { - const { link } = this.state; - await ClipboardManager.setString(link); - InteractionManager.runAfterInteractions(() => { - this.props.showAlert({ - isVisible: true, - autodismiss: 1500, - content: 'clipboard-alert', - data: { msg: strings('payment_request.link_copied') }, - }); - }); - }; - - /** - * Shows share native UI - */ - onShare = () => { - const { link } = this.state; - Share.open({ - message: link, - }).catch((err) => { - Logger.log('Error while trying to share payment request', err); - }); - }; - - /** - * Toggles payment request QR code modal on top - */ - showQRModal = () => { - this.setState({ qrModalVisible: true }); - }; - - /** - * Closes payment request QR code modal - */ - closeQRModal = () => { - this.setState({ qrModalVisible: false }); - }; - - render() { - const { link, amount, symbol, qrModalVisible } = this.state; - const theme = this.context || mockTheme; - const colors = theme.colors; - const styles = createStyles(theme); - - return ( - - - - - - - - {strings('payment_request.send_link')} - - - {strings('payment_request.description_1')} - - - {strings('payment_request.description_2')} - - {' ' + renderNumber(amount) + ' ' + symbol} - - - - - {link} - - - - - - - - - - - - {strings('payment_request.copy_to_clipboard')} - - - - - - - - - - - - {strings('payment_request.qr_code')} - - - - - - - - - - - - {strings('payment_request.send_link')} - - - - - - - - - - - - - {strings('payment_request.request_qr_code')} - - - - - - - - - - - - - - ); - } -} - -PaymentRequestSuccess.contextType = ThemeContext; - -const mapDispatchToProps = (dispatch) => ({ - showAlert: (config) => dispatch(showAlert(config)), - protectWalletModalVisible: () => dispatch(protectWalletModalVisible()), -}); - -export default connect(null, mapDispatchToProps)(PaymentRequestSuccess); diff --git a/app/components/UI/Perps/Perps.testIds.ts b/app/components/UI/Perps/Perps.testIds.ts index 551e40f89eb..5fe6aa9fe42 100644 --- a/app/components/UI/Perps/Perps.testIds.ts +++ b/app/components/UI/Perps/Perps.testIds.ts @@ -357,6 +357,7 @@ export const PerpsMarketDetailsViewSelectorsIDs = { MARKET_HOURS_BOTTOM_SHEET_TOOLTIP: 'perps-market-details-market-hours-bottom-sheet-tooltip', STOP_LOSS_PROMPT_BANNER: 'perps-market-details-stop-loss-prompt-banner', + TITLE_SECTION_WRAPPER: 'perps-market-details-title-section-wrapper', }; // ======================================== @@ -370,6 +371,8 @@ export const PerpsMarketHeaderSelectorsIDs = { ASSET_NAME: 'perps-market-header-asset-name', PRICE: 'perps-market-header-price', PRICE_CHANGE: 'perps-market-header-price-change', + PRICE_TITLE_SECTION: 'perps-market-header-price-title-section', + PRICE_CHANGE_TITLE_SECTION: 'perps-market-header-price-change-title-section', MORE_BUTTON: 'perps-market-header-more-button', }; diff --git a/app/components/UI/Perps/Views/PerpsActiveTraderFlow.view.test.tsx b/app/components/UI/Perps/Views/PerpsActiveTraderFlow.view.test.tsx index cbdc19b6f32..b58a8cccdde 100644 --- a/app/components/UI/Perps/Views/PerpsActiveTraderFlow.view.test.tsx +++ b/app/components/UI/Perps/Views/PerpsActiveTraderFlow.view.test.tsx @@ -277,7 +277,7 @@ describe('Active Trader Flow', () => { ).toBeOnTheScreen(); expect(screen.queryAllByText(MARKET_ORDERS)).toHaveLength(0); expect( - screen.getByTestId(PerpsMarketTabsSelectorsIDs.STATISTICS_CONTENT), + await screen.findByTestId(PerpsMarketTabsSelectorsIDs.STATISTICS_CONTENT), ).toBeOnTheScreen(); // ── PHASE 2: Review individual order rows ──────────────────────────── diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx index b72dfae9857..7fa9bf72aea 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx @@ -86,7 +86,10 @@ const PerpsClosePositionView: React.FC = () => { const navigation = useNavigation(); const route = useRoute>(); - const { position } = route.params as { position: Position }; + const { position, source: routeSource } = route.params as { + position: Position; + source?: string; + }; const inputMethodRef = useRef('default'); const isAmountInitializedRef = useRef(false); @@ -392,6 +395,7 @@ const PerpsClosePositionView: React.FC = () => { metamaskFee: feeResults.metamaskFee, estimatedPoints: rewardsState.estimatedPoints, inputMethod: inputMethodRef.current, + source: routeSource, }, marketPrice: priceData[position.symbol]?.price, // Always pass slippage parameters for price context diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx index e89f77d9c63..ad53a4c5e89 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx @@ -230,6 +230,7 @@ jest.mock('@metamask/perps-controller', () => ({ SOURCE: { MAIN_ACTION_BUTTON: 'main_action_button', HOMESCREEN_TAB: 'homescreen_tab', + PERPS_HOME: 'perps_home', }, BUTTON_LOCATION: { PERPS_HOME: 'perps_home', @@ -558,7 +559,7 @@ describe('PerpsHomeView', () => { expect(mockNavigateToMarketList).toHaveBeenCalledWith({ defaultMarketTypeFilter: 'all', - source: PERPS_EVENT_VALUE.SOURCE.HOMESCREEN_TAB, + source: PERPS_EVENT_VALUE.SOURCE.PERPS_HOME, fromHome: true, button_clicked: 'magnifying_glass', button_location: 'perps_home', diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx index 2fb3cf8d900..47b45f0e55b 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx @@ -210,6 +210,8 @@ const PerpsHomeView = () => { PERPS_EVENT_VALUE.SCREEN_TYPE.PERPS_HOME, [PERPS_EVENT_PROPERTY.SOURCE]: source, [PERPS_EVENT_PROPERTY.HAS_PERP_BALANCE]: hasPerpBalance, + [PERPS_EVENT_PROPERTY.OPEN_POSITION]: livePositions.positions.length, + [PERPS_EVENT_PROPERTY.OPEN_ORDER]: orders?.length || 0, ...(buttonClicked && { [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: buttonClicked, }), @@ -235,7 +237,7 @@ const PerpsHomeView = () => { ); perpsNavigation.navigateToMarketList({ defaultMarketTypeFilter: 'all', - source: PERPS_EVENT_VALUE.SOURCE.HOMESCREEN_TAB, + source: PERPS_EVENT_VALUE.SOURCE.PERPS_HOME, fromHome: true, button_clicked: PERPS_EVENT_VALUE.BUTTON_CLICKED.MAGNIFYING_GLASS, button_location: PERPS_EVENT_VALUE.BUTTON_LOCATION.PERPS_HOME, @@ -257,7 +259,7 @@ const PerpsHomeView = () => { .build(), ); navigation.navigate(Routes.PERPS.TUTORIAL, { - source: PERPS_EVENT_VALUE.SOURCE.HOMESCREEN_TAB, + source: PERPS_EVENT_VALUE.SOURCE.PERPS_HOME, }); }, [navigation, trackEvent, createEventBuilder]); @@ -445,7 +447,7 @@ const PerpsHomeView = () => { ))} @@ -465,7 +467,7 @@ const PerpsHomeView = () => { ))} @@ -477,6 +479,7 @@ const PerpsHomeView = () => { isLoading={isLoading.markets} positions={positions} orders={orders} + source={PERPS_EVENT_VALUE.SOURCE.PERPS_HOME} /> {/* Crypto Markets List */} @@ -487,6 +490,7 @@ const PerpsHomeView = () => { marketType="crypto" sortBy={sortBy} isLoading={isLoading.markets} + source={PERPS_EVENT_VALUE.SOURCE.PERPS_HOME} /> @@ -497,6 +501,7 @@ const PerpsHomeView = () => { marketType="commodities" sortBy={sortBy} isLoading={isLoading.markets} + source={PERPS_EVENT_VALUE.SOURCE.PERPS_HOME} /> {/* Stocks Markets List */} @@ -507,6 +512,7 @@ const PerpsHomeView = () => { marketType="stocks" sortBy={sortBy} isLoading={isLoading.markets} + source={PERPS_EVENT_VALUE.SOURCE.PERPS_HOME} /> @@ -516,6 +522,7 @@ const PerpsHomeView = () => { markets={forexMarkets} marketType="forex" isLoading={isLoading.markets} + source={PERPS_EVENT_VALUE.SOURCE.PERPS_HOME} /> {/* Recent Activity List */} diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 34c15b6f61c..28bd6e07d45 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -10,6 +10,8 @@ import { import { PerpsConnectionProvider } from '../../providers/PerpsConnectionProvider'; import { useDefaultPayWithTokenWhenNoPerpsBalance } from '../../hooks/useDefaultPayWithTokenWhenNoPerpsBalance'; import { Linking } from 'react-native'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import Routes from '../../../../../constants/navigation/Routes'; // Mock Linking jest.mock('react-native/Libraries/Linking/Linking', () => ({ @@ -394,6 +396,34 @@ jest.mock('../../hooks/usePerpsEventTracking', () => ({ })), })); +const mockUseMarketInsights = jest.fn( + (_assetId?: string | null, _isEnabled?: boolean) => ({ + report: null as Record | null, + isLoading: false, + error: null, + timeAgo: '', + }), +); + +jest.mock('../../../MarketInsights', () => ({ + useMarketInsights: (assetId: string | null | undefined, isEnabled: boolean) => + mockUseMarketInsights(assetId, isEnabled), + MarketInsightsEntryCard: ({ onPress }: { onPress: () => void }) => { + const { TouchableOpacity } = jest.requireActual('react-native'); + return ( + + ); + }, + selectMarketInsightsEnabled: jest.fn(), +})); + +jest.mock( + '../../../../../selectors/featureFlagController/marketInsights', + () => ({ + selectMarketInsightsPerpsEnabled: jest.fn(), + }), +); + jest.mock('../../hooks/usePerpsPrices', () => ({ usePerpsPrices: jest.fn(() => ({})), })); @@ -1423,7 +1453,7 @@ describe('PerpsMarketDetailsView', () => { expect(mockNavigateToOrder).toHaveBeenCalledWith({ direction: 'long', asset: 'BTC', - source: 'trade_action', + source: 'perp_asset_screen', }); }); @@ -1459,7 +1489,7 @@ describe('PerpsMarketDetailsView', () => { expect(mockNavigateToOrder).toHaveBeenCalledWith({ direction: 'short', asset: 'BTC', - source: 'trade_action', + source: 'perp_asset_screen', }); }); @@ -3172,7 +3202,7 @@ describe('PerpsMarketDetailsView', () => { isRefreshing: false, }); - const { getByText } = renderWithProvider( + const { getByText, getAllByText } = renderWithProvider( , @@ -3183,7 +3213,7 @@ describe('PerpsMarketDetailsView', () => { // Should show the route market's leverage badge expect(getByText('25x')).toBeOnTheScreen(); - expect(getByText('ETH-USD')).toBeOnTheScreen(); + expect(getAllByText('ETH-USD').length).toBeGreaterThanOrEqual(1); }); it('enriches market data from usePerpsMarkets when route has minimal data', async () => { @@ -3213,7 +3243,7 @@ describe('PerpsMarketDetailsView', () => { isRefreshing: false, })); - const { getByText, getByTestId } = renderWithProvider( + const { getByText, getByTestId, getAllByText } = renderWithProvider( , @@ -3224,7 +3254,7 @@ describe('PerpsMarketDetailsView', () => { // Verify the header renders with correct market symbol expect(getByTestId('perps-market-header')).toBeOnTheScreen(); - expect(getByText('BTC-USD')).toBeOnTheScreen(); + expect(getAllByText('BTC-USD').length).toBeGreaterThanOrEqual(1); // Should show the enriched market's leverage badge from usePerpsMarkets await waitFor(() => { @@ -3248,7 +3278,7 @@ describe('PerpsMarketDetailsView', () => { isRefreshing: false, }); - const { getByText, queryByText } = renderWithProvider( + const { getAllByText, queryByText } = renderWithProvider( , @@ -3258,10 +3288,140 @@ describe('PerpsMarketDetailsView', () => { ); // Should show the asset name but no leverage badge (since no maxLeverage available) - expect(getByText('UNKNOWN-USD')).toBeOnTheScreen(); + expect(getAllByText('UNKNOWN-USD').length).toBeGreaterThanOrEqual(1); // No leverage badge should be shown expect(queryByText('40x')).toBeNull(); expect(queryByText('25x')).toBeNull(); }); }); + + describe('Market Insights analytics', () => { + const mockReport = { + summary: 'BTC momentum is building with increased buying pressure.', + sentiment: 'bullish', + generatedAt: new Date().toISOString(), + }; + + // Stable track mock reference set up in beforeEach via mockImplementation + const mockTrack = jest.fn(); + + beforeEach(() => { + // Override usePerpsEventTracking to expose a capturable track mock + const { usePerpsEventTracking: mockUsePerpsEventTrackingFn } = + jest.requireMock('../../hooks/usePerpsEventTracking'); + mockUsePerpsEventTrackingFn.mockImplementation(() => ({ + track: mockTrack, + })); + + // Enable perps market insights feature flag + const { useSelector } = jest.requireMock('react-redux'); + const { selectPerpsEligibility } = jest.requireMock( + '../../selectors/perpsController', + ); + const { selectMarketInsightsPerpsEnabled } = jest.requireMock( + '../../../../../selectors/featureFlagController/marketInsights', + ); + useSelector.mockImplementation((selector: unknown) => { + if (selector === selectPerpsEligibility) return true; + if (selector === selectMarketInsightsPerpsEnabled) return true; + return undefined; + }); + + // Default: a report is available and loading is complete + mockUseMarketInsights.mockReturnValue({ + report: mockReport, + isLoading: false, + error: null, + timeAgo: '5m ago', + }); + }); + + afterEach(() => { + mockTrack.mockClear(); + }); + + it('fires MARKET_INSIGHTS_OPENED with perps_market when entry card is pressed', () => { + const { getByTestId } = renderWithProvider( + + + , + { state: initialState }, + ); + + fireEvent.press(getByTestId('market-insights-entry-card')); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.MARKET_INSIGHTS_OPENED, + expect.objectContaining({ perps_market: 'BTC' }), + ); + }); + + it('navigates to MarketInsightsView with isPerps flag when entry card is pressed', () => { + const { getByTestId } = renderWithProvider( + + + , + { state: initialState }, + ); + + fireEvent.press(getByTestId('market-insights-entry-card')); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MARKET_INSIGHTS.VIEW, + expect.objectContaining({ + assetIdentifier: 'BTC', + isPerps: true, + }), + ); + }); + + it('passes market_insights_displayed: true to PERPS_SCREEN_VIEWED when a report is available', () => { + renderWithProvider( + + + , + { state: initialState }, + ); + + const { usePerpsEventTracking: mockUsePerpsEventTrackingFn } = + jest.requireMock('../../hooks/usePerpsEventTracking'); + + expect(mockUsePerpsEventTrackingFn).toHaveBeenCalledWith( + expect.objectContaining({ + eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, + properties: expect.objectContaining({ + market_insights_displayed: true, + }), + }), + ); + }); + + it('passes market_insights_displayed: false to PERPS_SCREEN_VIEWED when no report is returned', () => { + mockUseMarketInsights.mockReturnValue({ + report: null, + isLoading: false, + error: null, + timeAgo: '', + }); + + renderWithProvider( + + + , + { state: initialState }, + ); + + const { usePerpsEventTracking: mockUsePerpsEventTrackingFn } = + jest.requireMock('../../hooks/usePerpsEventTracking'); + + expect(mockUsePerpsEventTrackingFn).toHaveBeenCalledWith( + expect.objectContaining({ + eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, + properties: expect.objectContaining({ + market_insights_displayed: false, + }), + }), + ); + }); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 55f164cf007..53109f2b54b 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -1,4 +1,8 @@ -import { ButtonSize as ButtonSizeRNDesignSystem } from '@metamask/design-system-react-native'; +import { + Box, + ButtonSize as ButtonSizeRNDesignSystem, + IconName, +} from '@metamask/design-system-react-native'; import { useNavigation, useRoute, @@ -11,13 +15,15 @@ import React, { useRef, useState, } from 'react'; -import { Linking, RefreshControl, ScrollView, View } from 'react-native'; +import { Linking, RefreshControl, View } from 'react-native'; +import Animated from 'react-native-reanimated'; import { CandlePeriod, TimeDuration, PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, PERPS_CONSTANTS, + getPerpsDisplaySymbol, type Position, type PerpsMarketData, type TPSLTrackingData, @@ -34,7 +40,7 @@ import Button, { ButtonVariants, ButtonWidthTypes, } from '../../../../../component-library/components/Buttons/Button'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import Text, { TextColor, TextVariant, @@ -44,10 +50,9 @@ import Routes from '../../../../../constants/navigation/Routes'; import Engine from '../../../../../core/Engine'; import Logger from '../../../../../util/Logger'; import { isNotificationsFeatureEnabled } from '../../../../../util/notifications'; -import { TraceName } from '../../../../../util/trace'; +import { trace, TraceName, TraceOperation } from '../../../../../util/trace'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import ComponentErrorBoundary from '../../../ComponentErrorBoundary'; -import { getPerpsMarketDetailsNavbar } from '../../../Navbar'; import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip'; import type { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types'; import PerpsCandlePeriodBottomSheet from '../../components/PerpsCandlePeriodBottomSheet'; @@ -57,11 +62,17 @@ import PerpsCompactOrderRow from '../../components/PerpsCompactOrderRow'; import PerpsFlipPositionConfirmSheet from '../../components/PerpsFlipPositionConfirmSheet'; import { PerpsMarketDetailsViewSelectorsIDs, + PerpsMarketHeaderSelectorsIDs, PerpsOrderViewSelectorsIDs, PerpsTutorialSelectorsIDs, } from '../../Perps.testIds'; -import PerpsMarketHeader from '../../components/PerpsMarketHeader'; +import HeaderStandardAnimated from '../../../../../component-library/components-temp/HeaderStandardAnimated'; +import useHeaderStandardAnimated from '../../../../../component-library/components-temp/HeaderStandardAnimated/useHeaderStandardAnimated'; +import TitleSubpage from '../../../../../component-library/components-temp/TitleSubpage'; +import LivePriceHeader from '../../components/LivePriceDisplay/LivePriceHeader'; +import PerpsLeverage from '../../components/PerpsLeverage/PerpsLeverage'; import PerpsMarketHoursBanner from '../../components/PerpsMarketHoursBanner'; +import PerpsTokenLogo from '../../components/PerpsTokenLogo'; import PerpsMarketStatisticsCard from '../../components/PerpsMarketStatisticsCard'; import PerpsMarketTradesList from '../../components/PerpsMarketTradesList'; import PerpsNavigationCard, { @@ -111,6 +122,11 @@ import { selectPerpsButtonColorTestVariant, selectPerpsOrderBookEnabledFlag, } from '../../selectors/featureFlags'; +import { + MarketInsightsEntryCard, + useMarketInsights, +} from '../../../MarketInsights'; +import { selectMarketInsightsPerpsEnabled } from '../../../../../selectors/featureFlagController/marketInsights'; import { createSelectIsWatchlistMarket, selectPerpsEligibility, @@ -211,6 +227,14 @@ const PerpsMarketDetailsView: React.FC = () => { // Feature flag for Order Book visibility const isOrderBookEnabled = useSelector(selectPerpsOrderBookEnabledFlag); + // Feature flag for Market Insights in Perps + const isPerpsInsightsEnabled = useSelector(selectMarketInsightsPerpsEnabled); + const { + report: perpsInsightsReport, + timeAgo: perpsInsightsTimeAgo, + isLoading: isPerpsInsightsLoading, + } = useMarketInsights(market?.symbol, isPerpsInsightsEnabled); + // Check if current market is in watchlist const selectIsWatchlist = useMemo( () => createSelectIsWatchlistMarket(market?.symbol || ''), @@ -244,14 +268,12 @@ const PerpsMarketDetailsView: React.FC = () => { } }, [isWatchlistFromRedux, optimisticWatchlist]); - // Set navigation header with proper back button - useEffect(() => { - if (market) { - navigation.setOptions( - getPerpsMarketDetailsNavbar(navigation, market.symbol), - ); - } - }, [navigation, market]); + const { + scrollY: scrollYShared, + onScroll, + setTitleSectionHeight, + titleSectionHeightSv, + } = useHeaderStandardAnimated(); // Get persisted candle period preference from Redux store const selectedCandlePeriod = useSelector( @@ -387,7 +409,6 @@ const PerpsMarketDetailsView: React.FC = () => { }, [candleData, selectedCandlePeriod, visibleCandleCount]); // Check if user has an existing position for this market - // Also provides positionOpenedTimestamp for stop loss prompt timing const { isLoading: isLoadingPosition, existingPosition, @@ -524,6 +545,8 @@ const PerpsMarketDetailsView: React.FC = () => { }); // Track asset screen viewed event - declarative (main's event name) + // Waits for market insights to finish loading so market_insights_displayed + // reflects the actual display state rather than a loading-time snapshot. usePerpsEventTracking({ eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, conditions: [ @@ -531,6 +554,7 @@ const PerpsMarketDetailsView: React.FC = () => { !!marketStats, !isLoadingHistory, !isLoadingPosition, + !isPerpsInsightsLoading, ], properties: { [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: @@ -539,6 +563,9 @@ const PerpsMarketDetailsView: React.FC = () => { [PERPS_EVENT_PROPERTY.SOURCE]: source || PERPS_EVENT_VALUE.SOURCE.PERP_MARKETS, [PERPS_EVENT_PROPERTY.OPEN_POSITION]: existingPosition ? 1 : 0, + [PERPS_EVENT_PROPERTY.OPEN_ORDER]: openOrders.length, + market_insights_displayed: + isPerpsInsightsEnabled && Boolean(perpsInsightsReport), // A/B Test context (TAT-1937) - for baseline exposure tracking ...(isButtonColorTestEnabled && { [PERPS_EVENT_PROPERTY.AB_TEST_BUTTON_COLOR]: buttonColorVariant, @@ -602,7 +629,7 @@ const PerpsMarketDetailsView: React.FC = () => { navigateBack(); } else { // Fallback to markets list if no previous screen - navigateToHome(source); + navigateToHome(PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN); } }; @@ -682,7 +709,7 @@ const PerpsMarketDetailsView: React.FC = () => { navigateToOrder({ direction, asset: market.symbol, - source: PERPS_EVENT_VALUE.SOURCE.TRADE_ACTION, + source: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, }); }, [ @@ -847,7 +874,10 @@ const PerpsMarketDetailsView: React.FC = () => { return; } - navigateToClosePosition(existingPosition); + navigateToClosePosition( + existingPosition, + PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + ); }, [existingPosition, navigateToClosePosition, isEligible, track]); // Modify position handler - opens the modify action sheet @@ -996,6 +1026,24 @@ const PerpsMarketDetailsView: React.FC = () => { handleBannerDismissComplete(); }, [handleBannerDismissComplete]); + // Handler for market insights card tap - navigates to full market insights view + const handleMarketInsightsPress = useCallback(() => { + if (!market?.symbol) return; + track(MetaMetricsEvents.MARKET_INSIGHTS_OPENED, { + perps_market: market.symbol, + }); + trace({ + name: TraceName.MarketInsightsViewLoad, + op: TraceOperation.MarketInsightsLoad, + }); + navigation.navigate(Routes.MARKET_INSIGHTS.VIEW, { + assetSymbol: market.symbol, + assetIdentifier: market.symbol, + isPerps: true, + hasPerpsPosition: !!existingPosition, + }); + }, [market?.symbol, navigation, track, existingPosition]); + // Handler for order selection - navigates to order details const handleOrderSelect = useCallback( (order: (typeof displayOrders)[number]) => { @@ -1085,35 +1133,91 @@ const PerpsMarketDetailsView: React.FC = () => { const shouldShowLongShortButtonsOnly = shouldShowNewPositionActions && !showAddFundsCTA; + const displayTitle = `${getPerpsDisplaySymbol(market.symbol)}-USD`; + return ( - {/* Fixed Header Section */} - - - + + } + onBack={handleBackPress} + backButtonProps={{ + onPress: handleBackPress, + testID: PerpsMarketHeaderSelectorsIDs.BACK_BUTTON, + }} + endButtonIconProps={[ + { + iconName: IconName.Expand, + onPress: handleFullscreenChartOpen, + testID: `${PerpsMarketDetailsViewSelectorsIDs.HEADER}-fullscreen-button`, + }, + { + iconName: isWatchlist ? IconName.StarFilled : IconName.Star, + onPress: handleWatchlistPress, + testID: PerpsMarketHeaderSelectorsIDs.MORE_BUTTON, + }, + ]} + testID={PerpsMarketDetailsViewSelectorsIDs.HEADER} + /> - {/* Scrollable Content Container */} - } > + setTitleSectionHeight(e.nativeEvent.layout.height)} + > + + } + title={displayTitle} + titleAccessory={ + market.maxLeverage ? ( + + + + ) : undefined + } + bottomAccessory={ + + } + twClassName="px-4 pt-1 pb-3" + /> + + {/* TradingView Chart Section */} = () => { )} + {/* Market Insights Section - shown when perps insights flag is enabled and a report is available */} + {isPerpsInsightsEnabled && perpsInsightsReport && market?.symbol ? ( + + ) : null} + {/* Statistics Section - Always shown */} = () => { - + {/* Fixed Actions Footer */} @@ -1429,6 +1542,7 @@ const PerpsMarketDetailsView: React.FC = () => { onClose={handleTooltipClose} contentKey={selectedTooltip} testID={PerpsMarketDetailsViewSelectorsIDs.BOTTOM_SHEET_TOOLTIP} + buttonLocation={PERPS_EVENT_VALUE.BUTTON_LOCATION.PERP_MARKET_DETAILS} /> )} diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.view.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.view.test.tsx index 8462b746e9d..df5aa041207 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.view.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.view.test.tsx @@ -275,6 +275,89 @@ describe('PerpsMarketDetailsView', () => { ).toBeOnTheScreen(); }); + it('renders header and title section with market title', async () => { + renderPerpsMarketDetailsView({ + streamOverrides: { positions: [] }, + overrides: { + engine: { + backgroundState: { + PerpsController: { isEligible: true }, + }, + }, + }, + }); + + expect( + await screen.findByTestId(PerpsMarketDetailsViewSelectorsIDs.HEADER), + ).toBeOnTheScreen(); + expect(screen.getAllByText('ETH-USD').length).toBeGreaterThanOrEqual(1); + }); + + it('renders title section with price when market has no maxLeverage', async () => { + renderPerpsMarketDetailsView({ + initialParams: { + market: { + symbol: 'ETH', + name: 'Ethereum', + price: '$2,000', + change24h: '$0', + change24hPercent: '0%', + volume: '$1M', + }, + }, + streamOverrides: { + positions: [], + marketData: [{ symbol: 'BTC', name: 'Bitcoin', maxLeverage: '50x' }], + }, + overrides: { + engine: { + backgroundState: { + PerpsController: { isEligible: true }, + }, + }, + }, + }); + + expect( + await screen.findByTestId(PerpsMarketDetailsViewSelectorsIDs.HEADER), + ).toBeOnTheScreen(); + expect( + await screen.findByTestId( + PerpsMarketHeaderSelectorsIDs.PRICE_TITLE_SECTION, + ), + ).toBeOnTheScreen(); + expect( + await screen.findByTestId( + PerpsMarketHeaderSelectorsIDs.PRICE_CHANGE_TITLE_SECTION, + ), + ).toBeOnTheScreen(); + }); + + it('title section onLayout sets header height for scroll animation', async () => { + renderPerpsMarketDetailsView({ + streamOverrides: { positions: [] }, + overrides: { + engine: { + backgroundState: { + PerpsController: { isEligible: true }, + }, + }, + }, + }); + + const titleSectionWrapper = await screen.findByTestId( + PerpsMarketDetailsViewSelectorsIDs.TITLE_SECTION_WRAPPER, + ); + fireEvent(titleSectionWrapper, 'layout', { + nativeEvent: { layout: { x: 0, y: 0, width: 100, height: 80 } }, + }); + + expect(titleSectionWrapper).toBeOnTheScreen(); + expect( + screen.getByTestId(PerpsMarketDetailsViewSelectorsIDs.HEADER), + ).toBeOnTheScreen(); + }); + it('opens fullscreen chart modal and close button is pressable', async () => { renderPerpsMarketDetailsView({ streamOverrides: { positions: [] }, diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index c665775e0da..862a34e1d6c 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -104,10 +104,13 @@ const PerpsMarketListView = ({ if (onMarketSelect) { onMarketSelect(market); } else { - perpsNavigation.navigateToMarketDetails(market, route.params?.source); + perpsNavigation.navigateToMarketDetails( + market, + PERPS_EVENT_VALUE.SOURCE.PERP_MARKETS, + ); } }, - [onMarketSelect, perpsNavigation, route.params?.source], + [onMarketSelect, perpsNavigation], ); // Compute available categories based on market counts (hide empty categories) @@ -192,6 +195,7 @@ const PerpsMarketListView = ({ PERPS_EVENT_VALUE.SCREEN_TYPE.MARKET_LIST, [PERPS_EVENT_PROPERTY.SOURCE]: source, [PERPS_EVENT_PROPERTY.HAS_PERP_BALANCE]: hasPerpBalance, + [PERPS_EVENT_PROPERTY.MARKET_CATEGORY]: marketTypeFilter, ...(buttonClicked && { [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: buttonClicked, }), diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.test.tsx index 85f90d8fdb2..aba6e315287 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.test.tsx @@ -2,20 +2,23 @@ import React from 'react'; import { render } from '@testing-library/react-native'; import PerpsMarketRowSkeleton from './PerpsMarketRowSkeleton'; -jest.mock('../../../../../../../component-library/components/Skeleton', () => { - const { View } = jest.requireActual('react-native'); - return { - Skeleton: ({ - testID, - ...props - }: { - testID?: string; - width?: number; - height?: number; - style?: object; - }) => , - }; -}); +jest.mock( + '../../../../../../../component-library/components-temp/Skeleton', + () => { + const { View } = jest.requireActual('react-native'); + return { + Skeleton: ({ + testID, + ...props + }: { + testID?: string; + width?: number; + height?: number; + style?: object; + }) => , + }; + }, +); describe('PerpsMarketRowSkeleton', () => { it('renders without crashing with testID', () => { diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.tsx index 555343d84df..455b42b05ae 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View } from 'react-native'; -import { Skeleton } from '../../../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../../../component-library/components-temp/Skeleton'; import { useStyles } from '../../../../../../../component-library/hooks'; import type { PerpsMarketRowSkeletonProps } from './PerpsMarketRowSkeleton.types'; import styleSheet from './PerpsMarketRowSkeleton.styles'; diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx index 3f00bd502cb..be8afdb0fce 100644 --- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx @@ -775,6 +775,7 @@ describe('PerpsOrderBookView', () => { expect(mockNavigateToClosePosition).toHaveBeenCalledWith( mockLongPosition, + 'order_book', ); }); diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx index a4a188cc6b6..b66a1a95b1d 100644 --- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx @@ -298,6 +298,7 @@ const PerpsOrderBookView: React.FC = ({ PERPS_EVENT_VALUE.SCREEN_TYPE.ORDER_BOOK, [PERPS_EVENT_PROPERTY.ASSET]: symbol || '', [PERPS_EVENT_PROPERTY.SOURCE]: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + [PERPS_EVENT_PROPERTY.OPEN_POSITION]: existingPosition ? 1 : 0, }, }); @@ -465,7 +466,10 @@ const PerpsOrderBookView: React.FC = ({ return; } - navigateToClosePosition(existingPosition); + navigateToClosePosition( + existingPosition, + PERPS_EVENT_VALUE.SOURCE.ORDER_BOOK, + ); }, [existingPosition, navigateToClosePosition, isEligible, track]); // Handle Modify position button press @@ -779,6 +783,7 @@ const PerpsOrderBookView: React.FC = ({ onClose={handleTooltipClose} contentKey={selectedTooltip} testID={PerpsOrderBookViewSelectorsIDs.BOTTOM_SHEET_TOOLTIP} + buttonLocation={PERPS_EVENT_VALUE.BUTTON_LOCATION.ORDER_BOOK} /> diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index 81b8e3a67c1..87536d54e85 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -113,6 +113,10 @@ jest.mock('../../../../../../locales/i18n', () => ({ 'Size must be a positive number', 'perps.tpsl.stop_loss_order_view_warning': 'Stop loss is {{direction}} liquidation price', + 'perps.tpsl.take_profit_wrong_side_warning': + 'Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.', + 'perps.tpsl.stop_loss_wrong_side_warning': + 'Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.', 'perps.tpsl.below': 'below', 'perps.tpsl.above': 'above', 'perps.points': 'Points', @@ -535,6 +539,16 @@ jest.mock('../../../../../core/Engine', () => ({ }, })); +// Mock usePerpsABTest hook (controllable per-test) +const mockUsePerpsABTest = jest.fn(() => ({ + variantName: 'control', + variant: { long: 'green', short: 'red' }, + isEnabled: false, +})); +jest.mock('../../utils/abTesting/usePerpsABTest', () => ({ + usePerpsABTest: () => mockUsePerpsABTest(), +})); + // Mock useTooltipModal hook jest.mock('../../../../hooks/useTooltipModal', () => ({ __esModule: true, @@ -1970,6 +1984,427 @@ describe('PerpsOrderView', () => { }); }); + describe('TP/SL wrong-side price validation', () => { + const orderContextWithTPSL = (overrides: { + direction: 'long' | 'short'; + takeProfitPrice?: string; + stopLossPrice?: string; + }) => ({ + orderForm: { + asset: 'ETH', + amount: '100', + leverage: 10, + direction: overrides.direction, + type: 'market' as const, + limitPrice: undefined, + takeProfitPrice: overrides.takeProfitPrice, + stopLossPrice: overrides.stopLossPrice, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + optimizeOrderAmount: jest.fn(), + maxPossibleAmount: 1000, + balanceForValidation: 1000, + calculations: { + marginRequired: '10', + positionSize: '0.033', + }, + }); + + it('shows warning and disables button for long position with TP below current price', async () => { + // Arrange: TP at 2000 is below current price 3000 → invalid for long + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ direction: 'long', takeProfitPrice: '2000' }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: warning visible + await waitFor(() => { + expect( + screen.getByText( + 'Take profit must be above current price. Update or clear it to place the order.', + ), + ).toBeDefined(); + }); + + // Assert: button disabled + const placeOrderButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON, + ); + expect(placeOrderButton.props.accessibilityState?.disabled).toBeTruthy(); + }); + + it('shows warning and disables button for long position with SL above current price', async () => { + // Arrange: SL at 3500 is above current price 3000 → invalid for long + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ direction: 'long', stopLossPrice: '3500' }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: warning visible + await waitFor(() => { + expect( + screen.getByText( + 'Stop loss must be below current price. Update or clear it to place the order.', + ), + ).toBeDefined(); + }); + + // Assert: button disabled + const placeOrderButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON, + ); + expect(placeOrderButton.props.accessibilityState?.disabled).toBeTruthy(); + }); + + it('shows warning for short position with TP above current price', async () => { + // Arrange: TP at 3500 is above current price 3000 → invalid for short + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ direction: 'short', takeProfitPrice: '3500' }), + ); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: warning uses "below" for short + await waitFor(() => { + expect( + screen.getByText( + 'Take profit must be below current price. Update or clear it to place the order.', + ), + ).toBeDefined(); + }); + }); + + it('shows warning for short position with SL below current price', async () => { + // Arrange: SL at 2000 is below current price 3000 → invalid for short + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ direction: 'short', stopLossPrice: '2000' }), + ); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: warning uses "above" for short + await waitFor(() => { + expect( + screen.getByText( + 'Stop loss must be above current price. Update or clear it to place the order.', + ), + ).toBeDefined(); + }); + }); + + it('does not show wrong-side warnings when TP/SL prices are valid', async () => { + // Arrange: valid TP above and SL below current price for long + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ + direction: 'long', + takeProfitPrice: '3500', + stopLossPrice: '2500', + }), + ); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: component renders (TP/SL summary with valid percentages) + await waitFor(() => { + expect(screen.getByText('Leverage')).toBeDefined(); + }); + + // Assert: no wrong-side warnings + expect(screen.queryByText(/Take profit must be/)).toBeNull(); + expect(screen.queryByText(/Stop loss must be.*current price/)).toBeNull(); + }); + + it('disables button when both TP and SL are on wrong side', async () => { + // Arrange: both invalid for long (TP below, SL above current price) + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ + direction: 'long', + takeProfitPrice: '2000', + stopLossPrice: '3500', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: both warnings shown + await waitFor(() => { + expect(screen.getByText(/Take profit must be above/)).toBeDefined(); + expect(screen.getByText(/Stop loss must be below/)).toBeDefined(); + }); + + // Assert: button disabled + const placeOrderButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON, + ); + expect(placeOrderButton.props.accessibilityState?.disabled).toBeTruthy(); + }); + + it('disables monochrome button variant when TP/SL is invalid', async () => { + // Arrange: monochrome A/B test variant + invalid TP + mockUsePerpsABTest.mockReturnValue({ + variantName: 'monochrome', + variant: { long: 'white', short: 'white' }, + isEnabled: true, + }); + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ direction: 'long', takeProfitPrice: '2000' }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: warning visible (proves hasInvalidTPSL is true in monochrome path) + await waitFor(() => { + expect(screen.getByText(/Take profit must be above/)).toBeDefined(); + }); + + // Assert: monochrome button rendered and receives isDisabled prop + const placeOrderButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON, + ); + expect(placeOrderButton).toBeDefined(); + }); + + describe('limit order TP/SL validates against entry price, not market price', () => { + const orderContextForLimitOrder = (overrides: { + direction: 'long' | 'short'; + limitPrice: string; + takeProfitPrice?: string; + stopLossPrice?: string; + }) => ({ + orderForm: { + asset: 'ETH', + amount: '100', + leverage: 10, + direction: overrides.direction, + type: 'limit' as const, + limitPrice: overrides.limitPrice, + takeProfitPrice: overrides.takeProfitPrice, + stopLossPrice: overrides.stopLossPrice, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + optimizeOrderAmount: jest.fn(), + maxPossibleAmount: 1000, + balanceForValidation: 1000, + calculations: { + marginRequired: '10', + positionSize: '0.04', + }, + }); + + it('accepts TP above limit price for long limit order even when TP is below market price', async () => { + // Scenario: market at $3000, long limit buy at $2500, TP at $2700 + // TP $2700 is valid relative to $2500 entry but below $3000 market price + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextForLimitOrder({ + direction: 'long', + limitPrice: '2500', + takeProfitPrice: '2700', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + render(, { wrapper: TestWrapper }); + + await waitFor(() => { + expect(screen.getByText('Leverage')).toBeDefined(); + }); + + // TP at $2700 is above the $2500 limit (entry) price, so no warning should appear + expect(screen.queryByText(/Take profit must be/)).toBeNull(); + }); + + it('accepts SL below limit price for long limit order even when SL is below market price', async () => { + // Scenario: market at $3000, long limit buy at $2500, SL at $2300 + // SL $2300 is valid relative to $2500 entry + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextForLimitOrder({ + direction: 'long', + limitPrice: '2500', + stopLossPrice: '2300', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + render(, { wrapper: TestWrapper }); + + await waitFor(() => { + expect(screen.getByText('Leverage')).toBeDefined(); + }); + + // SL at $2300 is below the $2500 limit (entry) price, so no warning should appear + expect(screen.queryByText(/Stop loss must be/)).toBeNull(); + }); + + it('rejects TP below limit price for long limit order', async () => { + // Scenario: market at $3000, long limit buy at $2500, TP at $2400 + // TP $2400 is below the $2500 entry → invalid + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextForLimitOrder({ + direction: 'long', + limitPrice: '2500', + takeProfitPrice: '2400', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + render(, { wrapper: TestWrapper }); + + await waitFor(() => { + expect( + screen.getByText(/Take profit must be above entry price/), + ).toBeDefined(); + }); + }); + + it('accepts TP below limit price for short limit order even when TP is above market price', async () => { + // Scenario: market at $3000, short limit sell at $3500, TP at $3200 + // TP $3200 is below $3500 entry (valid for short) but above $3000 market + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextForLimitOrder({ + direction: 'short', + limitPrice: '3500', + takeProfitPrice: '3200', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + render(, { wrapper: TestWrapper }); + + await waitFor(() => { + expect(screen.getByText('Leverage')).toBeDefined(); + }); + + // TP at $3200 is below $3500 limit entry, valid for short + expect(screen.queryByText(/Take profit must be/)).toBeNull(); + }); + + it('accepts SL above limit price for short limit order', async () => { + // Scenario: market at $3000, short limit sell at $3500, SL at $3700 + // SL $3700 is above $3500 entry (valid for short) + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextForLimitOrder({ + direction: 'short', + limitPrice: '3500', + stopLossPrice: '3700', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + render(, { wrapper: TestWrapper }); + + await waitFor(() => { + expect(screen.getByText('Leverage')).toBeDefined(); + }); + + // SL at $3700 is above $3500 limit entry, valid for short + expect(screen.queryByText(/Stop loss must be/)).toBeNull(); + }); + }); + }); + describe('TP/SL limit price validation', () => { it('shows toast and prevents TP/SL bottom sheet from opening on limit order without limit price', async () => { // Clear all mocks to ensure clean state diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 8b49ddc5879..94830b2df9d 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -40,13 +40,14 @@ import ListItem from '../../../../../component-library/components/List/ListItem' import ListItemColumn, { WidthType, } from '../../../../../component-library/components/List/ListItemColumn'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import Text, { TextColor, TextVariant, } from '../../../../../component-library/components/Texts/Text'; import useTooltipModal from '../../../../../components/hooks/useTooltipModal'; import Routes from '../../../../../constants/navigation/Routes'; +import Engine from '../../../../../core/Engine'; import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; import { useTheme } from '../../../../../util/theme'; import { TraceName } from '../../../../../util/trace'; @@ -139,6 +140,8 @@ import { willFlipPosition } from '../../utils/orderUtils'; import { calculateRoEForPrice, isStopLossSafeFromLiquidation, + isValidStopLossPrice, + isValidTakeProfitPrice, } from '../../utils/tpslValidation'; import createStyles from './PerpsOrderView.styles'; import { PerpsPayRow } from './PerpsPayRow'; @@ -381,6 +384,7 @@ const PerpsOrderViewContentBase: React.FC = ({ : PERPS_EVENT_VALUE.DIRECTION.SHORT, [PERPS_EVENT_PROPERTY.SOURCE]: source ?? PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + [PERPS_EVENT_PROPERTY.OPEN_POSITION]: currentMarketPosition ? 1 : 0, ...(routeAbTestTokenDetailsLayout && { ab_tests: { assetsASSETS2493AbtestTokenDetailsLayout: routeAbTestTokenDetailsLayout, @@ -671,7 +675,11 @@ const PerpsOrderViewContentBase: React.FC = ({ ); const absRoE = Math.abs(parseFloat(tpRoE || '0')); tpDisplay = - absRoE > 0 ? `${absRoE.toFixed(0)}%` : strings('perps.order.off'); + absRoE > 0 + ? `${absRoE.toFixed(0)}%` + : formatPerpsFiat(orderForm.takeProfitPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + }); } if (orderForm.stopLossPrice && price > 0 && orderForm.leverage) { @@ -688,7 +696,11 @@ const PerpsOrderViewContentBase: React.FC = ({ ); const absRoE = Math.abs(parseFloat(slRoE || '0')); slDisplay = - absRoE > 0 ? `${absRoE.toFixed(0)}%` : strings('perps.order.off'); + absRoE > 0 + ? `${absRoE.toFixed(0)}%` + : formatPerpsFiat(orderForm.stopLossPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + }); } return `${strings('perps.order.tp')} ${tpDisplay}, ${strings( @@ -1078,6 +1090,12 @@ const PerpsOrderViewContentBase: React.FC = ({ } else { await executeOrder(orderParams); } + + // Clear pending trade config after successful submission to prevent + // stale TP/SL values from being restored on the next order form visit + Engine.context.PerpsController?.clearPendingTradeConfiguration( + orderForm.asset, + ); } finally { // Always reset submission flag isSubmittingRef.current = false; @@ -1203,6 +1221,35 @@ const PerpsOrderViewContentBase: React.FC = ({ ), ); + const isLimitWithPrice = + orderForm.type === 'limit' && Boolean(orderForm.limitPrice); + + const validationReferencePrice = isLimitWithPrice + ? parseFloat(String(orderForm.limitPrice)) + : assetData.price; + + const tpslPriceType = isLimitWithPrice ? 'entry' : 'current'; + + const isTakeProfitPriceInvalid = Boolean( + orderForm.takeProfitPrice?.trim() && + validationReferencePrice > 0 && + !isValidTakeProfitPrice(orderForm.takeProfitPrice, { + currentPrice: validationReferencePrice, + direction: orderForm.direction, + }), + ); + + const isStopLossPriceInvalid = Boolean( + orderForm.stopLossPrice?.trim() && + validationReferencePrice > 0 && + !isValidStopLossPrice(orderForm.stopLossPrice, { + currentPrice: validationReferencePrice, + direction: orderForm.direction, + }), + ); + + const hasInvalidTPSL = isTakeProfitPriceInvalid || isStopLossPriceInvalid; + let rewardAnimationState = RewardAnimationState.Idle; if (rewardsState.isLoading) { rewardAnimationState = RewardAnimationState.Loading; @@ -1420,6 +1467,32 @@ const PerpsOrderViewContentBase: React.FC = ({ )} + {!hideTPSL && isTakeProfitPriceInvalid && ( + + + {strings('perps.tpsl.take_profit_wrong_side_warning', { + direction: + orderForm.direction === 'long' + ? strings('perps.tpsl.above') + : strings('perps.tpsl.below'), + priceType: tpslPriceType, + })} + + + )} + {!hideTPSL && isStopLossPriceInvalid && ( + + + {strings('perps.tpsl.stop_loss_wrong_side_warning', { + direction: + orderForm.direction === 'long' + ? strings('perps.tpsl.below') + : strings('perps.tpsl.above'), + priceType: tpslPriceType, + })} + + + )} )} @@ -1651,6 +1724,7 @@ const PerpsOrderViewContentBase: React.FC = ({ !orderValidation.isValid || isPlacingOrder || doesStopLossRiskLiquidation || + hasInvalidTPSL || isAtOICap || shouldBlockBecauseOfFeesLoading } @@ -1671,6 +1745,7 @@ const PerpsOrderViewContentBase: React.FC = ({ !orderValidation.isValid || isPlacingOrder || doesStopLossRiskLiquidation || + hasInvalidTPSL || isAtOICap || shouldBlockBecauseOfFeesLoading } @@ -1788,6 +1863,7 @@ const PerpsOrderViewContentBase: React.FC = ({ contentKey={selectedTooltip} testID={PerpsOrderViewSelectorsIDs.BOTTOM_SHEET_TOOLTIP} key={selectedTooltip} + buttonLocation={PERPS_EVENT_VALUE.BUTTON_LOCATION.PERPS_ASSET_SCREEN} data={ selectedTooltip === 'fees' ? { diff --git a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx index 66c3db5936f..91610ef6c99 100644 --- a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx @@ -184,7 +184,10 @@ describe('PerpsSelectModifyActionView', () => { fireEvent.press(screen.getByTestId('reduce-position')); - expect(mockNavigateToClosePosition).toHaveBeenCalledWith(mockLongPosition); + expect(mockNavigateToClosePosition).toHaveBeenCalledWith( + mockLongPosition, + 'position_screen', + ); }); it('calls onReversePosition when flip_position is selected with callback', () => { diff --git a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx index b296e8039d0..8fc576fb60f 100644 --- a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx +++ b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx @@ -98,7 +98,10 @@ const PerpsSelectModifyActionView: React.FC< case 'reduce_position': // Open close position screen - navigateToClosePosition(position); + navigateToClosePosition( + position, + PERPS_EVENT_VALUE.SOURCE.POSITION_SCREEN, + ); break; case 'flip_position': diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx index c117442a59a..d47e484debd 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx @@ -45,7 +45,7 @@ import styleSheet from './PerpsTabView.styles'; import PerpsRowSkeleton from '../../components/PerpsRowSkeleton'; import PerpsMarketRowItem from '../../components/PerpsMarketRowItem'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import ConditionalScrollView from '../../../../../component-library/components-temp/ConditionalScrollView'; const PerpsTabView = () => { @@ -126,6 +126,7 @@ const PerpsTabView = () => { [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: PERPS_EVENT_VALUE.SCREEN_TYPE.WALLET_HOME_PERPS_TAB, [PERPS_EVENT_PROPERTY.OPEN_POSITION]: positions?.length || 0, + [PERPS_EVENT_PROPERTY.OPEN_ORDER]: orders?.length || 0, [PERPS_EVENT_PROPERTY.SOURCE]: PERPS_EVENT_VALUE.SOURCE.HOMESCREEN_TAB, }, }); diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx index 1d11e1a0ffd..e718f342707 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx @@ -22,6 +22,7 @@ import { createMockAccountsControllerState } from '../../../../../util/test/acco 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'; const mockTrackEvent = jest.fn(); const mockAddProperties = jest.fn(); @@ -29,11 +30,16 @@ const mockBuild = jest.fn(() => ({ name: 'test-event' })); const mockCreateEventBuilder = jest.fn(); const mockNavigate = jest.fn(); +const mockRefetchTransactions = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ navigate: mockNavigate, }), + useFocusEffect: (callback: () => void | (() => void)) => { + const ReactActual = jest.requireActual('react'); + ReactActual.useEffect(() => callback(), [callback]); + }, })); jest.mock('../../hooks', () => ({ @@ -50,6 +56,10 @@ jest.mock('../../hooks/usePerpsAssetsMetadata', () => ({ }), })); +jest.mock('../../hooks/usePerpsMeasurement', () => ({ + usePerpsMeasurement: jest.fn(), +})); + const mockInitialState: DeepPartial = { engine: { backgroundState: { @@ -126,10 +136,14 @@ describe('PerpsTransactionsView', () => { >; const mockUsePerpsEventTracking = usePerpsEventTracking as jest.MockedFunction; + const mockUsePerpsMeasurement = usePerpsMeasurement as jest.MockedFunction< + typeof usePerpsMeasurement + >; beforeEach(() => { jest.clearAllMocks(); mockNavigate.mockClear(); + mockRefetchTransactions.mockClear(); // Set up analytics mock const { useAnalytics } = jest.requireMock( @@ -160,12 +174,14 @@ describe('PerpsTransactionsView', () => { transactions: mockTransactions, isLoading: false, error: null, - refetch: jest.fn(), + refetch: mockRefetchTransactions, }); mockUsePerpsEventTracking.mockReturnValue({ track: jest.fn(), }); + + mockUsePerpsMeasurement.mockImplementation(() => undefined); }); it('should render with filter tabs', () => { @@ -191,6 +207,29 @@ describe('PerpsTransactionsView', () => { }); }); + it('refreshes transaction history when screen becomes focused', async () => { + renderWithProvider(, { + state: mockInitialState, + }); + + await waitFor(() => { + expect(mockRefetchTransactions).toHaveBeenCalled(); + }); + }); + + it('tracks measurement as ready when initial loading is complete', () => { + renderWithProvider(, { + state: mockInitialState, + }); + + expect(mockUsePerpsMeasurement).toHaveBeenCalledWith( + expect.objectContaining({ + conditions: [true], + resetConditions: [], + }), + ); + }); + it('should not load transactions when not connected', () => { mockUsePerpsConnection.mockReturnValue({ isConnected: false, @@ -294,6 +333,34 @@ describe('PerpsTransactionsView', () => { }); }); + it('shows loading skeleton instead of blank list when disconnected with no data', () => { + mockUsePerpsConnection.mockReturnValue({ + isConnected: false, + isConnecting: false, + isInitialized: true, + error: null, + connect: jest.fn(), + disconnect: jest.fn(), + resetError: jest.fn(), + reconnectWithNewContext: jest.fn(), + }); + + mockUsePerpsTransactionHistory.mockReturnValue({ + transactions: [], + isLoading: false, + error: null, + refetch: mockRefetchTransactions, + }); + + const component = renderWithProvider(, { + state: mockInitialState, + }); + + expect( + component.getByTestId('perps-transactions-loading-skeleton'), + ).toBeOnTheScreen(); + }); + it('should handle API errors gracefully', async () => { // Mock hook to return error state mockUsePerpsTransactionHistory.mockReturnValue({ diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx index 36c4ea19ad9..3e2474e4397 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx @@ -1,12 +1,6 @@ -import { useNavigation } from '@react-navigation/native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { FlashList } from '@shopify/flash-list'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { RefreshControl, ScrollView, View } from 'react-native'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; @@ -42,7 +36,6 @@ import { FilterTab, ListItem, PerpsTransaction, - PerpsTransactionsViewProps, TransactionSection, } from '../../types/transactionHistory'; import { formatDateSection } from '../../utils/formatUtils'; @@ -51,15 +44,14 @@ import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { TraceName } from '../../../../../util/trace'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -const PerpsTransactionsView: React.FC = () => { +const PerpsTransactionsView: React.FC = () => { const { styles } = useStyles(styleSheet, {}); const tw = useTailwind(); const navigation = useNavigation(); - // Transaction data is now computed from hooks instead of stored in state - const [flatListData, setFlatListData] = useState([]); const [activeFilter, setActiveFilter] = useState('Trades'); const [refreshing, setRefreshing] = useState(false); + const [isFocusRefreshing, setIsFocusRefreshing] = useState(false); // Ref for FlashList to control scrolling const flashListRef = useRef(null); @@ -179,16 +171,10 @@ const PerpsTransactionsView: React.FC = () => { groupTransactionsByDate, ]); - // Memoized flat data for current filter - prevents re-flattening on every change - const currentFlatListData = useMemo(() => { + const flatListData = useMemo(() => { const currentGrouped = allGroupedTransactions[activeFilter] || []; return flattenGroupedTransactions(currentGrouped, activeFilter); - }, [allGroupedTransactions, activeFilter]); - - // Update state only when needed - much faster tab switching - useEffect(() => { - setFlatListData(currentFlatListData); - }, [allGroupedTransactions, activeFilter, currentFlatListData]); + }, [activeFilter, allGroupedTransactions]); // Note: Removed automatic scroll to top on tab change to allow switching tabs while scrolling @@ -207,7 +193,35 @@ const PerpsTransactionsView: React.FC = () => { } }, [isConnected, refreshTransactions]); - // Initial loading is handled by the hooks themselves + useFocusEffect( + useCallback(() => { + if (!isConnected) { + setIsFocusRefreshing(false); + return; + } + + let isMounted = true; + + const refreshOnFocus = async () => { + setIsFocusRefreshing(true); + try { + await refreshTransactions(); + } catch (error) { + console.warn('Failed to refresh perps transactions on focus:', error); + } finally { + if (isMounted) { + setIsFocusRefreshing(false); + } + } + }; + + refreshOnFocus(); + + return () => { + isMounted = false; + }; + }, [isConnected, refreshTransactions]), + ); const renderFilterTab = useCallback( (tab: FilterTab, index: number) => { @@ -380,9 +394,18 @@ const PerpsTransactionsView: React.FC = () => { // Determine if we should show loading skeleton const isInitialLoading = useMemo( () => - // Show loading if we're connecting or if transaction data is loading - isConnecting || transactionsLoading, - [isConnecting, transactionsLoading], + // Show loading for connection/data fetch states and focus-refresh with no cached rows. + isConnecting || + transactionsLoading || + (!isConnected && flatListData.length === 0) || + (isFocusRefreshing && flatListData.length === 0), + [ + isConnecting, + transactionsLoading, + isConnected, + isFocusRefreshing, + flatListData.length, + ], ); // Track screen load performance - measures time until all data is loaded and UI is interactive diff --git a/app/components/UI/Perps/__mocks__/serviceMocks.ts b/app/components/UI/Perps/__mocks__/serviceMocks.ts index ae4edc0217f..890e77d1560 100644 --- a/app/components/UI/Perps/__mocks__/serviceMocks.ts +++ b/app/components/UI/Perps/__mocks__/serviceMocks.ts @@ -61,6 +61,7 @@ export const createMockInfrastructure = trace: jest.fn(() => undefined), endTrace: jest.fn(), setMeasurement: jest.fn(), + addBreadcrumb: jest.fn(), }, // === Platform Services === diff --git a/app/components/UI/Perps/adapters/mobileInfrastructure.ts b/app/components/UI/Perps/adapters/mobileInfrastructure.ts index b2709f513e1..5dbf7f24858 100644 --- a/app/components/UI/Perps/adapters/mobileInfrastructure.ts +++ b/app/components/UI/Perps/adapters/mobileInfrastructure.ts @@ -11,7 +11,11 @@ import { MetaMetricsEvents } from '../../../../core/Analytics'; import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; import { analytics } from '../../../../util/analytics/analytics'; import { trace, endTrace, TraceName } from '../../../../util/trace'; -import { setMeasurement } from '@sentry/react-native'; +import { + setMeasurement, + addBreadcrumb, + type SeverityLevel, +} from '@sentry/react-native'; import performance from 'react-native-performance'; import { getStreamManagerInstance } from '../providers/PerpsStreamManager'; import Engine from '../../../../core/Engine'; @@ -217,6 +221,14 @@ export function createMobileInfrastructure(): PerpsPlatformDependencies { setMeasurement(name: string, value: number, unit: string): void { setMeasurement(name, value, unit); }, + addBreadcrumb(breadcrumb: { + category: string; + message: string; + level: SeverityLevel; + data?: Record; + }): void { + addBreadcrumb(breadcrumb); + }, }, // === Platform Services === diff --git a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx index 5e3cdff5541..0686b839efd 100644 --- a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx +++ b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx @@ -105,14 +105,14 @@ const LivePriceHeader: React.FC = ({ return ( {formattedPrice} diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx index 4b969c10048..1e84531fc34 100644 --- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx +++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx @@ -54,6 +54,7 @@ const PerpsBottomSheetTooltip = React.memo( testID = PerpsBottomSheetTooltipSelectorsIDs.TOOLTIP, buttonConfig: buttonConfigProps, data, + buttonLocation, }) => { const { styles } = useStyles(createStyles, {}); const bottomSheetRef = useRef(null); @@ -96,17 +97,16 @@ const PerpsBottomSheetTooltip = React.memo( // Memoize the button handler to prevent recreation const handleGotItPress = useCallback(() => { - // Track tooltip button click track(MetaMetricsEvents.PERPS_UI_INTERACTION, { [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: PERPS_EVENT_VALUE.INTERACTION_TYPE.BUTTON_CLICKED, [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: PERPS_EVENT_VALUE.BUTTON_CLICKED.TOOLTIP, [PERPS_EVENT_PROPERTY.BUTTON_LOCATION]: - PERPS_EVENT_VALUE.BUTTON_LOCATION.TOOLTIP, + buttonLocation ?? PERPS_EVENT_VALUE.BUTTON_LOCATION.TOOLTIP, }); handleClose(); - }, [track, handleClose]); + }, [track, handleClose, buttonLocation]); // Memoize button label and footer buttons const buttonLabel = useMemo( diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts index a45cf5433e1..feda16042c4 100644 --- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts +++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts @@ -31,6 +31,13 @@ export interface PerpsBottomSheetTooltipProps { * Optional button config to pass to custom content renderers */ buttonConfig?: ButtonProps[]; + + /** + * Analytics: screen context for button_location tracking. + * When provided, overrides the default 'tooltip' button_location + * to indicate which screen the tooltip was opened from. + */ + buttonLocation?: string; } export type PerpsTooltipContentKey = diff --git a/app/components/UI/Perps/components/PerpsDiscoveryBanner/PerpsDiscoveryBanner.tsx b/app/components/UI/Perps/components/PerpsDiscoveryBanner/PerpsDiscoveryBanner.tsx index 171fd79c963..5e492b81ee5 100644 --- a/app/components/UI/Perps/components/PerpsDiscoveryBanner/PerpsDiscoveryBanner.tsx +++ b/app/components/UI/Perps/components/PerpsDiscoveryBanner/PerpsDiscoveryBanner.tsx @@ -13,7 +13,7 @@ import { Image, Pressable, StyleSheet } from 'react-native'; import { useStyles } from '../../../../../component-library/hooks'; import type { PerpsDiscoveryBannerProps } from './PerpsDiscoveryBanner.types'; -// eslint-disable-next-line import/no-commonjs, @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires +// eslint-disable-next-line import-x/no-commonjs, @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires const perpsLogo = require('../../../../../images/perps-home-empty-state.png'); const styleSheet = () => diff --git a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx index 94f4f4b6691..ff319add57d 100644 --- a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx @@ -259,7 +259,7 @@ jest.mock('../../../../../component-library/components/Badges/Badge', () => { }; }); -jest.mock('../../../../../component-library/components/Skeleton', () => { +jest.mock('../../../../../component-library/components-temp/Skeleton', () => { const { View } = jest.requireActual('react-native'); return { Skeleton: jest.fn(({ testID, width, height }) => ( diff --git a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx index e286922579d..1b726707208 100644 --- a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx +++ b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx @@ -35,7 +35,7 @@ import { import { usePerpsDepositProgress } from '../../hooks/usePerpsDepositProgress'; import { usePerpsTransactionState } from '../../hooks/usePerpsTransactionState'; import { convertPerpsAmountToUSD } from '../../utils/amountConversion'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import PerpsEmptyBalance from '../PerpsEmptyBalance'; import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; import { PerpsProgressBar } from '../PerpsProgressBar'; diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx index c575f805004..0b17436e2c7 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx @@ -509,7 +509,7 @@ describe('PerpsMarketTabs', () => { expect(onActiveTabChange).toHaveBeenLastCalledWith('position'); }); - it('handles initialTab when data loads after component mount', () => { + it('handles initialTab when data loads after component mount', async () => { const onActiveTabChange = jest.fn(); mockUsePerpsLivePositions.mockReturnValue({ positions: [{ ...mockPosition, symbol: 'BTC' }], @@ -525,9 +525,11 @@ describe('PerpsMarketTabs', () => { />, ); - expect( - getByTestId(PerpsMarketTabsSelectorsIDs.POSITION_CONTENT), - ).toBeDefined(); + await waitFor(() => { + expect( + getByTestId(PerpsMarketTabsSelectorsIDs.POSITION_CONTENT), + ).toBeDefined(); + }); expect(onActiveTabChange).toHaveBeenCalledWith('position'); }); }); @@ -646,7 +648,7 @@ describe('PerpsMarketTabs', () => { }); describe('Order Sorting', () => { - it('sorts orders by detailedOrderType then by orderId', () => { + it('sorts orders by detailedOrderType then by orderId', async () => { const limitOrder = { ...mockOrder, symbol: 'BTC', @@ -682,10 +684,12 @@ describe('PerpsMarketTabs', () => { />, ); - expect(getAllByTestId('mock-perps-open-order-card')).toHaveLength(3); + await waitFor(() => { + expect(getAllByTestId('mock-perps-open-order-card')).toHaveLength(3); + }); }); - it('falls back to orderType when detailedOrderType is missing', () => { + it('falls back to orderType when detailedOrderType is missing', async () => { const orderWithoutDetailedType = { ...mockOrder, symbol: 'BTC', @@ -707,7 +711,9 @@ describe('PerpsMarketTabs', () => { />, ); - expect(getByTestId('mock-perps-open-order-card')).toBeDefined(); + await waitFor(() => { + expect(getByTestId('mock-perps-open-order-card')).toBeDefined(); + }); }); }); diff --git a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx index 6c4901aac30..f1b2472cb70 100644 --- a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx @@ -129,6 +129,7 @@ describe('PerpsMarketTypeSection', () => { title="Crypto" markets={mockMarkets} marketType="crypto" + source="perps_home" />, { state: initialState }, ); @@ -142,6 +143,7 @@ describe('PerpsMarketTypeSection', () => { screen: Routes.PERPS.MARKET_LIST, params: { defaultMarketTypeFilter: 'crypto', + source: 'perps_home', }, }); }); @@ -153,6 +155,7 @@ describe('PerpsMarketTypeSection', () => { title="Crypto" markets={mockMarkets} marketType="crypto" + source="perps_home" />, { state: initialState }, ); @@ -164,7 +167,7 @@ describe('PerpsMarketTypeSection', () => { // Assert expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, - params: { market: mockMarkets[0] }, + params: { market: mockMarkets[0], source: 'perps_home' }, }); }); diff --git a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx index b474f14ee67..f1a75092538 100644 --- a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx @@ -24,6 +24,8 @@ export interface PerpsMarketTypeSectionProps { sortBy?: SortField; /** Whether markets are loading */ isLoading?: boolean; + /** Analytics source identifying the parent screen (e.g., 'perps_home') */ + source?: string; /** Test ID for component */ testID?: string; /** Optional style override for the section container */ @@ -63,6 +65,7 @@ const PerpsMarketTypeSection: React.FC = ({ marketType, sortBy = 'volume', isLoading, + source, testID, style, headerStyle, @@ -72,23 +75,23 @@ const PerpsMarketTypeSection: React.FC = ({ const navigation = useNavigation(); const handleViewAll = useCallback(() => { - // Navigate to the specific market type tab when "See all" is pressed navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_LIST, params: { defaultMarketTypeFilter: marketType, + source, }, }); - }, [navigation, marketType]); + }, [navigation, marketType, source]); const handleMarketPress = useCallback( (market: PerpsMarketData) => { navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, - params: { market }, + params: { market, source }, }); }, - [navigation], + [navigation, source], ); // Show skeleton during initial load diff --git a/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.test.tsx b/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.test.tsx index 46c7f500beb..fcc00c5f237 100644 --- a/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.test.tsx +++ b/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.test.tsx @@ -4,22 +4,19 @@ import PerpsRowSkeleton from './PerpsRowSkeleton'; import { HOME_SCREEN_CONFIG } from '../../constants/perpsConfig'; // Mock Skeleton component -jest.mock( - '../../../../../component-library/components/Skeleton/Skeleton', - () => { - const ReactNative = jest.requireActual('react-native'); - return { - __esModule: true, - default: jest.fn(({ height, width, style, testID }) => ( - - )), - }; - }, -); +jest.mock('../../../../../component-library/components-temp/Skeleton', () => { + const ReactNative = jest.requireActual('react-native'); + return { + __esModule: true, + Skeleton: jest.fn(({ height, width, style, testID }) => ( + + )), + }; +}); describe('PerpsRowSkeleton', () => { describe('rendering', () => { diff --git a/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.tsx b/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.tsx index 1ecaa2c276e..169422cfd5e 100644 --- a/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.tsx +++ b/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, StyleSheet, type ViewStyle } from 'react-native'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { HOME_SCREEN_CONFIG } from '../../constants/perpsConfig'; export interface PerpsRowSkeletonProps { diff --git a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx index 20ced09b025..23b97ca9b15 100644 --- a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx +++ b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/no-namespace */ +/* eslint-disable import-x/no-namespace */ import { fireEvent, render, diff --git a/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.test.tsx b/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.test.tsx index 1733e6d75d8..2310fbdeca8 100644 --- a/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.test.tsx +++ b/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.test.tsx @@ -22,19 +22,17 @@ jest.mock('../../../../../component-library/hooks', () => ({ })); // Mock the Skeleton component -jest.mock( - '../../../../../component-library/components/Skeleton/Skeleton', - () => - function MockSkeleton({ - testID, - ...props - }: { - testID?: string; - [key: string]: unknown; - }) { - return
; - }, -); +jest.mock('../../../../../component-library/components-temp/Skeleton', () => ({ + Skeleton: function MockSkeleton({ + testID, + ...props + }: { + testID?: string; + [key: string]: unknown; + }) { + return
; + }, +})); describe('PerpsTransactionsSkeleton', () => { it('renders loading skeleton with correct structure', () => { diff --git a/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.tsx b/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.tsx index 2824574fc29..927ebc2e655 100644 --- a/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.tsx +++ b/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View } from 'react-native'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { useStyles } from '../../../../../component-library/hooks'; import styleSheet from './PerpsTransactionsSkeleton.styles'; diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx index 5295a373530..6f3428b726d 100644 --- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx +++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx @@ -44,11 +44,11 @@ import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { PerpsConnectionManager } from '../../services/PerpsConnectionManager'; import createStyles from './PerpsTutorialCarousel.styles'; import Rive, { Alignment, Fit } from 'rive-react-native'; -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const PerpsOnboardingAnimationLight = require('../../animations/perps-onboarding-carousel-light.riv'); -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const PerpsOnboardingAnimationDark = require('../../animations/perps-onboarding-carousel-dark.riv'); -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs import Character from '../../../../../images/character_3x.png'; import { PerpsTutorialSelectorsIDs } from '../../Perps.testIds'; import { selectPerpsEligibility } from '../../selectors/perpsController'; diff --git a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx index a631c7466d8..45906c7ad77 100644 --- a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx +++ b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx @@ -150,7 +150,9 @@ describe('PerpsWatchlistMarkets', () => { // Navigation to watchlist is now handled elsewhere it('navigates to market details when market row is pressed', () => { - render(); + render( + , + ); const btcRow = screen.getByTestId('perps-market-row-BTC'); fireEvent.press(btcRow); @@ -161,12 +163,15 @@ describe('PerpsWatchlistMarkets', () => { params: { market: mockMarkets[0], initialTab: undefined, + source: 'perps_home', }, }); }); it('passes correct market data to navigation for different markets', () => { - render(); + render( + , + ); const ethRow = screen.getByTestId('perps-market-row-ETH'); fireEvent.press(ethRow); @@ -177,6 +182,7 @@ describe('PerpsWatchlistMarkets', () => { params: { market: mockMarkets[1], initialTab: undefined, + source: 'perps_home', }, }); }); @@ -374,7 +380,9 @@ describe('PerpsWatchlistMarkets', () => { }); it('handles pressing different market rows in sequence', () => { - render(); + render( + , + ); const btcRow = screen.getByTestId('perps-market-row-BTC'); const ethRow = screen.getByTestId('perps-market-row-ETH'); @@ -385,11 +393,11 @@ describe('PerpsWatchlistMarkets', () => { expect(mockNavigate).toHaveBeenCalledTimes(2); expect(mockNavigate).toHaveBeenNthCalledWith(1, Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, - params: { market: mockMarkets[0] }, + params: { market: mockMarkets[0], source: 'perps_home' }, }); expect(mockNavigate).toHaveBeenNthCalledWith(2, Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, - params: { market: mockMarkets[1] }, + params: { market: mockMarkets[1], source: 'perps_home' }, }); }); }); diff --git a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx index e8e213ec6c8..5d3cbebbeb4 100644 --- a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx +++ b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx @@ -24,6 +24,8 @@ interface PerpsWatchlistMarketsProps { positions?: Position[]; /** Orders from parent - avoids duplicate WebSocket subscriptions */ orders?: Order[]; + /** Analytics source identifying the parent screen (e.g., 'perps_home') */ + source?: string; /** Override section styles (e.g., to adjust margins) */ sectionStyle?: StyleProp; /** Override header styles (e.g., to remove horizontal padding) */ @@ -37,6 +39,7 @@ const PerpsWatchlistMarkets: React.FC = ({ isLoading, positions = [], orders = [], + source, sectionStyle, headerStyle, contentContainerStyle, @@ -57,17 +60,17 @@ const PerpsWatchlistMarkets: React.FC = ({ } else if (hasOrder) { initialTab = 'orders'; } - // If no position or order, initialTab remains undefined and defaults to Overview navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: { market, initialTab, + source, }, }); }, - [navigation, positions, orders], + [navigation, positions, orders, source], ); const renderMarket = useCallback( diff --git a/app/components/UI/Perps/components/TradingViewChart/TradingViewChart.tsx b/app/components/UI/Perps/components/TradingViewChart/TradingViewChart.tsx index b48284cf7d2..a92865adb1f 100644 --- a/app/components/UI/Perps/components/TradingViewChart/TradingViewChart.tsx +++ b/app/components/UI/Perps/components/TradingViewChart/TradingViewChart.tsx @@ -8,7 +8,7 @@ import React, { } from 'react'; import { WebView, WebViewMessageEvent } from '@metamask/react-native-webview'; import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { useStyles } from '../../../../../component-library/hooks'; import { styleSheet } from './TradingViewChart.styles'; import { type CandleData } from '@metamask/perps-controller'; diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index 142186148b0..d023148f711 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -243,11 +243,6 @@ export const STOP_LOSS_PROMPT_CONFIG = { // No banner shown until ROE drops below this value MinLossThreshold: -10, - // Debounce duration for ROE threshold (milliseconds) - // User must have ROE below threshold for this duration before showing banner - // Prevents banner from appearing during temporary price fluctuations - RoeDebounceMs: 60_000, // 60 seconds - // Minimum position age before showing any banner (milliseconds) // Prevents banner from appearing immediately after opening a position PositionMinAgeMs: 120_000, // 2 minutes diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts index 7a81635caeb..304bab74e86 100644 --- a/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts @@ -486,6 +486,102 @@ describe('usePerpsHomeData', () => { }); }); + describe('Market type filtering', () => { + it('excludes HIP-3 markets from crypto (perpsMarkets) section', () => { + const marketsWithHip3 = [ + ...mockMarkets, + createMockMarket({ + symbol: 'xyz:BRENTOIL', + name: 'Brent Oil', + isHip3: true, + isNewMarket: true, + }), + createMockMarket({ + symbol: 'xyz:GOLD', + name: 'Gold', + marketType: 'commodity', + isHip3: true, + }), + ]; + + mockUsePerpsMarkets.mockReturnValue({ + markets: marketsWithHip3, + isLoading: false, + isRefreshing: false, + error: null, + refresh: mockRefreshMarkets, + }); + + mockSortMarkets.mockImplementation(({ markets }) => markets); + + const { result } = renderHook(() => usePerpsHomeData()); + + // Only non-HIP3 crypto markets should be in perpsMarkets + expect(result.current.perpsMarkets).toHaveLength(3); + expect(result.current.perpsMarkets.every((m) => !m.isHip3)).toBe(true); + // BRENTOIL (unmapped HIP-3) must not appear in crypto + expect( + result.current.perpsMarkets.find((m) => m.symbol === 'xyz:BRENTOIL'), + ).toBeUndefined(); + }); + + it('includes HIP-3 commodity markets in commoditiesMarkets', () => { + const marketsWithCommodity = [ + ...mockMarkets, + createMockMarket({ + symbol: 'xyz:BRENTOIL', + name: 'Brent Oil', + marketType: 'commodity', + isHip3: true, + }), + ]; + + mockUsePerpsMarkets.mockReturnValue({ + markets: marketsWithCommodity, + isLoading: false, + isRefreshing: false, + error: null, + refresh: mockRefreshMarkets, + }); + + mockSortMarkets.mockImplementation(({ markets }) => markets); + + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.commoditiesMarkets).toHaveLength(1); + expect(result.current.commoditiesMarkets[0].symbol).toBe('xyz:BRENTOIL'); + }); + + it('excludes unmapped HIP-3 markets from search crypto results', () => { + const marketsWithHip3 = [ + ...mockMarkets, + createMockMarket({ + symbol: 'xyz:BRENTOIL', + name: 'Brent Oil', + isHip3: true, + isNewMarket: true, + }), + ]; + + mockUsePerpsMarkets.mockReturnValue({ + markets: marketsWithHip3, + isLoading: false, + isRefreshing: false, + error: null, + refresh: mockRefreshMarkets, + }); + + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: 'BRENT' }), + ); + + // BRENTOIL should not appear in perpsMarkets (crypto) during search + expect( + result.current.perpsMarkets.find((m) => m.symbol === 'xyz:BRENTOIL'), + ).toBeUndefined(); + }); + }); + describe('Trending markets sorting', () => { it('sorts markets using saved sort preference', () => { renderHook(() => usePerpsHomeData()); diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.ts index f540ddc5b3d..531716bd08d 100644 --- a/app/components/UI/Perps/hooks/usePerpsHomeData.ts +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.ts @@ -182,7 +182,7 @@ export const usePerpsHomeData = ({ const perpsMarkets = useMemo( () => sortMarkets({ - markets: allMarkets.filter((m) => !m.marketType), // Crypto markets have no marketType + markets: allMarkets.filter((m) => !m.marketType && !m.isHip3), sortBy, direction, }).slice(0, trendingLimit), @@ -321,7 +321,7 @@ export const usePerpsHomeData = ({ if (!searchQuery.trim()) { return perpsMarkets; } - return filteredData.markets.filter((m) => !m.marketType); + return filteredData.markets.filter((m) => !m.marketType && !m.isHip3); }, [searchQuery, perpsMarkets, filteredData.markets]); const searchedStocksMarkets = useMemo(() => { diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.ts index 812a8d7b03c..74a6a30f371 100644 --- a/app/components/UI/Perps/hooks/usePerpsNavigation.ts +++ b/app/components/UI/Perps/hooks/usePerpsNavigation.ts @@ -40,7 +40,7 @@ export interface PerpsNavigationHandlers { params?: PerpsNavigationParamList['PerpsTutorial'], ) => void; navigateToAdjustMargin: (position: Position, mode: 'add' | 'remove') => void; - navigateToClosePosition: (position: Position) => void; + navigateToClosePosition: (position: Position, source?: string) => void; navigateToOrderDetails: (order: Order) => void; // Utility navigation @@ -199,8 +199,8 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => { ); const navigateToClosePosition = useCallback( - (position: Position) => { - navigation.navigate(Routes.PERPS.CLOSE_POSITION, { position }); + (position: Position, source?: string) => { + navigation.navigate(Routes.PERPS.CLOSE_POSITION, { position, source }); }, [navigation], ); diff --git a/app/components/UI/Perps/hooks/usePerpsNetworkManagement.ts b/app/components/UI/Perps/hooks/usePerpsNetworkManagement.ts index fa6702c147d..34e3b997b25 100644 --- a/app/components/UI/Perps/hooks/usePerpsNetworkManagement.ts +++ b/app/components/UI/Perps/hooks/usePerpsNetworkManagement.ts @@ -11,7 +11,7 @@ import { } from '@metamask/perps-controller'; import { usePerpsNetwork } from './usePerpsNetwork'; -/* eslint-disable @typescript-eslint/no-require-imports, import/no-commonjs */ +/* eslint-disable @typescript-eslint/no-require-imports, import-x/no-commonjs */ const InfuraKey = process.env.MM_INFURA_PROJECT_ID; const infuraProjectId = InfuraKey === 'null' ? '' : InfuraKey; diff --git a/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts b/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts index c9c4b580ae5..7239d7af3d3 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts @@ -102,6 +102,10 @@ export function usePerpsOrderExecution( [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: orderParams.trackingData?.tradeWithToken === true, }; + if (orderParams.trackingData?.source) { + partialProps[PERPS_EVENT_PROPERTY.SOURCE] = + orderParams.trackingData.source; + } if (orderParams.trackingData?.tradeWithToken === true) { if (orderParams.trackingData.mmPayTokenSelected != null) { partialProps[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] = @@ -168,6 +172,10 @@ export function usePerpsOrderExecution( [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: orderParams.trackingData?.tradeWithToken === true, }; + if (orderParams.trackingData?.source) { + failedProps[PERPS_EVENT_PROPERTY.SOURCE] = + orderParams.trackingData.source; + } if (orderParams.trackingData?.tradeWithToken === true) { if (orderParams.trackingData.mmPayTokenSelected != null) { failedProps[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] = @@ -232,6 +240,10 @@ export function usePerpsOrderExecution( [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: orderParams.trackingData?.tradeWithToken === true, }; + if (orderParams.trackingData?.source) { + exceptionProps[PERPS_EVENT_PROPERTY.SOURCE] = + orderParams.trackingData.source; + } if (orderParams.trackingData?.tradeWithToken === true) { if (orderParams.trackingData.mmPayTokenSelected != null) { exceptionProps[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] = diff --git a/app/components/UI/Perps/hooks/usePerpsPositions.test.ts b/app/components/UI/Perps/hooks/usePerpsPositions.test.ts index a4219637032..ca2f048bd7e 100644 --- a/app/components/UI/Perps/hooks/usePerpsPositions.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsPositions.test.ts @@ -14,7 +14,7 @@ jest.mock('@react-navigation/native', () => ({ useFocusEffect: jest.fn(), })); -// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, import-x/no-commonjs const { useFocusEffect } = require('@react-navigation/native') as { useFocusEffect: jest.Mock; }; diff --git a/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts b/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts index fcafa5854c7..7921f54d136 100644 --- a/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts +++ b/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts @@ -3,7 +3,7 @@ import { useStopLossPrompt } from './useStopLossPrompt'; import { type Position } from '@metamask/perps-controller'; import { STOP_LOSS_PROMPT_CONFIG } from '../constants/perpsConfig'; -// Mock timers for debounce testing +// Mock timers for position age testing jest.useFakeTimers(); describe('useStopLossPrompt', () => { @@ -191,7 +191,7 @@ describe('useStopLossPrompt', () => { }); describe('stop_loss variant', () => { - it('shows stop_loss variant after both position age and ROE debounce requirements are met', () => { + it('shows stop_loss variant after position age requirement is met', () => { const position = createMockPosition({ returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) liquidationPrice: '40000', // Far from liquidation @@ -204,26 +204,21 @@ describe('useStopLossPrompt', () => { }), ); - // Initially should not show (debounce not complete) + // Initially should not show (position age not met) expect(result.current.shouldShowBanner).toBe(false); - // Explicitly advance past BOTH position age AND ROE debounce requirements - // Both timers must complete for the banner to show - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - + // Advance past position age requirement act(() => { - jest.advanceTimersByTime(requiredTime); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); }); - it('does not show stop_loss variant if ROE recovers before debounce', () => { + it('hides stop_loss variant when ROE recovers above threshold', () => { const position = createMockPosition({ returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) liquidationPrice: '40000', @@ -238,11 +233,15 @@ describe('useStopLossPrompt', () => { { initialProps: { pos: position } }, ); - // Fast-forward halfway through debounce + // Fast-forward past position age requirement act(() => { - jest.advanceTimersByTime(STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs / 2); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); + expect(result.current.shouldShowBanner).toBe(true); + // ROE recovers const recoveredPosition = createMockPosition({ returnOnEquity: '-0.05', // -5% ROE (above threshold) @@ -251,11 +250,6 @@ describe('useStopLossPrompt', () => { rerender({ pos: recoveredPosition }); - // Fast-forward past original debounce time - act(() => { - jest.advanceTimersByTime(STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs); - }); - expect(result.current.shouldShowBanner).toBe(false); }); }); @@ -267,12 +261,12 @@ describe('useStopLossPrompt', () => { jest.setSystemTime(new Date('2024-01-01T12:00:00.000Z')); }); - it('bypasses debounce immediately when position is older than 2 minutes and ROE is below threshold', async () => { + it('bypasses position age wait when position is older than 2 minutes', async () => { const now = Date.now(); - const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; // 2 minutes 1 second ago + const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; const position = createMockPosition({ - returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) - liquidationPrice: '40000', // Far from liquidation + returnOnEquity: '-0.15', + liquidationPrice: '40000', }); const { result } = renderHook(() => @@ -283,21 +277,19 @@ describe('useStopLossPrompt', () => { }), ); - // Flush effects to allow timestamp bypass to run await act(async () => { jest.runAllTimers(); }); - // Shows after effects run (server timestamp bypasses debounce) expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); }); - it('does not bypass debounce when position is less than 2 minutes old', () => { + it('does not bypass when position is less than 2 minutes old', () => { const now = Date.now(); - const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS + 1000; // 1 minute 59 seconds ago + const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS + 1000; const position = createMockPosition({ - returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) + returnOnEquity: '-0.15', liquidationPrice: '40000', }); @@ -309,37 +301,24 @@ describe('useStopLossPrompt', () => { }), ); - // Should NOT show immediately (position too new) - expect(result.current.shouldShowBanner).toBe(false); - - // Should still require full debounce period AND position age - // Need to wait for max of both: RoeDebounceMs (60s) and PositionMinAgeMs (120s) - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - - act(() => { - jest.advanceTimersByTime(requiredTime - 200); - }); - expect(result.current.shouldShowBanner).toBe(false); - // After full time passes, should show + // Still requires client-side position age timer act(() => { - jest.advanceTimersByTime(200); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); }); - it('does not bypass debounce when ROE is above threshold even if position is old', () => { + it('does not show banner when ROE is above threshold even if position is old', () => { const now = Date.now(); - const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; // 2 minutes 1 second ago + const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; const position = createMockPosition({ - returnOnEquity: '-0.05', // -5% ROE (above -10% threshold) + returnOnEquity: '-0.05', liquidationPrice: '40000', }); @@ -351,10 +330,8 @@ describe('useStopLossPrompt', () => { }), ); - // Should NOT show (ROE above threshold) expect(result.current.shouldShowBanner).toBe(false); - // Even after time passes, should not show act(() => { jest.advanceTimersByTime(10000); }); @@ -362,11 +339,11 @@ describe('useStopLossPrompt', () => { expect(result.current.shouldShowBanner).toBe(false); }); - it('bypasses debounce when position is exactly 2 minutes old', async () => { + it('bypasses when position is exactly 2 minutes old', async () => { const now = Date.now(); - const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS; // Exactly 2 minutes ago + const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS; const position = createMockPosition({ - returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) + returnOnEquity: '-0.15', liquidationPrice: '40000', }); @@ -378,21 +355,19 @@ describe('useStopLossPrompt', () => { }), ); - // Flush effects to allow timestamp bypass to run await act(async () => { jest.runAllTimers(); }); - // Shows after effects run (exactly at threshold) expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); }); - it('bypasses debounce only once per position lifecycle', async () => { + it('keeps showing banner when position updates but still below threshold', async () => { const now = Date.now(); const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; const position = createMockPosition({ - returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) + returnOnEquity: '-0.15', liquidationPrice: '40000', }); @@ -411,31 +386,27 @@ describe('useStopLossPrompt', () => { }, ); - // Flush effects to allow timestamp bypass to run await act(async () => { jest.runAllTimers(); }); - // Shows after effects run expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); - // Simulate position update (ROE changes but still below threshold) const updatedPosition = createMockPosition({ - returnOnEquity: '-0.12', // Still below threshold + returnOnEquity: '-0.12', liquidationPrice: '40000', }); rerender({ pos: updatedPosition, timestamp: positionOpenedTimestamp }); - // Still shows (bypass already happened) expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); }); - it('does not bypass when positionOpenedTimestamp is undefined', () => { + it('falls back to client-side timer when positionOpenedTimestamp is undefined', () => { const position = createMockPosition({ - returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) + returnOnEquity: '-0.15', liquidationPrice: '40000', }); @@ -447,26 +418,19 @@ describe('useStopLossPrompt', () => { }), ); - // Should NOT show immediately (no timestamp provided) expect(result.current.shouldShowBanner).toBe(false); - // Should require full debounce period AND position age - // Need to wait for max of both: RoeDebounceMs (60s) and PositionMinAgeMs (120s) - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - act(() => { - jest.advanceTimersByTime(requiredTime); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); }); - it('resets bypass state when position is closed', async () => { + it('resets when position is closed and reopened', async () => { const now = Date.now(); const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; const position = createMockPosition({ @@ -489,28 +453,22 @@ describe('useStopLossPrompt', () => { }, ); - // Flush effects to allow timestamp bypass to run await act(async () => { jest.runAllTimers(); }); - // Shows after effects run expect(result.current.shouldShowBanner).toBe(true); - // Close position rerender({ pos: null, timestamp: undefined }); expect(result.current.shouldShowBanner).toBe(false); - // Reopen position with same timestamp rerender({ pos: position, timestamp: positionOpenedTimestamp }); - // Flush effects again for the reopened position await act(async () => { jest.runAllTimers(); }); - // Shows again (state was reset) expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); }); @@ -532,7 +490,6 @@ describe('useStopLossPrompt', () => { }), ); - // Should NOT show (hook disabled) expect(result.current.shouldShowBanner).toBe(false); act(() => { @@ -929,15 +886,11 @@ describe('useStopLossPrompt', () => { { initialProps: { pos: position } }, ); - // Fast-forward past both age and debounce requirements - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - + // Fast-forward past position age requirement act(() => { - jest.advanceTimersByTime(requiredTime); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); expect(result.current.shouldShowBanner).toBe(true); @@ -1103,15 +1056,11 @@ describe('useStopLossPrompt', () => { }), ); - // Fast-forward past both position age and debounce requirements - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - + // Fast-forward past position age requirement act(() => { - jest.advanceTimersByTime(requiredTime); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); // Midpoint = (50000 + 47500) / 2 = 48750 @@ -1139,15 +1088,11 @@ describe('useStopLossPrompt', () => { }), ); - // Fast-forward past both position age and debounce requirements - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - + // Fast-forward past position age requirement act(() => { - jest.advanceTimersByTime(requiredTime); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); // Midpoint = (50000 + 40000) / 2 = 45000 @@ -1176,15 +1121,11 @@ describe('useStopLossPrompt', () => { }), ); - // Fast-forward past both position age and debounce requirements - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - + // Fast-forward past position age requirement act(() => { - jest.advanceTimersByTime(requiredTime); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); // Midpoint = (50000 + 47000) / 2 = 48500 @@ -1212,15 +1153,11 @@ describe('useStopLossPrompt', () => { }), ); - // Fast-forward past both position age and debounce requirements - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - + // Fast-forward past position age requirement act(() => { - jest.advanceTimersByTime(requiredTime); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); // Without a valid liquidation price, we can't calculate a stop loss price diff --git a/app/components/UI/Perps/hooks/useStopLossPrompt.ts b/app/components/UI/Perps/hooks/useStopLossPrompt.ts index 0ff5e0f45bc..879ff9c3692 100644 --- a/app/components/UI/Perps/hooks/useStopLossPrompt.ts +++ b/app/components/UI/Perps/hooks/useStopLossPrompt.ts @@ -24,7 +24,7 @@ export interface UseStopLossPromptParams { currentPrice: number; /** Enable/disable the hook (default: true) */ enabled?: boolean; - /** Timestamp when position was opened (from order fills) - bypasses debounce if position is >2min old */ + /** Timestamp when position was opened (from order fills) - bypasses client-side age wait for old positions */ positionOpenedTimestamp?: number; } @@ -52,8 +52,9 @@ export interface UseStopLossPromptResult { * * Implements the logic from TASK_AUTOSET.md: * - Shows "add_margin" variant when within 3% of liquidation - * - Shows "stop_loss" variant when ROE <= -10% for 60s (debounced) + * - Shows "stop_loss" variant when ROE <= -10% * - Suppresses when position has cross margin or existing stop loss + * - Suppresses for the first 2 minutes after a position is detected * * @example * ```tsx @@ -65,7 +66,6 @@ export interface UseStopLossPromptResult { * } = useStopLossPrompt({ * position: existingPosition, * currentPrice: 50000, - * positionOpenedTimestamp: 1234567890000, // Optional: from order fills * }); * ``` */ @@ -75,11 +75,6 @@ export const useStopLossPrompt = ({ enabled = true, positionOpenedTimestamp, }: UseStopLossPromptParams): UseStopLossPromptResult => { - // Track when ROE first dropped below threshold for debouncing - const roeBelowThresholdSinceRef = useRef(null); - const hasBeenShownRef = useRef(false); - const [roeDebounceComplete, setRoeDebounceComplete] = useState(false); - // Track when the current position was first detected (client-side) // This is used to enforce the minimum position age requirement const positionFirstSeenRef = useRef<{ @@ -124,51 +119,27 @@ export const useStopLossPrompt = ({ return roeValue * 100; }, [position?.returnOnEquity]); - // Callback to finish debounce (from main - for server timestamp bypass) - const finishDebounce = useCallback(() => { - setRoeDebounceComplete(true); - hasBeenShownRef.current = true; - }, []); - - // Reset hasBeenShownRef when position changes (from main) - useEffect(() => { - hasBeenShownRef.current = false; - }, [position?.symbol, position?.liquidationPrice, position?.entryPrice]); - - // Server timestamp bypass effect (from main) - // If positionOpenedTimestamp shows position is >2 minutes old, bypass debounce AND position age check - useEffect(() => { - if (!enabled || roePercent === null || hasBeenShownRef.current) { - return; - } - - // Check if position was opened more than 2 minutes ago (from order fills timestamp) - const POSITION_AGE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes - const positionAge = positionOpenedTimestamp - ? Date.now() - positionOpenedTimestamp - : 0; - - const isBelowThreshold = roePercent <= STOP_LOSS_PROMPT_CONFIG.RoeThreshold; - - // If position is old enough (from actual order fill data), bypass both debounce and position age check - // Server timestamp is authoritative - no need to wait for client-side age tracking - if (positionAge >= POSITION_AGE_THRESHOLD_MS && isBelowThreshold) { - setPositionAgeCheckPassed(true); // Also bypass client-side age check - finishDebounce(); - } - }, [positionOpenedTimestamp, enabled, roePercent, finishDebounce]); - - // Handle client-side position age tracking (from HEAD) - // Track when a position is first detected and enforce minimum age before showing banners + // Handle position age tracking + // Positions must be open for PositionMinAgeMs before showing the banner. + // If positionOpenedTimestamp (from order fills) proves the position is already old enough, + // the check passes immediately — no client-side timer needed. useEffect(() => { if (!enabled || !position?.symbol) { - // Reset when disabled or no position positionFirstSeenRef.current = null; setPositionAgeCheckPassed(false); return; } - // Check if this is a new position (different symbol or first time seeing it) + // Server timestamp bypass: if order fills confirm the position is old enough, skip the wait + if (positionOpenedTimestamp) { + const positionAge = Date.now() - positionOpenedTimestamp; + if (positionAge >= STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs) { + setPositionAgeCheckPassed(true); + return; + } + } + + // Client-side fallback: track when this position was first seen on this screen if ( !positionFirstSeenRef.current || positionFirstSeenRef.current.symbol !== position.symbol @@ -180,12 +151,10 @@ export const useStopLossPrompt = ({ setPositionAgeCheckPassed(false); } - // Check if minimum age has passed const elapsed = Date.now() - positionFirstSeenRef.current.timestamp; if (elapsed >= STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs) { setPositionAgeCheckPassed(true); } else { - // Set up timer to check again when age threshold is reached const remainingTime = STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs - elapsed; const timer = setTimeout(() => { setPositionAgeCheckPassed(true); @@ -195,49 +164,7 @@ export const useStopLossPrompt = ({ } return undefined; - }, [enabled, position?.symbol]); - - // Handle ROE debounce logic - useEffect(() => { - if (!enabled || roePercent === null) { - roeBelowThresholdSinceRef.current = null; - setRoeDebounceComplete(false); - hasBeenShownRef.current = false; // Reset when position is closed - return; - } - - const isBelowThreshold = roePercent <= STOP_LOSS_PROMPT_CONFIG.RoeThreshold; - - if (isBelowThreshold) { - // Start tracking if not already - if (roeBelowThresholdSinceRef.current === null) { - roeBelowThresholdSinceRef.current = Date.now(); - } - - // Check if debounce period has passed - const elapsed = Date.now() - roeBelowThresholdSinceRef.current; - if (elapsed >= STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs) { - finishDebounce(); - } else { - // Set up timer to check again - const remainingTime = STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs - elapsed; - const timer = setTimeout(() => { - // Re-check if still below threshold - if (roeBelowThresholdSinceRef.current !== null) { - finishDebounce(); - } - }, remainingTime); - - return () => clearTimeout(timer); - } - } else { - // Reset tracking when ROE goes above threshold - roeBelowThresholdSinceRef.current = null; - setRoeDebounceComplete(false); - } - - return undefined; - }, [enabled, roePercent, position, positionOpenedTimestamp, finishDebounce]); + }, [enabled, position?.symbol, positionOpenedTimestamp]); // Calculate suggested stop loss price as midpoint between current price and liquidation price // This provides a balanced protection point that limits losses while avoiding premature triggers @@ -380,9 +307,12 @@ export const useStopLossPrompt = ({ return { shouldShowBanner: true, variant: 'add_margin' }; } - // Priority 2: ROE below threshold with debounce → Stop loss variant + // Priority 2: ROE below threshold → Stop loss variant // But if suggested SL is too close to current price (within 3%), show add_margin instead - if (roeDebounceComplete) { + if ( + roePercent !== null && + roePercent <= STOP_LOSS_PROMPT_CONFIG.RoeThreshold + ) { // Guard: Don't show stop_loss variant if we can't calculate a valid suggested price // This prevents displaying garbled banner text like "Set a stop loss at ( ROE)" if (!suggestedStopLossPrice) { @@ -400,7 +330,6 @@ export const useStopLossPrompt = ({ enabled, position, liquidationDistance, - roeDebounceComplete, isSuggestedSlTooClose, suggestedStopLossPrice, positionAgeCheckPassed, diff --git a/app/components/UI/Perps/selectors/featureFlags/index.test.ts b/app/components/UI/Perps/selectors/featureFlags/index.test.ts index bb44ee4b103..c95bd6552f6 100644 --- a/app/components/UI/Perps/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Perps/selectors/featureFlags/index.test.ts @@ -22,7 +22,7 @@ import { VersionGatedFeatureFlag, validatedVersionGatedFeatureFlag, } from '../../../../../util/remoteFeatureFlag'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as remoteFeatureFlagModule from '../../../../../util/remoteFeatureFlag'; jest.mock('react-native-device-info', () => ({ diff --git a/app/components/UI/Perps/types/navigation.ts b/app/components/UI/Perps/types/navigation.ts index 876596ad122..a24af66c397 100644 --- a/app/components/UI/Perps/types/navigation.ts +++ b/app/components/UI/Perps/types/navigation.ts @@ -110,6 +110,7 @@ export interface PerpsNavigationParamList extends ParamListBase { PerpsClosePosition: { position: Position; + source?: string; }; PerpsAdjustMargin: { diff --git a/app/components/UI/Perps/types/transactionHistory.ts b/app/components/UI/Perps/types/transactionHistory.ts index 7ff2dc9a1f4..fd5851e25e4 100644 --- a/app/components/UI/Perps/types/transactionHistory.ts +++ b/app/components/UI/Perps/types/transactionHistory.ts @@ -99,8 +99,6 @@ export type ListItem = export type FilterTab = 'Trades' | 'Orders' | 'Funding' | 'Deposits'; -export interface PerpsTransactionsViewProps {} - export type PerpsPositionTransactionRouteProp = RouteProp< PerpsNavigationParamList, 'PerpsPositionTransaction' diff --git a/app/components/UI/Perps/utils/wait.test.ts b/app/components/UI/Perps/utils/wait.test.ts index aea87705d79..ac38e76a03a 100644 --- a/app/components/UI/Perps/utils/wait.test.ts +++ b/app/components/UI/Perps/utils/wait.test.ts @@ -13,14 +13,14 @@ describe('wait', () => { const promise = wait(100); jest.advanceTimersByTime(100); await promise; - expect(promise).resolves.toBeUndefined(); + await expect(promise).resolves.toBeUndefined(); }); it('should handle zero duration', async () => { const promise = wait(0); jest.advanceTimersByTime(0); await promise; - expect(promise).resolves.toBeUndefined(); + await expect(promise).resolves.toBeUndefined(); }); it('should return a Promise that resolves to undefined', async () => { diff --git a/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx b/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx index 9b332047e7f..8b2bdd9bb4f 100644 --- a/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx +++ b/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx @@ -31,7 +31,7 @@ import BadgeWrapper, { import Button, { ButtonVariants, } from '../../../../../component-library/components/Buttons/Button'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { USDC_SYMBOL, USDC_TOKEN_ICON_URL } from '@metamask/perps-controller'; import { usePredictBalance } from '../../hooks/usePredictBalance'; import { usePredictDeposit } from '../../hooks/usePredictDeposit'; diff --git a/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/PredictDetailsButtonsSkeleton.tsx b/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/PredictDetailsButtonsSkeleton.tsx index f177ea9873c..a8ed53ba5d0 100644 --- a/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/PredictDetailsButtonsSkeleton.tsx +++ b/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/PredictDetailsButtonsSkeleton.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, BoxFlexDirection } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { PREDICT_DETAILS_BUTTONS_SKELETON, PREDICT_DETAILS_BUTTONS_SKELETON_TEST_IDS, diff --git a/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/__snapshots__/PredictDetailsButtonsSkeleton.test.tsx.snap b/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/__snapshots__/PredictDetailsButtonsSkeleton.test.tsx.snap index 501b2e36d19..cdb1b28375d 100644 --- a/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/__snapshots__/PredictDetailsButtonsSkeleton.test.tsx.snap +++ b/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/__snapshots__/PredictDetailsButtonsSkeleton.test.tsx.snap @@ -30,12 +30,21 @@ exports[`PredictDetailsButtonsSkeleton matches snapshot 1`] = ` > @@ -72,12 +81,21 @@ exports[`PredictDetailsButtonsSkeleton matches snapshot 1`] = ` > diff --git a/app/components/UI/Predict/components/PredictDetailsContentSkeleton/PredictDetailsContentSkeleton.tsx b/app/components/UI/Predict/components/PredictDetailsContentSkeleton/PredictDetailsContentSkeleton.tsx index 7b719e1e6bc..da4527a4620 100644 --- a/app/components/UI/Predict/components/PredictDetailsContentSkeleton/PredictDetailsContentSkeleton.tsx +++ b/app/components/UI/Predict/components/PredictDetailsContentSkeleton/PredictDetailsContentSkeleton.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { PREDICT_DETAILS_CONTENT_SKELETON, PREDICT_DETAILS_CONTENT_SKELETON_TEST_IDS, diff --git a/app/components/UI/Predict/components/PredictDetailsContentSkeleton/__snapshots__/PredictDetailsContentSkeleton.test.tsx.snap b/app/components/UI/Predict/components/PredictDetailsContentSkeleton/__snapshots__/PredictDetailsContentSkeleton.test.tsx.snap index 2576050fbab..a6feef74a46 100644 --- a/app/components/UI/Predict/components/PredictDetailsContentSkeleton/__snapshots__/PredictDetailsContentSkeleton.test.tsx.snap +++ b/app/components/UI/Predict/components/PredictDetailsContentSkeleton/__snapshots__/PredictDetailsContentSkeleton.test.tsx.snap @@ -27,12 +27,21 @@ exports[`PredictDetailsContentSkeleton matches snapshot 1`] = ` > @@ -55,12 +64,21 @@ exports[`PredictDetailsContentSkeleton matches snapshot 1`] = ` @@ -95,12 +113,21 @@ exports[`PredictDetailsContentSkeleton matches snapshot 1`] = ` > @@ -123,12 +150,21 @@ exports[`PredictDetailsContentSkeleton matches snapshot 1`] = ` diff --git a/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/PredictDetailsHeaderSkeleton.tsx b/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/PredictDetailsHeaderSkeleton.tsx index c6a0d191e78..967c03198db 100644 --- a/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/PredictDetailsHeaderSkeleton.tsx +++ b/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/PredictDetailsHeaderSkeleton.tsx @@ -13,7 +13,7 @@ import Icon, { IconName, IconSize, } from '../../../../../component-library/components/Icons/Icon'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { PREDICT_DETAILS_HEADER_SKELETON, PREDICT_DETAILS_HEADER_SKELETON_TEST_IDS, diff --git a/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/__snapshots__/PredictDetailsHeaderSkeleton.test.tsx.snap b/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/__snapshots__/PredictDetailsHeaderSkeleton.test.tsx.snap index 2fc292ede41..3e8cbf6ed49 100644 --- a/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/__snapshots__/PredictDetailsHeaderSkeleton.test.tsx.snap +++ b/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/__snapshots__/PredictDetailsHeaderSkeleton.test.tsx.snap @@ -88,13 +88,22 @@ exports[`PredictDetailsHeaderSkeleton matches snapshot 1`] = ` > @@ -118,12 +127,21 @@ exports[`PredictDetailsHeaderSkeleton matches snapshot 1`] = ` diff --git a/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.test.tsx b/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.test.tsx index bee8f2136d3..7e164bad917 100644 --- a/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.test.tsx +++ b/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.test.tsx @@ -81,6 +81,12 @@ jest.mock('../../../../../../locales/i18n', () => ({ if (key === 'predict.fee_summary.exchange_fee_description') { return 'Fee paid to the exchange or market'; } + if (key === 'predict.fee_summary.deposit_fee') { + return 'Deposit fee'; + } + if (key === 'predict.fee_summary.deposit_fee_description') { + return 'Fee paid for token deposit'; + } if (key === 'predict.fee_summary.total') { return 'Total'; } @@ -241,6 +247,22 @@ describe('PredictFeeBreakdownSheet', () => { const zeroAmounts = getAllByText('$0.00'); expect(zeroAmounts.length).toBeGreaterThanOrEqual(2); }); + + it('does not render standalone 0 text when depositFee is 0', () => { + const props = { + ...defaultProps, + depositFee: 0, + }; + const TestComponent = () => { + const ref = useRef(null); + return ; + }; + + const { queryByText, queryAllByText } = render(); + + expect(queryByText(/^0$/)).not.toBeOnTheScreen(); + expect(queryAllByText('Deposit fee')).toHaveLength(0); + }); }); describe('Total display', () => { diff --git a/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.tsx b/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.tsx index f2b57f844c1..4e16008ccda 100644 --- a/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.tsx +++ b/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.tsx @@ -42,6 +42,7 @@ const FeeRow = ({ title, description, amount }: FeeRowProps) => ( interface PredictFeeBreakdownSheetProps { providerFee: number; metamaskFee: number; + depositFee?: number; sharePrice: number; contractCount: number; betAmount: number; @@ -59,6 +60,7 @@ const PredictFeeBreakdownSheet = forwardRef< providerFee, metamaskFee, sharePrice, + depositFee, contractCount, betAmount, total, @@ -95,6 +97,32 @@ const PredictFeeBreakdownSheet = forwardRef< amount={formatPrice(providerFee, { maximumDecimals: 2 })} /> + {depositFee && ( + <> + + + + {strings('predict.fee_summary.deposit_fee')} + + + {strings('predict.fee_summary.deposit_fee_description')} + + + + {formatPrice(depositFee, { maximumDecimals: 2 })} + + + + + + )} + { }; }); -jest.mock( - '../../../../../component-library/components/Skeleton/Skeleton', - () => { - const ReactNative = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ testID }: { testID?: string }) => ( - - ), - }; - }, -); +jest.mock('../../../../../component-library/components-temp/Skeleton', () => { + const ReactNative = jest.requireActual('react-native'); + return { + __esModule: true, + Skeleton: ({ testID }: { testID?: string }) => ( + + ), + }; +}); jest.mock('./PredictHomeSkeleton', () => { const ReactNative = jest.requireActual('react-native'); diff --git a/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedSkeleton.tsx b/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedSkeleton.tsx index 8fe65c2cf5d..de061c7be46 100644 --- a/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedSkeleton.tsx +++ b/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedSkeleton.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Dimensions, ScrollView } from 'react-native'; import { Box, BoxFlexDirection } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { PredictHomeFeaturedVariant } from '../../selectors/featureFlags'; import PredictHomeSkeleton from './PredictHomeSkeleton'; import { diff --git a/app/components/UI/Predict/components/PredictHome/PredictHomeSkeleton.tsx b/app/components/UI/Predict/components/PredictHome/PredictHomeSkeleton.tsx index ad7b519c65c..dd15b92d93f 100644 --- a/app/components/UI/Predict/components/PredictHome/PredictHomeSkeleton.tsx +++ b/app/components/UI/Predict/components/PredictHome/PredictHomeSkeleton.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, BoxFlexDirection } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { PREDICT_HOME_SKELETON, PREDICT_HOME_SKELETON_TEST_IDS, diff --git a/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.test.tsx b/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.test.tsx index dc9299399bd..e16c12dff40 100644 --- a/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.test.tsx +++ b/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.test.tsx @@ -171,65 +171,6 @@ describe('PredictKeypad', () => { }); }); - describe('Add Funds Button', () => { - it('renders Add Funds button when hasInsufficientFunds is true and onAddFunds is provided', () => { - const onAddFundsMock = jest.fn(); - const props = { - ...defaultProps, - hasInsufficientFunds: true, - onAddFunds: onAddFundsMock, - }; - - const { getByText, queryByText } = render(); - - expect(getByText('Add funds')).toBeOnTheScreen(); - expect(queryByText('$20')).toBeNull(); - expect(queryByText('$50')).toBeNull(); - expect(queryByText('$100')).toBeNull(); - }); - - it('calls onAddFunds when Add Funds button is pressed', () => { - const onAddFundsMock = jest.fn(); - const props = { - ...defaultProps, - hasInsufficientFunds: true, - onAddFunds: onAddFundsMock, - }; - - const { getByText } = render(); - fireEvent.press(getByText('Add funds')); - - expect(onAddFundsMock).toHaveBeenCalledTimes(1); - }); - - it('renders quick amount buttons when hasInsufficientFunds is false', () => { - const props = { - ...defaultProps, - hasInsufficientFunds: false, - }; - - const { getByText, queryByText } = render(); - - expect(getByText('$20')).toBeOnTheScreen(); - expect(getByText('$50')).toBeOnTheScreen(); - expect(getByText('$100')).toBeOnTheScreen(); - expect(queryByText('predict.deposit.add_funds')).toBeNull(); - }); - - it('renders quick amount buttons when onAddFunds is not provided', () => { - const props = { - ...defaultProps, - hasInsufficientFunds: true, - onAddFunds: undefined, - }; - - const { getByText, queryByText } = render(); - - expect(getByText('$20')).toBeOnTheScreen(); - expect(queryByText('predict.deposit.add_funds')).toBeNull(); - }); - }); - describe('handleDonePress', () => { it('removes trailing decimal point when Done is pressed', () => { const props = { diff --git a/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx b/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx index 232d0627089..a6a1381ace9 100644 --- a/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx +++ b/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx @@ -1,12 +1,11 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import React, { useCallback, forwardRef, useImperativeHandle } from 'react'; +import React, { forwardRef, useCallback, useImperativeHandle } from 'react'; import { View } from 'react-native'; import Button, { ButtonSize, ButtonVariants, } from '../../../../../component-library/components/Buttons/Button'; import Keypad from '../../../../Base/Keypad'; -import { strings } from '../../../../../../locales/i18n'; interface PredictKeypadProps { isInputFocused: boolean; @@ -15,8 +14,6 @@ interface PredictKeypadProps { setCurrentValue: (value: number) => void; setCurrentValueUSDString: (value: string) => void; setIsInputFocused: (focused: boolean) => void; - hasInsufficientFunds?: boolean; - onAddFunds?: () => void; } export interface PredictKeypadHandles { @@ -34,8 +31,6 @@ const PredictKeypad = forwardRef( setCurrentValue, setCurrentValueUSDString, setIsInputFocused, - hasInsufficientFunds = false, - onAddFunds, }, ref, ) => { @@ -144,46 +139,36 @@ const PredictKeypad = forwardRef( return ( - {hasInsufficientFunds && onAddFunds ? ( + + ) : null} {!showChangeProvider && providerName && providerSupportUrl ? ( ) : null} ); diff --git a/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap index 0e93d9d6a1c..acdb5568719 100644 --- a/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap @@ -320,11 +320,11 @@ exports[`ErrorDetailsModal renders correctly and matches snapshot 1`] = ` "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -373,13 +373,17 @@ exports[`ErrorDetailsModal renders correctly and matches snapshot 1`] = ` Error details @@ -390,39 +394,74 @@ exports[`ErrorDetailsModal renders correctly and matches snapshot 1`] = ` - - + @@ -445,13 +484,19 @@ exports[`ErrorDetailsModal renders correctly and matches snapshot 1`] = ` This is a test error message. @@ -468,43 +513,93 @@ exports[`ErrorDetailsModal renders correctly and matches snapshot 1`] = ` } } > - Got it - + @@ -838,11 +933,11 @@ exports[`ErrorDetailsModal renders with a multiline error message 1`] = ` "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -891,13 +986,17 @@ exports[`ErrorDetailsModal renders with a multiline error message 1`] = ` Error details @@ -908,39 +1007,74 @@ exports[`ErrorDetailsModal renders with a multiline error message 1`] = ` - - + @@ -963,13 +1097,19 @@ exports[`ErrorDetailsModal renders with a multiline error message 1`] = ` Error on line 1. @@ -988,43 +1128,93 @@ Additional context for debugging. } } > - Got it - + @@ -1358,11 +1548,11 @@ exports[`ErrorDetailsModal renders with an empty error message 1`] = ` "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -1411,13 +1601,17 @@ exports[`ErrorDetailsModal renders with an empty error message 1`] = ` Error details @@ -1428,39 +1622,74 @@ exports[`ErrorDetailsModal renders with an empty error message 1`] = ` - - + @@ -1483,13 +1712,19 @@ exports[`ErrorDetailsModal renders with an empty error message 1`] = ` @@ -1504,43 +1739,93 @@ exports[`ErrorDetailsModal renders with an empty error message 1`] = ` } } > - Got it - + @@ -1874,11 +2159,11 @@ exports[`ErrorDetailsModal renders with change provider button and matches snaps "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -1927,13 +2212,17 @@ exports[`ErrorDetailsModal renders with change provider button and matches snaps Error details @@ -1944,39 +2233,74 @@ exports[`ErrorDetailsModal renders with change provider button and matches snaps - - + @@ -1999,13 +2323,19 @@ exports[`ErrorDetailsModal renders with change provider button and matches snaps No quotes available. @@ -2022,82 +2352,182 @@ exports[`ErrorDetailsModal renders with change provider button and matches snaps } } > - Change provider - - + Got it - + @@ -2431,11 +2861,11 @@ exports[`ErrorDetailsModal renders with provider support info and matches snapsh "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -2484,13 +2914,17 @@ exports[`ErrorDetailsModal renders with provider support info and matches snapsh Error details @@ -2501,39 +2935,74 @@ exports[`ErrorDetailsModal renders with provider support info and matches snapsh - - + @@ -2556,13 +3025,19 @@ exports[`ErrorDetailsModal renders with provider support info and matches snapsh Provider error occurred. @@ -2579,82 +3054,182 @@ exports[`ErrorDetailsModal renders with provider support info and matches snapsh } } > - Contact Transak support - - + Got it - + diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentMethodListItem.tsx b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentMethodListItem.tsx index c990ff3f02e..828df3b9a55 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentMethodListItem.tsx +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentMethodListItem.tsx @@ -4,10 +4,12 @@ import ListItemSelect from '../../../../../../component-library/components/List/ import ListItemColumn, { WidthType, } from '../../../../../../component-library/components/List/ListItemColumn'; -import Text, { +import { + Text, TextVariant, TextColor, -} from '../../../../../../component-library/components/Texts/Text'; + FontWeight, +} from '@metamask/design-system-react-native'; import { PaymentType } from '@consensys/on-ramp-sdk'; import PaymentMethodIcon from '../../../Aggregator/components/PaymentMethodIcon'; import QuoteDisplay from './QuoteDisplay'; @@ -97,9 +99,11 @@ const PaymentMethodListItem: React.FC = ({ - {paymentMethod.name} + + {paymentMethod.name} + {delayText ? ( - + {delayText} ) : null} diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionAlert.tsx b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionAlert.tsx index 91cba3b16ba..c754e2fbdaa 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionAlert.tsx +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionAlert.tsx @@ -1,9 +1,7 @@ import React from 'react'; import BannerAlert from '../../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert'; import { BannerAlertSeverity } from '../../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.types'; -import Text, { - TextVariant, -} from '../../../../../../component-library/components/Texts/Text'; +import { Text, TextVariant } from '@metamask/design-system-react-native'; interface PaymentSelectionAlertProps { message: string; @@ -15,7 +13,7 @@ const PaymentSelectionAlert: React.FC = ({ severity = BannerAlertSeverity.Error, }) => ( {message}} + description={{message}} severity={severity} /> ); diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx index 1c8bb9c31e0..40bad701f4b 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { fireEvent, waitFor } from '@testing-library/react-native'; import PaymentSelectionModal from './PaymentSelectionModal'; +import { PAYMENT_SELECTION_MODAL_TEST_IDS } from './PaymentSelectionModal.testIds'; import { renderScreen } from '../../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../../util/test/initial-root-state'; jest.mock('../../../../../Base/RemoteImage', () => jest.fn(() => null)); @@ -12,11 +13,11 @@ const mockGetQuotes = jest.fn().mockResolvedValue({ customActions: [], }); -const mockGetWidgetUrl = jest.fn(); +const mockGetBuyWidgetData = jest.fn(); const defaultQuotesReturn = { getQuotes: mockGetQuotes, - getWidgetUrl: mockGetWidgetUrl, + getBuyWidgetData: mockGetBuyWidgetData, data: null, loading: false, error: null, @@ -208,6 +209,16 @@ describe('PaymentSelectionModal', () => { expect(getByText('fiat_on_ramp.pay_with')).toBeOnTheScreen(); }); + it('calls onCloseBottomSheet when header close is pressed', () => { + const { getByTestId } = renderWithProvider(PaymentSelectionModal); + + fireEvent.press( + getByTestId(PAYMENT_SELECTION_MODAL_TEST_IDS.HEADER_CLOSE_BUTTON), + ); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); + it('displays payment methods list', () => { const { getAllByText } = renderWithProvider(PaymentSelectionModal); @@ -350,4 +361,27 @@ describe('PaymentSelectionModal', () => { forceRefresh: true, }); }); + + it('shows payment method without quote when only custom-action quote matches', () => { + const customActionQuote = { + provider: '/providers/transak', + quote: { + paymentMethod: '/payments/debit-credit-card-1', + isCustomAction: true, + }, + }; + mockUseRampsQuotes.mockImplementation(() => ({ + ...defaultQuotesReturn, + data: { + success: [customActionQuote], + error: [], + sorted: [], + customActions: [], + }, + loading: false, + })); + + const { getAllByText } = renderWithProvider(PaymentSelectionModal); + expect(getAllByText('Debit or Credit').length).toBeGreaterThan(0); + }); }); diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.testIds.ts b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.testIds.ts new file mode 100644 index 00000000000..a747216b609 --- /dev/null +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.testIds.ts @@ -0,0 +1,3 @@ +export const PAYMENT_SELECTION_MODAL_TEST_IDS = { + HEADER_CLOSE_BUTTON: 'payment-selection-modal-header-close', +} as const; diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx index 8a1faf7af30..82811b843bd 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx @@ -7,16 +7,16 @@ import { useNavigation } from '@react-navigation/native'; import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; -import Text, { - TextVariant, - TextColor, -} from '../../../../../../component-library/components/Texts/Text'; -import { BannerAlertSeverity } from '../../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.types'; import { Box, BoxAlignItems, BoxJustifyContent, + Text, + TextVariant, + TextColor, } from '@metamask/design-system-react-native'; +import { BannerAlertSeverity } from '../../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.types'; +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; import { useStyles } from '../../../../../hooks/useStyles'; import { strings } from '../../../../../../../locales/i18n'; import styleSheet from './PaymentSelectionModal.styles'; @@ -28,9 +28,11 @@ import { import PaymentMethodListItem from './PaymentMethodListItem'; import PaymentMethodListSkeleton from './PaymentMethodListSkeleton'; import PaymentSelectionAlert from './PaymentSelectionAlert'; +import { PAYMENT_SELECTION_MODAL_TEST_IDS } from './PaymentSelectionModal.testIds'; import { useRampsController } from '../../../hooks/useRampsController'; import { useRampsQuotes } from '../../../hooks/useRampsQuotes'; import useRampAccountAddress from '../../../hooks/useRampAccountAddress'; +import { isCustomAction } from '../../../types'; import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../../core/Analytics'; @@ -161,7 +163,9 @@ function PaymentSelectionModal() { ({ item: paymentMethod }: { item: PaymentMethod }) => { const matchedQuote = quotes?.success?.find( - (quote) => quote.quote?.paymentMethod === paymentMethod.id, + (quote) => + quote.quote?.paymentMethod === paymentMethod.id && + !isCustomAction(quote), ) ?? null; const hasQuoteError = !matchedQuote && @@ -244,15 +248,13 @@ function PaymentSelectionModal() { - - - {strings('fiat_on_ramp.pay_with')} - - + sheetRef.current?.onCloseBottomSheet()} + closeButtonProps={{ + testID: PAYMENT_SELECTION_MODAL_TEST_IDS.HEADER_CLOSE_BUTTON, + }} + /> {renderListContent()} {selectedProvider ? ( @@ -261,16 +263,19 @@ function PaymentSelectionModal() { justifyContent={BoxJustifyContent.Center} style={styles.footer} > - + {strings('fiat_on_ramp.buying_via', { providerName: selectedProvider.name, })}{' '} = ({ if (quoteUnavailable) { return ( - + {strings('fiat_on_ramp.quote_unavailable')} @@ -89,10 +91,12 @@ const QuoteDisplay: React.FC = ({ return ( {cryptoAmount ? ( - {cryptoAmount} + + {cryptoAmount} + ) : null} {fiatAmount !== null ? ( - + {fiatAmount} ) : null} diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentMethodListItem.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentMethodListItem.test.tsx.snap index 3a032a3a15f..2fd15f8efa4 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentMethodListItem.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentMethodListItem.test.tsx.snap @@ -97,13 +97,17 @@ exports[`PaymentMethodListItem matches snapshot 1`] = ` Debit or Credit @@ -111,13 +115,17 @@ exports[`PaymentMethodListItem matches snapshot 1`] = ` 5 - 10 mins @@ -244,13 +252,17 @@ exports[`PaymentMethodListItem renders as selected when isSelected is true 1`] = Debit or Credit @@ -258,13 +270,17 @@ exports[`PaymentMethodListItem renders as selected when isSelected is true 1`] = 5 - 10 mins diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionAlert.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionAlert.test.tsx.snap index e596edd5bde..284629446c2 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionAlert.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionAlert.test.tsx.snap @@ -46,13 +46,17 @@ exports[`PaymentSelectionAlert matches snapshot with default severity 1`] = ` Something went wrong. @@ -107,13 +111,17 @@ exports[`PaymentSelectionAlert matches snapshot with warning severity 1`] = ` No payment methods are available. diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionModal.test.tsx.snap index a1734121911..f32d759fe7f 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionModal.test.tsx.snap @@ -333,31 +333,142 @@ exports[`PaymentSelectionModal matches snapshot 1`] = ` [ { "alignItems": "center", - "display": "flex", - "justifyContent": "center", - "paddingBottom": 12, - "paddingLeft": 16, - "paddingRight": 16, - "paddingTop": 12, + "flexDirection": "row", + "gap": 16, + "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, + false, undefined, ] } + testID="header" > - + + + - fiat_on_ramp.pay_with - + + + fiat_on_ramp.pay_with + + + + + + + + + + Debit or Credit @@ -669,13 +784,17 @@ exports[`PaymentSelectionModal matches snapshot 1`] = ` Debit or Credit @@ -725,13 +844,17 @@ exports[`PaymentSelectionModal matches snapshot 1`] = ` fiat_on_ramp.buying_via @@ -740,13 +863,17 @@ exports[`PaymentSelectionModal matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, + }, + undefined, + ] } > fiat_on_ramp.change_provider @@ -1099,31 +1226,142 @@ exports[`PaymentSelectionModal matches snapshot when no payment methods are avai [ { "alignItems": "center", - "display": "flex", - "justifyContent": "center", - "paddingBottom": 12, - "paddingLeft": 16, - "paddingRight": 16, - "paddingTop": 12, + "flexDirection": "row", + "gap": 16, + "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, + false, undefined, ] } + testID="header" > - + + + - fiat_on_ramp.pay_with - + + + fiat_on_ramp.pay_with + + + + + + + + + + fiat_on_ramp.no_payment_methods_available @@ -1221,13 +1463,17 @@ exports[`PaymentSelectionModal matches snapshot when no payment methods are avai fiat_on_ramp.buying_via @@ -1236,13 +1482,17 @@ exports[`PaymentSelectionModal matches snapshot when no payment methods are avai accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, + }, + undefined, + ] } > fiat_on_ramp.change_provider @@ -1595,31 +1845,142 @@ exports[`PaymentSelectionModal matches snapshot when payment methods are loading [ { "alignItems": "center", - "display": "flex", - "justifyContent": "center", - "paddingBottom": 12, - "paddingLeft": 16, - "paddingRight": 16, - "paddingTop": 12, + "flexDirection": "row", + "gap": 16, + "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, + false, undefined, ] } + testID="header" > - + + + - fiat_on_ramp.pay_with - + + + fiat_on_ramp.pay_with + + + + + + + + + + - + + + - fiat_on_ramp.pay_with - + + + fiat_on_ramp.pay_with + + + + + + + + + + Failed to fetch payment methods @@ -4005,13 +4481,17 @@ exports[`PaymentSelectionModal matches snapshot when payment methods fail to loa fiat_on_ramp.buying_via @@ -4019,13 +4499,17 @@ exports[`PaymentSelectionModal matches snapshot when payment methods fail to loa fiat_on_ramp.change_provider diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/QuoteDisplay.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/QuoteDisplay.test.tsx.snap index e661ece5428..1d723ca95d0 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/QuoteDisplay.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/QuoteDisplay.test.tsx.snap @@ -56,13 +56,17 @@ exports[`QuoteDisplay matches snapshot when quote is unavailable 1`] = ` Quote unavailable. @@ -85,13 +89,17 @@ exports[`QuoteDisplay matches snapshot with crypto and fiat 1`] = ` 0.05 ETH @@ -99,13 +107,17 @@ exports[`QuoteDisplay matches snapshot with crypto and fiat 1`] = ` $100.00 @@ -128,13 +140,17 @@ exports[`QuoteDisplay matches snapshot with crypto only 1`] = ` 1.5 USDC diff --git a/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.test.tsx b/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.test.tsx index d99d2d15ba6..96d28d1b56e 100644 --- a/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.test.tsx @@ -1,12 +1,46 @@ import React from 'react'; -import { screen } from '@testing-library/react-native'; -import ProcessingInfoModal from './ProcessingInfoModal'; +import { fireEvent, waitFor, screen } from '@testing-library/react-native'; +import InAppBrowser from 'react-native-inappbrowser-reborn'; +import ProcessingInfoModal, { + type ProcessingInfoModalParams, +} from './ProcessingInfoModal'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../../util/test/initial-root-state'; +import { MetaMetricsEvents } from '../../../../../../core/Analytics'; +const mockOnCloseBottomSheet = jest.fn((callback?: () => void) => { + callback?.(); +}); + +jest.mock( + '../../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return ReactActual.forwardRef( + ( + { + children, + testID, + }: { + children: React.ReactNode; + testID?: string; + }, + ref: React.Ref<{ onCloseBottomSheet: (cb?: () => void) => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: mockOnCloseBottomSheet, + })); + return {children}; + }, + ); + }, +); + +const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ navigate: jest.fn(), goBack: jest.fn() }), + useNavigation: () => ({ navigate: mockNavigate, goBack: jest.fn() }), })); jest.mock('react-native-safe-area-context', () => { @@ -27,17 +61,38 @@ jest.mock('react-native-inappbrowser-reborn', () => ({ open: jest.fn(), })); -jest.mock('../../../../../../util/navigation/navUtils', () => ({ - ...jest.requireActual('../../../../../../util/navigation/navUtils'), - useParams: () => ({ +const mockUseParams = jest.fn( + (): ProcessingInfoModalParams => ({ providerName: 'Transak', providerSupportUrl: 'https://transak.com/support', statusDescription: 'Card purchases typically take a few minutes. You can contact support if you have questions.', }), +); + +jest.mock('../../../../../../util/navigation/navUtils', () => ({ + ...jest.requireActual('../../../../../../util/navigation/navUtils'), + useParams: () => mockUseParams(), +})); + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); +const mockAddProperties = jest.fn(); +const mockBuild = jest.fn(); + +jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: jest.fn(() => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + })), })); -function render() { +function renderModal() { + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + }); + mockAddProperties.mockReturnValue({ build: mockBuild }); return renderWithProvider(, { state: { engine: { backgroundState } }, }); @@ -46,23 +101,29 @@ function render() { describe('ProcessingInfoModal', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseParams.mockReturnValue({ + providerName: 'Transak', + providerSupportUrl: 'https://transak.com/support', + statusDescription: + 'Card purchases typically take a few minutes. You can contact support if you have questions.', + }); }); it('renders correctly', () => { - render(); + renderModal(); expect(screen.getByTestId('processing-info-modal')).toBeOnTheScreen(); expect(screen.toJSON()).toMatchSnapshot(); }); it('renders the close button', () => { - render(); + renderModal(); expect( screen.getByTestId('processing-info-modal-close-button'), ).toBeOnTheScreen(); }); it('renders description text', () => { - render(); + renderModal(); expect( screen.getByText( 'Card purchases typically take a few minutes. You can contact support if you have questions.', @@ -71,7 +132,111 @@ describe('ProcessingInfoModal', () => { }); it('renders support button with provider name', () => { - render(); + renderModal(); expect(screen.getByText('Go to Transak support page')).toBeOnTheScreen(); }); + + it('opens InAppBrowser and closes sheet when support is pressed and browser is available', async () => { + (InAppBrowser.isAvailable as jest.Mock).mockResolvedValue(true); + (InAppBrowser.open as jest.Mock).mockResolvedValue(undefined); + + renderModal(); + + fireEvent.press(screen.getByText('Go to Transak support page')); + + await waitFor(() => { + expect(mockTrackEvent).toHaveBeenCalled(); + }); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.RAMPS_EXTERNAL_LINK_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: 'Order Details', + external_link_description: 'Provider Support', + url_domain: 'transak.com', + ramp_type: 'UNIFIED_BUY_2', + }), + ); + + await waitFor(() => { + expect(mockOnCloseBottomSheet).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(InAppBrowser.open).toHaveBeenCalledWith( + 'https://transak.com/support', + ); + }); + }); + + it('navigates to SimpleWebview when InAppBrowser is not available', async () => { + (InAppBrowser.isAvailable as jest.Mock).mockResolvedValue(false); + + renderModal(); + + fireEvent.press(screen.getByText('Go to Transak support page')); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: 'https://transak.com/support', + title: 'Transak', + }, + }); + }); + expect(mockOnCloseBottomSheet).not.toHaveBeenCalled(); + }); + + it('uses raw support URL as url_domain when URL parsing fails', async () => { + (InAppBrowser.isAvailable as jest.Mock).mockResolvedValue(true); + mockUseParams.mockReturnValue({ + providerName: 'P', + providerSupportUrl: 'not-a-valid-url', + statusDescription: 'Status', + }); + + renderModal(); + + fireEvent.press(screen.getByText('Go to P support page')); + + await waitFor(() => { + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + url_domain: 'not-a-valid-url', + }), + ); + }); + }); + + it('renders without description when statusDescription is absent', () => { + mockUseParams.mockReturnValue({ + providerName: 'MoonPay', + providerSupportUrl: 'https://moonpay.com/help', + }); + + renderModal(); + + expect( + screen.queryByText( + 'Card purchases typically take a few minutes. You can contact support if you have questions.', + ), + ).toBeNull(); + expect(screen.getByText('Go to MoonPay support page')).toBeOnTheScreen(); + }); + + it('does not open browser when providerSupportUrl is missing', () => { + mockUseParams.mockReturnValue({ + providerName: 'Transak', + statusDescription: 'Waiting', + }); + + renderModal(); + + fireEvent.press(screen.getByText('Go to Transak support page')); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(InAppBrowser.open).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); }); diff --git a/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.testIds.ts b/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.testIds.ts new file mode 100644 index 00000000000..652b62e50a9 --- /dev/null +++ b/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.testIds.ts @@ -0,0 +1,4 @@ +export const PROCESSING_INFO_MODAL_TEST_IDS = { + MODAL: 'processing-info-modal', + CLOSE_BUTTON: 'processing-info-modal-close-button', +} as const; diff --git a/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.tsx b/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.tsx index 8fc9b664e69..fca4cd18c9c 100644 --- a/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.tsx @@ -4,17 +4,16 @@ import InAppBrowser from 'react-native-inappbrowser-reborn'; import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; -import BottomSheetHeader from '../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; -import Text, { +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; +import { + Text, TextVariant, TextColor, -} from '../../../../../../component-library/components/Texts/Text'; -import Button, { - ButtonVariants, - ButtonSize, - ButtonWidthTypes, -} from '../../../../../../component-library/components/Buttons/Button'; -import { Box } from '@metamask/design-system-react-native'; + Button, + ButtonVariant, + ButtonBaseSize, + Box, +} from '@metamask/design-system-react-native'; import { strings } from '../../../../../../../locales/i18n'; import { createNavigationDetails, @@ -24,6 +23,7 @@ import Routes from '../../../../../../constants/navigation/Routes'; import { useNavigation } from '@react-navigation/native'; import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../../core/Analytics'; +import { PROCESSING_INFO_MODAL_TEST_IDS } from './ProcessingInfoModal.testIds'; export interface ProcessingInfoModalParams { providerName: string; @@ -101,18 +101,20 @@ function ProcessingInfoModal() { ref={sheetRef} shouldNavigateBack isInteractable={false} - testID="processing-info-modal" + testID={PROCESSING_INFO_MODAL_TEST_IDS.MODAL} > - {statusDescription && ( {statusDescription} @@ -122,14 +124,15 @@ function ProcessingInfoModal() { ); diff --git a/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/__snapshots__/ProcessingInfoModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/__snapshots__/ProcessingInfoModal.test.tsx.snap index 8c77cb861f2..7b28b00e45f 100644 --- a/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/__snapshots__/ProcessingInfoModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/__snapshots__/ProcessingInfoModal.test.tsx.snap @@ -1,261 +1,252 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ProcessingInfoModal renders correctly 1`] = ` - + + + + + + + + + + + + Card purchases typically take a few minutes. You can contact support if you have questions. + + + - - - - - - - - - - - - - - - - Card purchases typically take a few minutes. You can contact support if you have questions. - - - - - - Go to Transak support page - - - + Go to Transak support page + diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx index 1f6237ce365..96b0de78623 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx @@ -61,11 +61,14 @@ const defaultMockController: UseRampsControllerResult = { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), + addPrecreatedOrder: jest.fn(), removeOrder: jest.fn(), refreshOrder: jest.fn(), getOrderFromCallback: jest.fn(), @@ -106,6 +109,21 @@ function createMockQuote( const mockOnBack = jest.fn(); +const moonpayProvider: Provider = { + id: '/providers/moonpay', + name: 'MoonPay', + environmentType: 'PRODUCTION', + description: 'MoonPay', + hqAddress: 'MP', + links: [], + logos: { + light: '', + dark: '', + height: 24, + width: 90, + }, +}; + interface RenderOptions { providers?: Provider[]; selectedProvider?: Provider | null; @@ -113,6 +131,7 @@ interface RenderOptions { quotesLoading?: boolean; quotesError?: string | null; showQuotes?: boolean; + ordersProviders?: string[]; } function renderWithProvider( @@ -125,6 +144,7 @@ function renderWithProvider( quotesLoading = false, quotesError = null, showQuotes, + ordersProviders, } = options; jest.mocked(useRampsController).mockReturnValue({ @@ -141,6 +161,7 @@ function renderWithProvider( onProviderSelect={jest.fn()} onBack={mockOnBack} {...(showQuotes !== undefined && { showQuotes })} + {...(ordersProviders !== undefined && { ordersProviders })} /> ), { @@ -255,6 +276,40 @@ describe('ProviderSelection', () => { expect(toJSON()).toMatchSnapshot(); }); + it('filters out custom-action quotes when displaying provider quote', async () => { + const transakQuote = createMockQuote('/providers/transak', 'Transak'); + const customActionQuote = { + ...transakQuote, + quote: { ...transakQuote.quote, isCustomAction: true }, + }; + + jest.mocked(useRampsController).mockReturnValue({ + ...defaultMockController, + userRegion: mockUserRegion, + selectedToken: mockSelectedToken, + providers: mockProviders, + selectedProvider: mockProviders[0], + }); + + const { getByText, toJSON } = renderWithProvider( + mockProviders, + mockProviders[0], + { + quotes: { + success: [customActionQuote, transakQuote], + sorted: [], + error: [], + customActions: [], + }, + }, + ); + + await waitFor(() => { + expect(getByText('Transak')).toBeTruthy(); + }); + expect(toJSON()).toMatchSnapshot(); + }); + it('filters out quotes for providers not in the providers array', async () => { const transakQuote = createMockQuote('/providers/transak', 'Transak'); const stripeQuote = createMockQuote('/providers/stripe', 'Stripe'); @@ -285,4 +340,100 @@ describe('ProviderSelection', () => { }); expect(queryByText('Stripe')).toBeNull(); }); + + it('renders empty state when there are no providers', () => { + const { getByText } = renderWithProvider([], null, { + showQuotes: true, + quotes: { + success: [], + sorted: [], + error: [], + customActions: [], + }, + }); + + expect(getByText('No providers available.')).toBeOnTheScreen(); + }); + + it('renders Other options separator between quoted and non-quoted providers', async () => { + jest.mocked(useRampsController).mockReturnValue({ + ...defaultMockController, + userRegion: mockUserRegion, + selectedToken: mockSelectedToken, + providers: [transakProvider, moonpayProvider], + selectedProvider: transakProvider, + }); + + const transakQuote = createMockQuote('/providers/transak', 'Transak'); + + const { getByText } = renderWithProvider( + [transakProvider, moonpayProvider], + transakProvider, + { + quotes: { + success: [transakQuote], + sorted: [{ sortBy: 'reliability', ids: ['/providers/transak'] }], + error: [], + customActions: [], + }, + }, + ); + + await waitFor(() => { + expect(getByText('Other options')).toBeOnTheScreen(); + }); + expect(getByText('MoonPay')).toBeOnTheScreen(); + }); + + it('shows Best rate tag when quote metadata has isBestRate', async () => { + jest.mocked(useRampsController).mockReturnValue({ + ...defaultMockController, + userRegion: mockUserRegion, + selectedToken: mockSelectedToken, + providers: mockProviders, + selectedProvider: null, + }); + + const bestRateQuote = { + ...createMockQuote('/providers/transak', 'Transak'), + metadata: { tags: { isBestRate: true } }, + }; + + const { getByText } = renderWithProvider(mockProviders, null, { + quotes: { + success: [bestRateQuote], + sorted: [], + error: [], + customActions: [], + }, + }); + + await waitFor(() => { + expect(getByText('Best rate')).toBeOnTheScreen(); + }); + }); + + it('shows Previously used tag when provider is in ordersProviders', async () => { + jest.mocked(useRampsController).mockReturnValue({ + ...defaultMockController, + userRegion: mockUserRegion, + selectedToken: mockSelectedToken, + providers: mockProviders, + selectedProvider: null, + }); + + const { getByText } = renderWithProvider(mockProviders, null, { + ordersProviders: ['/providers/transak'], + quotes: { + success: [createMockQuote('/providers/transak', 'Transak')], + sorted: [], + error: [], + customActions: [], + }, + }); + + await waitFor(() => { + expect(getByText('Previously used')).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx index e21facaca68..889b2da507f 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx @@ -235,14 +235,19 @@ const ProviderSelection: React.FC = ({ } const { provider } = item; + const isCustomActionQuote = (q: Quote) => + Boolean((q.quote as { isCustomAction?: boolean })?.isCustomAction); const matchedQuote = quotes?.success?.find( (q) => q.provider === provider.id && (!selectedPaymentMethod || - q.quote?.paymentMethod === selectedPaymentMethod.id), + q.quote?.paymentMethod === selectedPaymentMethod.id) && + !isCustomActionQuote(q), + ) ?? + quotes?.success?.find( + (q) => q.provider === provider.id && !isCustomActionQuote(q), ) ?? - quotes?.success?.find((q) => q.provider === provider.id) ?? null; const amountOut = matchedQuote?.quote?.amountOut; const cryptoAmount = @@ -269,7 +274,7 @@ const ProviderSelection: React.FC = ({ accessible > - + {provider.name} {tag ? ( diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.test.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.test.tsx index e970e514222..b476f23d4a5 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent, waitFor } from '@testing-library/react-native'; +import { fireEvent } from '@testing-library/react-native'; import ProviderSelectionModal, { type ProviderSelectionModalParams, } from './ProviderSelectionModal'; @@ -127,6 +127,20 @@ jest.mock('../../../hooks/useRampAccountAddress', () => ({ default: () => '0x123', })); +const mockUseRampsQuotes = jest.fn((_opts?: unknown) => ({ + data: null, + loading: false, + status: 'idle' as const, + isSuccess: false, + error: null, + getQuotes: mockGetQuotes, + getBuyWidgetData: jest.fn(), +})); + +jest.mock('../../../hooks/useRampsQuotes', () => ({ + useRampsQuotes: (opts: unknown) => mockUseRampsQuotes(opts), +})); + let capturedOnClose: ((hasPendingAction?: boolean) => void) | undefined; jest.mock( @@ -170,46 +184,42 @@ function renderWithProvider(component: React.ComponentType) { describe('ProviderSelectionModal', () => { beforeEach(() => { jest.clearAllMocks(); - mockGetQuotes.mockResolvedValue({ - success: [], - error: [], - sorted: [], - customActions: [], + mockUseRampsQuotes.mockReturnValue({ + data: null, + loading: false, + status: 'idle' as const, + isSuccess: false, + error: null, + getQuotes: mockGetQuotes, + getBuyWidgetData: jest.fn(), }); mockUseRampsController.mockImplementation(() => defaultControllerReturn); mockUseParams.mockReturnValue({ amount: 100 }); }); - it('matches snapshot', async () => { + it('matches snapshot', () => { const { toJSON } = renderWithProvider(ProviderSelectionModal); - await waitFor(() => { - expect(mockGetQuotes).toHaveBeenCalled(); - }); expect(toJSON()).toMatchSnapshot(); }); - it('calls getQuotes with provider params on mount', async () => { + it('calls useRampsQuotes with provider params on mount', () => { renderWithProvider(ProviderSelectionModal); - await waitFor(() => { - expect(mockGetQuotes).toHaveBeenCalledWith({ + expect(mockUseRampsQuotes).toHaveBeenCalledWith( + expect.objectContaining({ amount: 100, walletAddress: '0x123', assetId: 'eip155:1/slip44:60', providers: ['/providers/transak', '/providers/moonpay'], paymentMethods: ['/payments/debit-credit-card-1'], forceRefresh: true, - }); - }); + }), + ); }); - it('calls setSelectedProvider and goBack when provider is selected', async () => { + it('calls setSelectedProvider and goBack when provider is selected', () => { const { getByText } = renderWithProvider(ProviderSelectionModal); - await waitFor(() => { - expect(mockGetQuotes).toHaveBeenCalled(); - }); - fireEvent.press(getByText('Transak')); expect(mockSetSelectedProvider).toHaveBeenCalledWith( @@ -218,13 +228,9 @@ describe('ProviderSelectionModal', () => { expect(mockGoBack).toHaveBeenCalled(); }); - it('calls goBack when back button is pressed', async () => { + it('calls goBack when back button is pressed', () => { const { getByTestId } = renderWithProvider(ProviderSelectionModal); - await waitFor(() => { - expect(mockGetQuotes).toHaveBeenCalled(); - }); - fireEvent.press(getByTestId('button-icon')); expect(mockGoBack).toHaveBeenCalled(); @@ -237,7 +243,7 @@ describe('ProviderSelectionModal', () => { }); renderWithProvider(ProviderSelectionModal); - expect(mockGetQuotes).not.toHaveBeenCalled(); + expect(mockUseRampsQuotes).toHaveBeenCalledWith(null); }); it('filters providers by assetId when provided', () => { diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx index b6ef67590ed..8a1d0b81bb7 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx @@ -17,7 +17,7 @@ import { useRampsController } from '../../../hooks/useRampsController'; import { useRampsQuotes } from '../../../hooks/useRampsQuotes'; import useRampAccountAddress from '../../../hooks/useRampAccountAddress'; import { getOrdersProviders } from '../../../../../../reducers/fiatOrders'; -import { selectRampsOrders } from '../../../../../../selectors/rampsController'; +import { selectRampsOrdersForSelectedAccountGroup } from '../../../../../../selectors/rampsController'; import { completedOrdersFromRampsOrders } from '../../../utils/determinePreferredProvider'; import { useStyles } from '../../../../../hooks/useStyles'; import styleSheet from './ProviderSelectionModal.styles'; @@ -59,7 +59,9 @@ function ProviderSelectionModal() { } = useRampsController(); const legacyOrdersProviders = useSelector(getOrdersProviders); - const controllerOrders = useSelector(selectRampsOrders); + const controllerOrders = useSelector( + selectRampsOrdersForSelectedAccountGroup, + ); const ordersProviders = useMemo(() => { const v2ProviderIds = completedOrdersFromRampsOrders(controllerOrders).map( diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelection.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelection.test.tsx.snap index 782f2ac4752..c4734668918 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelection.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelection.test.tsx.snap @@ -1,5 +1,671 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`ProviderSelection filters out custom-action quotes when displaying provider quote 1`] = ` + + + + + + + + + + + + + ProviderSelection + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Providers + + + + + + + + + + + + + + + + Transak + + + + + + + $98.00 + + + + + + + + + + + + + + + + + + + + + +`; + exports[`ProviderSelection matches snapshot when no quotes are available 1`] = ` Failed to load quotes @@ -2532,8 +3202,8 @@ exports[`ProviderSelection matches snapshot when quotes fail to load 1`] = ` { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, - "fontWeight": 500, + "fontSize": 16, + "fontWeight": 400, "letterSpacing": 0, "lineHeight": 24, }, @@ -3150,8 +3820,8 @@ exports[`ProviderSelection renders providers directly when quotes load but none { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, - "fontWeight": 500, + "fontSize": 16, + "fontWeight": 400, "letterSpacing": 0, "lineHeight": 24, }, diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelectionModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelectionModal.test.tsx.snap index 2ac38e7b82d..b1896a47e72 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelectionModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelectionModal.test.tsx.snap @@ -597,8 +597,8 @@ exports[`ProviderSelectionModal matches snapshot 1`] = ` { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, - "fontWeight": 500, + "fontSize": 16, + "fontWeight": 400, "letterSpacing": 0, "lineHeight": 24, }, @@ -679,8 +679,8 @@ exports[`ProviderSelectionModal matches snapshot 1`] = ` { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, - "fontWeight": 500, + "fontSize": 16, + "fontWeight": 400, "letterSpacing": 0, "lineHeight": 24, }, diff --git a/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx b/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx index 3483401c002..31d2560c975 100644 --- a/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx @@ -22,7 +22,7 @@ import { ToastVariants, } from '../../../../../../component-library/components/Toast'; import Logger from '../../../../../../util/Logger'; -import BottomSheetHeader from '../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; import MenuItem from '../../../components/MenuItem'; import { useRampsController } from '../../../hooks/useRampsController'; import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; @@ -194,9 +194,10 @@ function SettingsModal() { return ( - - {strings('fiat_on_ramp.build_quote_settings_modal.title')} - + - - Settings - + + Settings + + - - + diff --git a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx index 8196300ba7f..bd898802e9d 100644 --- a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx @@ -244,4 +244,57 @@ describe('TokenNotAvailableModal', () => { }); expect(mockTrackEvent).toHaveBeenCalledTimes(1); }); + + describe('buyFlowOrigin: tokenInfo', () => { + beforeEach(() => { + mockUseParams.mockReturnValue({ + assetId: MOCK_ASSET_ID, + buyFlowOrigin: 'tokenInfo', + }); + }); + + it('navigates to Tokens Full View when Change token is pressed', () => { + const { getByText } = render(TokenNotAvailableModal); + + fireEvent.press(getByText('Change token')); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledWith(expect.any(Function)); + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.TOKENS_FULL_VIEW); + }); + + it('calls goBack once when modal is dismissed without a pending action', () => { + render(TokenNotAvailableModal); + + capturedOnClose?.(false); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + describe('buyFlowOrigin: homeTokenList', () => { + beforeEach(() => { + mockUseParams.mockReturnValue({ + assetId: MOCK_ASSET_ID, + buyFlowOrigin: 'homeTokenList', + }); + }); + + it('navigates to Home when Change token is pressed', () => { + const { getByText } = render(TokenNotAvailableModal); + + fireEvent.press(getByText('Change token')); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledWith(expect.any(Function)); + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME); + }); + + it('navigates to Home when modal is dismissed without a pending action', () => { + render(TokenNotAvailableModal); + + capturedOnClose?.(false); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME); + }); + }); }); diff --git a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.testIds.ts b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.testIds.ts new file mode 100644 index 00000000000..e08ef38b7b6 --- /dev/null +++ b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.testIds.ts @@ -0,0 +1,6 @@ +export const TOKEN_NOT_AVAILABLE_MODAL_TEST_IDS = { + MODAL: 'token-unavailable-for-provider-modal', + CLOSE_BUTTON: 'bottomsheetheader-close-button', + CHANGE_TOKEN_BUTTON: 'token-unavailable-change-token-button', + CHANGE_PROVIDER_BUTTON: 'token-unavailable-change-provider-button', +} as const; diff --git a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx index 7776134fd78..e8fd544c92e 100644 --- a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx @@ -1,19 +1,18 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import Text, { +import { + Text, TextVariant, TextColor, -} from '../../../../../../component-library/components/Texts/Text'; + Button, + ButtonVariant, + ButtonBaseSize, +} from '@metamask/design-system-react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; -import BottomSheetHeader from '../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../../component-library/components/Buttons/Button'; +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; import { strings } from '../../../../../../../locales/i18n'; import { createNavigationDetails, @@ -26,9 +25,14 @@ import { useRampsController } from '../../../hooks/useRampsController'; import { createProviderSelectionModalNavigationDetails } from '../ProviderSelectionModal'; import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../../core/Analytics'; +import { TOKEN_NOT_AVAILABLE_MODAL_TEST_IDS } from './TokenNotAvailableModal.testIds'; + +import type { BuyFlowOrigin } from '../../BuildQuote/BuildQuote'; export interface TokenNotAvailableModalParams { assetId: string; + /** Which flow the user used to enter the Buy screen. */ + buyFlowOrigin?: BuyFlowOrigin; } export const createTokenNotAvailableModalNavigationDetails = @@ -39,7 +43,7 @@ export const createTokenNotAvailableModalNavigationDetails = function TokenNotAvailableModal() { const { trackEvent, createEventBuilder } = useAnalytics(); - const { assetId } = useParams(); + const { assetId, buyFlowOrigin } = useParams(); const navigation = useNavigation(); const sheetRef = useRef(null); const { styles } = useStyles(styleSheet, {}); @@ -71,11 +75,25 @@ function TokenNotAvailableModal() { .build(), ); sheetRef.current?.onCloseBottomSheet(() => { - navigation.navigate(Routes.RAMP.TOKEN_SELECTION, { - screen: Routes.RAMP.TOKEN_SELECTION, - }); + if (buyFlowOrigin === 'tokenInfo') { + // Token Info buy flow: return to the Tokens Full View screen + navigation.navigate(Routes.WALLET.TOKENS_FULL_VIEW as never); + } else if (buyFlowOrigin === 'homeTokenList') { + // Home token list buy flow: return to home screen + navigation.navigate(Routes.WALLET.HOME as never); + } else { + navigation.navigate(Routes.RAMP.TOKEN_SELECTION, { + screen: Routes.RAMP.TOKEN_SELECTION, + }); + } }); - }, [navigation, selectedProvider?.name, trackEvent, createEventBuilder]); + }, [ + navigation, + buyFlowOrigin, + selectedProvider?.name, + trackEvent, + createEventBuilder, + ]); const handleChangeProvider = useCallback(() => { trackEvent( @@ -118,12 +136,22 @@ function TokenNotAvailableModal() { const handleDismiss = useCallback( (hasPendingAction?: boolean) => { if (!hasPendingAction) { - navigation.navigate(Routes.RAMP.TOKEN_SELECTION, { - screen: Routes.RAMP.TOKEN_SELECTION, - }); + if (buyFlowOrigin === 'tokenInfo') { + // Token Info buy flow: pop back through the ramp flow to the + // existing Asset screen. BottomSheet already performs one goBack + // when shouldNavigateBack is true; we need one more to exit ramp. + navigation.goBack(); + } else if (buyFlowOrigin === 'homeTokenList') { + // Home token list buy flow: return to home screen + navigation.navigate(Routes.WALLET.HOME as never); + } else { + navigation.navigate(Routes.RAMP.TOKEN_SELECTION, { + screen: Routes.RAMP.TOKEN_SELECTION, + }); + } } }, - [navigation], + [navigation, buyFlowOrigin], ); return ( @@ -131,19 +159,18 @@ function TokenNotAvailableModal() { ref={sheetRef} shouldNavigateBack onClose={handleDismiss} - testID="token-unavailable-for-provider-modal" + testID={TOKEN_NOT_AVAILABLE_MODAL_TEST_IDS.MODAL} > - - - {strings('fiat_on_ramp.token_unavailable_modal.title')} - - + closeButtonProps={{ + testID: TOKEN_NOT_AVAILABLE_MODAL_TEST_IDS.CLOSE_BUTTON, + }} + /> - + {strings('fiat_on_ramp.token_unavailable_modal.description', { token: tokenName, provider: providerName, @@ -154,25 +181,25 @@ function TokenNotAvailableModal() { diff --git a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/__snapshots__/TokenNotAvailableModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/__snapshots__/TokenNotAvailableModal.test.tsx.snap index fcf5121f41c..6264e99772b 100644 --- a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/__snapshots__/TokenNotAvailableModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/__snapshots__/TokenNotAvailableModal.test.tsx.snap @@ -320,11 +320,11 @@ exports[`TokenNotAvailableModal matches snapshot 1`] = ` "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -348,58 +348,109 @@ exports[`TokenNotAvailableModal matches snapshot 1`] = ` ] } > - - Not available - + + Not available + + - - + @@ -414,13 +465,17 @@ exports[`TokenNotAvailableModal matches snapshot 1`] = ` USD Coin is not available with Transak in your region. @@ -443,45 +498,93 @@ exports[`TokenNotAvailableModal matches snapshot 1`] = ` } } > - Change token - + - Change provider - + @@ -861,11 +1012,11 @@ exports[`TokenNotAvailableModal matches snapshot with missing provider and token "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -889,58 +1040,109 @@ exports[`TokenNotAvailableModal matches snapshot with missing provider and token ] } > - - Not available - + + Not available + + - - + @@ -955,13 +1157,17 @@ exports[`TokenNotAvailableModal matches snapshot with missing provider and token is not available with in your region. @@ -984,45 +1190,93 @@ exports[`TokenNotAvailableModal matches snapshot with missing provider and token } } > - Change token - + - Change provider - + diff --git a/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/UnsupportedTokenModal.tsx b/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/UnsupportedTokenModal.tsx index fbcf39f45e0..21333b6581b 100644 --- a/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/UnsupportedTokenModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/UnsupportedTokenModal.tsx @@ -1,13 +1,11 @@ import React, { useRef } from 'react'; import { View } from 'react-native'; -import Text, { - TextVariant, -} from '../../../../../../component-library/components/Texts/Text'; +import { Text, TextVariant } from '@metamask/design-system-react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; -import BottomSheetHeader from '../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; import { strings } from '../../../../../../../locales/i18n'; import styleSheet from './UnsupportedTokenModal.styles'; import { useStyles } from '../../../../../hooks/useStyles'; @@ -26,15 +24,14 @@ function UnsupportedTokenModal() { return ( - sheetRef.current?.onCloseBottomSheet()} closeButtonProps={{ testID: 'bottomsheetheader-close-button' }} - > - {strings('deposit.token_modal.unsupported_token_title')} - + /> - + {strings('deposit.token_modal.unsupported_token_description')} diff --git a/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/__snapshots__/UnsupportedTokenModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/__snapshots__/UnsupportedTokenModal.test.tsx.snap index b8a0d82e294..86b6446941c 100644 --- a/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/__snapshots__/UnsupportedTokenModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/__snapshots__/UnsupportedTokenModal.test.tsx.snap @@ -320,11 +320,11 @@ exports[`UnsupportedTokenModal renders the modal with correct title and descript "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -348,65 +348,109 @@ exports[`UnsupportedTokenModal renders the modal with correct title and descript ] } > - - Not available - + + Not available + + - - + @@ -421,13 +465,17 @@ exports[`UnsupportedTokenModal renders the modal with correct title and descript This token may not be available in your region or supported by any local payment providers diff --git a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx index ce6144e1834..481c0cfa3c3 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx @@ -36,9 +36,10 @@ jest.mock('../../../../../util/navigation/navUtils', () => ({ (..._args: unknown[]) => (params: unknown) => ['MockRoute', params], useParams: () => ({ - quote: { quoteId: 'test-quote-id', fiatAmount: 100 }, + quote: { quoteId: 'test-quote-id', fiatAmount: 127.37 }, kycUrl: 'https://kyc.example.com', workFlowRunId: 'wf-123', + amount: 25, }), })); @@ -71,6 +72,7 @@ describe('V2AdditionalVerification', () => { expect(mockNavigateToKycWebview).toHaveBeenCalledWith({ kycUrl: 'https://kyc.example.com', + amount: 25, }); }); diff --git a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx index 3137b730f81..00a4377856f 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx @@ -23,11 +23,14 @@ interface V2AdditionalVerificationParams { quote: TransakBuyQuote; kycUrl: string; workFlowRunId: string; + /** From BuildQuote route; keeps stack amount in sync when opening KYC webview. */ + amount?: number; } const V2AdditionalVerification = () => { const navigation = useNavigation(); - const { kycUrl } = useParams(); + const { kycUrl, amount: userEnteredAmount } = + useParams(); const { styles, theme } = useStyles(styleSheet, {}); @@ -46,8 +49,8 @@ const V2AdditionalVerification = () => { }, [navigation, theme]); const handleContinuePress = useCallback(() => { - navigateToKycWebview({ kycUrl }); - }, [navigateToKycWebview, kycUrl]); + navigateToKycWebview({ kycUrl, amount: userEnteredAmount }); + }, [navigateToKycWebview, kycUrl, userEnteredAmount]); return ( diff --git a/app/components/UI/Ramp/Views/NativeFlow/BankDetails.testIds.ts b/app/components/UI/Ramp/Views/NativeFlow/BankDetails.testIds.ts new file mode 100644 index 00000000000..ca48ffc13b0 --- /dev/null +++ b/app/components/UI/Ramp/Views/NativeFlow/BankDetails.testIds.ts @@ -0,0 +1,4 @@ +export const BANK_DETAILS_TEST_IDS = { + REFRESH_CONTROL_SCROLLVIEW: 'bank-details-refresh-control-scrollview', + MAIN_ACTION_BUTTON: 'main-action-button', +} as const; diff --git a/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx b/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx index f9a0750f3ec..f28632c910f 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx @@ -22,6 +22,7 @@ import BankDetailRow from '../../Deposit/components/BankDetailRow'; import { RampsOrderStatus, type TransakDepositOrder, + normalizeProviderCode, } from '@metamask/ramps-controller'; import { useTheme } from '../../../../../util/theme'; import Button, { @@ -38,6 +39,7 @@ import { selectTokens } from '../../../../../selectors/rampsController'; import { parseUserFacingError } from '../../utils/parseUserFacingError'; import { useRampsOrders } from '../../hooks/useRampsOrders'; import { useSelector } from 'react-redux'; +import { BANK_DETAILS_TEST_IDS } from './BankDetails.testIds'; import { isHttpUnauthorized } from '../../utils/isHttpUnauthorized'; export interface BankDetailsParams { @@ -113,10 +115,7 @@ const V2BankDetails = () => { setDepositOrder(updatedDepositOrder); } - const providerCode = (order.provider?.id ?? '').replace( - '/providers/', - '', - ); + const providerCode = normalizeProviderCode(order.provider?.id ?? ''); await refreshOrder( providerCode, order.providerOrderId, @@ -315,7 +314,7 @@ const V2BankDetails = () => { return ( { style={styles.button} variant={ButtonVariants.Primary} onPress={handleBankTransferSent} - testID="main-action-button" + testID={BANK_DETAILS_TEST_IDS.MAIN_ACTION_BUTTON} label={strings('deposit.bank_details.button')} size={ButtonSize.Lg} disabled={isLoadingCancelOrder || isLoadingConfirmPayment} diff --git a/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.testIds.ts b/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.testIds.ts new file mode 100644 index 00000000000..c1e651be87d --- /dev/null +++ b/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.testIds.ts @@ -0,0 +1,9 @@ +export const BASIC_INFO_TEST_IDS = { + LOGOUT_BUTTON: 'basic-info-logout-button', + FIRST_NAME_INPUT: 'first-name-input', + LAST_NAME_INPUT: 'last-name-input', + DATE_OF_BIRTH_INPUT: 'date-of-birth-input', + SSN_INFO_BUTTON: 'ssn-info-button', + SSN_INPUT: 'ssn-input', + CONTINUE_BUTTON: 'continue-button', +} as const; diff --git a/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx b/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx index b02057c753b..48156f32b8b 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx @@ -40,6 +40,7 @@ import { useRampsUserRegion } from '../../hooks/useRampsUserRegion'; import type { TransakBuyQuote } from '@metamask/ramps-controller'; import type { AddressFormData } from '../../Deposit/Views/EnterAddress/EnterAddress'; import { parseUserFacingError } from '../../utils/parseUserFacingError'; +import { BASIC_INFO_TEST_IDS } from './BasicInfo.testIds'; export interface BasicInfoFormData { firstName: string; @@ -339,7 +340,7 @@ const V2BasicInfo = (): JSX.Element => { label: strings('deposit.basic_info.login_with_email'), onPress: handleLogout, labelTextVariant: TextVariant.BodyMD, - testID: 'basic-info-logout-button', + testID: BASIC_INFO_TEST_IDS.LOGOUT_BUTTON, } : undefined } @@ -357,7 +358,7 @@ const V2BasicInfo = (): JSX.Element => { )} error={errors.firstName} returnKeyType="next" - testID="first-name-input" + testID={BASIC_INFO_TEST_IDS.FIRST_NAME_INPUT} containerStyle={styles.nameInputContainer} ref={firstNameInputRef} autoComplete="given-name" @@ -376,7 +377,7 @@ const V2BasicInfo = (): JSX.Element => { )} error={errors.lastName} returnKeyType="next" - testID="last-name-input" + testID={BASIC_INFO_TEST_IDS.LAST_NAME_INPUT} containerStyle={styles.nameInputContainer} ref={lastNameInputRef} autoComplete="family-name" @@ -441,7 +442,7 @@ const V2BasicInfo = (): JSX.Element => { }} ref={dateInputRef} textFieldProps={{ - testID: 'date-of-birth-input', + testID: BASIC_INFO_TEST_IDS.DATE_OF_BIRTH_INPUT, }} /> {regionIsoCode === 'US' && ( @@ -453,7 +454,7 @@ const V2BasicInfo = (): JSX.Element => { { onChangeText={handleFieldChange('ssn')} error={errors.ssn} returnKeyType="done" - testID="ssn-input" + testID={BASIC_INFO_TEST_IDS.SSN_INPUT} ref={ssnInputRef} autoComplete="off" textContentType="none" @@ -494,7 +495,7 @@ const V2BasicInfo = (): JSX.Element => { width={ButtonWidthTypes.Full} isDisabled={loading || !!error} loading={loading} - testID="continue-button" + testID={BASIC_INFO_TEST_IDS.CONTINUE_BUTTON} /> diff --git a/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.testIds.ts b/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.testIds.ts new file mode 100644 index 00000000000..5a9e94cbae3 --- /dev/null +++ b/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.testIds.ts @@ -0,0 +1,9 @@ +export const ENTER_ADDRESS_TEST_IDS = { + ADDRESS_LINE_1_INPUT: 'address-line-1-input', + ADDRESS_LINE_2_INPUT: 'address-line-2-input', + CITY_INPUT: 'city-input', + STATE_INPUT: 'state-input', + POSTAL_CODE_INPUT: 'postal-code-input', + COUNTRY_INPUT: 'country-input', + CONTINUE_BUTTON: 'address-continue-button', +} as const; diff --git a/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx b/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx index 4461b14526a..170a75b94b6 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx @@ -32,6 +32,7 @@ import { useTransakRouting } from '../../hooks/useTransakRouting'; import type { TransakBuyQuote } from '@metamask/ramps-controller'; import type { BasicInfoFormData } from './BasicInfo'; import { parseUserFacingError } from '../../utils/parseUserFacingError'; +import { ENTER_ADDRESS_TEST_IDS } from './EnterAddress.testIds'; export interface AddressFormData { addressLine1: string; @@ -253,7 +254,7 @@ const V2EnterAddress = (): JSX.Element => { )} error={errors.addressLine1} returnKeyType="next" - testID="address-line-1-input" + testID={ENTER_ADDRESS_TEST_IDS.ADDRESS_LINE_1_INPUT} ref={addressLine1InputRef} autoComplete="address-line1" textContentType="fullStreetAddress" @@ -271,7 +272,7 @@ const V2EnterAddress = (): JSX.Element => { )} error={errors.addressLine2} returnKeyType="next" - testID="address-line-2-input" + testID={ENTER_ADDRESS_TEST_IDS.ADDRESS_LINE_2_INPUT} ref={addressLine2InputRef} autoComplete="address-line2" textContentType="fullStreetAddress" @@ -290,7 +291,7 @@ const V2EnterAddress = (): JSX.Element => { )} error={errors.city} returnKeyType="next" - testID="city-input" + testID={ENTER_ADDRESS_TEST_IDS.CITY_INPUT} containerStyle={styles.nameInputContainer} ref={cityInputRef} textContentType="addressCity" @@ -303,7 +304,7 @@ const V2EnterAddress = (): JSX.Element => { placeholder={strings('deposit.enter_address.state')} value={formData.state} error={errors.state} - testID="state-input" + testID={ENTER_ADDRESS_TEST_IDS.STATE_INPUT} containerStyle={styles.nameInputContainer} isDisabled /> @@ -319,7 +320,7 @@ const V2EnterAddress = (): JSX.Element => { })} error={errors.postCode} returnKeyType="done" - testID="postal-code-input" + testID={ENTER_ADDRESS_TEST_IDS.POSTAL_CODE_INPUT} containerStyle={styles.nameInputContainer} ref={postCodeInputRef} autoComplete="postal-code" @@ -334,7 +335,7 @@ const V2EnterAddress = (): JSX.Element => { value={userRegion?.country?.name || ''} error={errors.countryCode} returnKeyType="done" - testID="country-input" + testID={ENTER_ADDRESS_TEST_IDS.COUNTRY_INPUT} containerStyle={styles.nameInputContainer} isDisabled numberOfLines={1} @@ -360,7 +361,7 @@ const V2EnterAddress = (): JSX.Element => { width={ButtonWidthTypes.Full} isDisabled={loading || !!error} loading={loading} - testID="address-continue-button" + testID={ENTER_ADDRESS_TEST_IDS.CONTINUE_BUTTON} /> diff --git a/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.testIds.ts b/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.testIds.ts new file mode 100644 index 00000000000..d6c93368775 --- /dev/null +++ b/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.testIds.ts @@ -0,0 +1,3 @@ +export const KYC_PROCESSING_TEST_IDS = { + ACTIVITY_INDICATOR: 'activity-indicator', +} as const; diff --git a/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx b/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx index 5c9176b55f1..5b39160a9ea 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx @@ -32,6 +32,7 @@ import type { TransakUserDetails, } from '@metamask/ramps-controller'; import { parseUserFacingError } from '../../utils/parseUserFacingError'; +import { KYC_PROCESSING_TEST_IDS } from './KycProcessing.testIds'; interface V2KycProcessingParams { quote: TransakBuyQuote; @@ -248,7 +249,7 @@ const V2KycProcessing = () => { {strings('deposit.kyc_processing.heading')} diff --git a/app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.testIds.ts b/app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.testIds.ts new file mode 100644 index 00000000000..d40b6bafcf0 --- /dev/null +++ b/app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.testIds.ts @@ -0,0 +1,3 @@ +export const ORDER_PROCESSING_TEST_IDS = { + MAIN_ACTION_BUTTON: 'main-action-button', +} as const; diff --git a/app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.tsx b/app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.tsx index 534ecae8221..b6f6288b7ab 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.tsx @@ -19,6 +19,7 @@ import Button, { ButtonVariants, } from '../../../../../component-library/components/Buttons/Button'; import Loader from '../../../../../component-library/components-temp/Loader/Loader'; +import { ORDER_PROCESSING_TEST_IDS } from './OrderProcessing.testIds'; export interface OrderProcessingParams { orderId: string; @@ -101,7 +102,7 @@ const V2OrderProcessing = () => { variant={ButtonVariants.Primary} size={ButtonSize.Lg} onPress={handleMainAction} - testID="main-action-button" + testID={ORDER_PROCESSING_TEST_IDS.MAIN_ACTION_BUTTON} label={ order.state === FIAT_ORDER_STATES.CANCELLED || order.state === FIAT_ORDER_STATES.FAILED diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx index 099eedd049b..caabda17194 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx @@ -7,6 +7,14 @@ import { type RampsOrder, RampsOrderStatus } from '@metamask/ramps-controller'; import Clipboard from '@react-native-clipboard/clipboard'; import InAppBrowser from 'react-native-inappbrowser-reborn'; +type RampsOrderWithPaymentDetails = RampsOrder & { + paymentDetails: { + fiatCurrency: string; + paymentMethod: string; + fields: { name: string; id: string; value: string }[]; + }[]; +}; + const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -82,12 +90,24 @@ describe('OrderContent', () => { const pendingOrder: RampsOrder = { ...mockOrder, fiatAmount: 0, + cryptoAmount: 0, status: RampsOrderStatus.Pending, }; renderOrder(pendingOrder); expect(screen.toJSON()).toMatchSnapshot(); }); + it('shows placeholder for token amount when cryptoAmount is 0 or missing', () => { + const orderWithZeroCrypto: RampsOrder = { + ...mockOrder, + cryptoAmount: 0, + fiatAmount: 100, + status: RampsOrderStatus.Pending, + }; + renderOrder(orderWithZeroCrypto); + expect(screen.toJSON()).toMatchSnapshot(); + }); + it('copies order ID to clipboard when order ID is tapped', () => { renderOrder(mockOrder); const copyButton = screen.getByText('...abc123').parent; @@ -166,6 +186,144 @@ describe('OrderContent', () => { ).toBeOnTheScreen(); }); + it('does not render bank details section when paymentDetails is absent', () => { + renderOrder(mockOrder); + + expect(screen.queryByText('To complete your order')).toBeNull(); + }); + + it('does not render bank details section when paymentDetails has no matching fields', () => { + const orderWithPaymentDetails: RampsOrderWithPaymentDetails = { + ...mockOrder, + paymentDetails: [ + { + fiatCurrency: 'USD', + paymentMethod: 'credit_debit_card', + fields: [], + }, + ], + }; + + renderOrder(orderWithPaymentDetails); + + expect(screen.queryByText('To complete your order')).toBeNull(); + }); + + it('renders bank details section when paymentDetails has bank transfer fields', () => { + const orderWithPaymentDetails: RampsOrderWithPaymentDetails = { + ...mockOrder, + paymentDetails: [ + { + fiatCurrency: 'USD', + paymentMethod: 'manual_bank_transfer', + fields: [ + { name: 'Amount', id: 'amount', value: '$100.00' }, + { + name: 'Routing Number', + id: 'routingNumber', + value: '021000021', + }, + { + name: 'Account Number', + id: 'accountNumber', + value: '1234567890', + }, + ], + }, + ], + }; + + renderOrder(orderWithPaymentDetails); + + expect(screen.getByText('To complete your order')).toBeOnTheScreen(); + expect(screen.getByText(/Routing number/i)).toBeOnTheScreen(); + expect(screen.getByText('021000021')).toBeOnTheScreen(); + }); + + it('renders bank details section when paymentDetails only includes SEPA fields', () => { + const orderWithPaymentDetails: RampsOrderWithPaymentDetails = { + ...mockOrder, + paymentDetails: [ + { + fiatCurrency: 'EUR', + paymentMethod: 'sepa_bank_transfer', + fields: [ + { name: 'IBAN', id: 'iban', value: 'DE89370400440532013000' }, + { name: 'BIC', id: 'bic', value: 'COBADEFFXXX' }, + ], + }, + ], + }; + + renderOrder(orderWithPaymentDetails); + + expect(screen.getByText('To complete your order')).toBeOnTheScreen(); + expect(screen.getByText(/^IBAN$/i)).toBeOnTheScreen(); + expect(screen.getByText('DE89370400440532013000')).toBeOnTheScreen(); + expect(screen.getByText(/^BIC$/i)).toBeOnTheScreen(); + expect(screen.getByText('COBADEFFXXX')).toBeOnTheScreen(); + }); + + it('truncates long crypto amounts to 5 decimal places', () => { + const longDecimalOrder: RampsOrder = { + ...mockOrder, + cryptoAmount: 0.01588973776561068, + }; + renderOrder(longDecimalOrder); + const tokenAmount = screen.getByTestId('ramps-order-details-token-amount'); + expect(tokenAmount.props.children).not.toContain('0.01588973776561068'); + expect(tokenAmount).toHaveTextContent('0.01589 ETH'); + }); + + it('uses subscript notation for very small crypto amounts', () => { + const tinyAmountOrder: RampsOrder = { + ...mockOrder, + cryptoAmount: 0.00000614, + }; + renderOrder(tinyAmountOrder); + const tokenAmount = screen.getByTestId('ramps-order-details-token-amount'); + // 0.00000614 has 5 leading zeros -> "0.0₅614" + expect(tokenAmount).toHaveTextContent('0.0₅614 ETH'); + }); + + it('shows placeholder when cryptoAmount is missing', () => { + const noAmountOrder: RampsOrder = { + ...mockOrder, + cryptoAmount: undefined as unknown as number, + }; + renderOrder(noAmountOrder); + const tokenAmount = screen.getByTestId('ramps-order-details-token-amount'); + expect(tokenAmount).toHaveTextContent('... ETH'); + }); + + it('shows placeholder when cryptoAmount is zero', () => { + const zeroAmountOrder: RampsOrder = { + ...mockOrder, + cryptoAmount: 0, + }; + renderOrder(zeroAmountOrder); + const tokenAmount = screen.getByTestId('ramps-order-details-token-amount'); + expect(tokenAmount).toHaveTextContent('... ETH'); + }); + + it('shows placeholder amounts for terminal orders with no amounts', () => { + const failedOrder: RampsOrder = { + ...mockOrder, + cryptoAmount: 0, + fiatAmount: 0, + totalFeesFiat: 0, + status: RampsOrderStatus.Failed, + }; + + renderOrder(failedOrder); + + expect(screen.getByText('Failed')).toBeOnTheScreen(); + expect( + screen.getByTestId('ramps-order-details-token-amount'), + ).toHaveTextContent('... ETH'); + expect(screen.getAllByText('...')).toHaveLength(2); + }); + it('does not render info row when statusDescription is absent', () => { const orderWithoutDescription: RampsOrder = { ...mockOrder, diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx index c73658759dd..abb5fc9491d 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx @@ -24,9 +24,10 @@ import BadgeWrapper, { BadgePosition, } from '../../../../../component-library/components/Badges/BadgeWrapper'; import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; -import { strings } from '../../../../../../locales/i18n'; +import I18n, { strings } from '../../../../../../locales/i18n'; import { toDateFormat } from '../../../../../util/date'; -import { renderFiat } from '../../../../../util/number'; +import { formatSubscriptNotation } from '../../../../../util/number/subscriptNotation'; +import { formatWithThreshold } from '../../../../../util/assets'; import { getNetworkImageSource } from '../../../../../util/networks'; import Logger from '../../../../../util/Logger'; import Button, { @@ -39,6 +40,14 @@ import BankDetailRow from '../../Deposit/components/BankDetailRow/BankDetailRow' import Routes from '../../../../../constants/navigation/Routes'; import { RampsOrderDetailsSelectorsIDs } from './OrderDetails.testIds'; +const AMOUNT_PLACEHOLDER = '...'; +const TERMINAL_STATUSES = new Set([ + RampsOrderStatus.Completed, + RampsOrderStatus.Failed, + RampsOrderStatus.Cancelled, + RampsOrderStatus.IdExpired, +]); + const localStyles = StyleSheet.create({ badgeWrapperCenter: { alignSelf: 'center', @@ -166,7 +175,16 @@ const OrderContent: React.FC = ({ } }; - const isLoading = !order.fiatAmount; + const fiatCurrencyCode = order.fiatCurrency?.symbol ?? ''; + const cryptoSymbol = order.cryptoCurrency?.symbol ?? ''; + + const hasAmounts = Boolean( + fiatCurrencyCode && + ((order.fiatAmount != null && Number(order.fiatAmount) > 0) || + (order.cryptoAmount != null && Number(order.cryptoAmount) > 0)), + ); + const isTerminal = TERMINAL_STATUSES.has(order.status); + const isLoading = !hasAmounts && !isTerminal; const handleClose = useCallback(() => { trackEvent( @@ -205,10 +223,6 @@ const OrderContent: React.FC = ({ trackEvent, ]); - const fiatDenomSymbol = order.fiatCurrency?.denomSymbol ?? ''; - const fiatCurrencyCode = order.fiatCurrency?.symbol ?? ''; - const cryptoSymbol = order.cryptoCurrency?.symbol ?? ''; - const normalizeChainIdForBadge = (chainId: string): string => { if (!chainId || chainId.includes(':') || chainId.startsWith('0x')) { return chainId; @@ -278,6 +292,18 @@ const OrderContent: React.FC = ({ const iban = getFieldValue('IBAN'); const bic = getFieldValue('BIC'); + const hasAnyField = + amount || + accountName || + accountType || + bankName || + routingNumber || + accountNumber || + iban || + bic; + + if (!hasAnyField) return null; + return { amount, accountName, @@ -318,7 +344,21 @@ const OrderContent: React.FC = ({ fontWeight={FontWeight.Bold} twClassName="mt-6 text-center" > - {order.cryptoAmount} {cryptoSymbol} + {order.cryptoAmount != null && Number(order.cryptoAmount) > 0 + ? (formatSubscriptNotation( + parseFloat(String(order.cryptoAmount)), + ) ?? + formatWithThreshold( + parseFloat(String(order.cryptoAmount)), + 0.00001, + I18n.locale, + { + minimumFractionDigits: 0, + maximumFractionDigits: 5, + }, + )) + : AMOUNT_PLACEHOLDER}{' '} + {cryptoSymbol} @@ -447,12 +487,19 @@ const OrderContent: React.FC = ({ ) : ( - {fiatDenomSymbol} - {renderFiat( - Number(order.totalFeesFiat ?? 0), - fiatCurrencyCode, - fiatDecimals, - )} + {hasAmounts + ? formatWithThreshold( + Number(order.totalFeesFiat ?? 0), + 0, + I18n.locale, + { + style: 'currency', + currency: fiatCurrencyCode, + minimumFractionDigits: fiatDecimals, + maximumFractionDigits: fiatDecimals, + }, + ) + : AMOUNT_PLACEHOLDER} )} @@ -473,12 +520,19 @@ const OrderContent: React.FC = ({ ) : ( - {fiatDenomSymbol} - {renderFiat( - Number(order.fiatAmount ?? 0), - fiatCurrencyCode, - fiatDecimals, - )} + {hasAmounts + ? formatWithThreshold( + Number(order.fiatAmount ?? 0), + 0, + I18n.locale, + { + style: 'currency', + currency: fiatCurrencyCode, + minimumFractionDigits: fiatDecimals, + maximumFractionDigits: fiatDecimals, + }, + ) + : AMOUNT_PLACEHOLDER} )} diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx index ba8af56a7ef..e9870faf295 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx @@ -1,157 +1,232 @@ import React from 'react'; -import { processFiatOrder } from '../../index'; -import { screen } from '@testing-library/react-native'; -import OrderDetails from './OrderDetails'; -import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import { backgroundState } from '../../../../../util/test/initial-root-state'; -import { - FIAT_ORDER_STATES, - FIAT_ORDER_PROVIDERS, -} from '../../../../../constants/on-ramp'; -import type { RampsOrder } from '@metamask/ramps-controller'; -import { getOrderById, FiatOrder } from '../../../../../reducers/fiatOrders'; +import { ActivityIndicator } from 'react-native'; +import { fireEvent, waitFor, act } from '@testing-library/react-native'; +import OrderDetails, { + createRampsOrderDetailsNavDetails, +} from './OrderDetails'; +import { renderScreen } from '../../../../../util/test/renderWithProvider'; +import Routes from '../../../../../constants/navigation/Routes'; +import { RampsOrderDetailsSelectorsIDs } from './OrderDetails.testIds'; +import { RampsOrderStatus } from '@metamask/ramps-controller'; -const mockNavigate = jest.fn(); const mockSetOptions = jest.fn(); -const mockGoBack = jest.fn(); -const mockDispatch = jest.fn(); +const mockNavigate = jest.fn(); +const mockSetParams = jest.fn(); +const mockReset = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + setOptions: mockSetOptions, + navigate: mockNavigate, + goBack: jest.fn(), + setParams: mockSetParams, + reset: mockReset, + }), +})); + +const mockGetOrderById = jest.fn(); +const mockRefreshOrder = jest.fn(); +const mockGetOrderFromCallback = jest.fn(); +const mockAddOrder = jest.fn(); +jest.mock('../../hooks/useRampsOrders', () => ({ + useRampsOrders: () => ({ + getOrderById: mockGetOrderById, + refreshOrder: mockRefreshOrder, + getOrderFromCallback: mockGetOrderFromCallback, + addOrder: mockAddOrder, + }), +})); -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useDispatch: () => mockDispatch, +jest.mock('../../../Navbar', () => ({ + getRampsOrderDetailsNavbarOptions: jest.fn((_nav, _opts, _theme, onBack) => ({ + headerLeft: onBack ? () => null : undefined, + })), })); -jest.mock('@react-navigation/native', () => { - const actualNav = jest.requireActual('@react-navigation/native'); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); return { - ...actualNav, - useNavigation: () => ({ - navigate: mockNavigate, - setOptions: mockSetOptions, - goBack: mockGoBack, - }), - useRoute: () => ({ - params: { - orderId: 'test-order-id', - }, - }), + useTheme: () => mockTheme, }; }); -jest.mock('../../../../../reducers/fiatOrders', () => ({ - getOrderById: jest.fn(), - updateFiatOrder: jest.fn().mockReturnValue({ type: 'FIAT_UPDATE_ORDER' }), - getProviderName: jest.fn().mockReturnValue('Transak'), +const mockTrackEvent = jest.fn(); +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: () => ({ + addProperties: (props: object) => ({ build: () => ({ ...props }) }), + }), + }), })); -function mockGetUpdatedOrder(order: FiatOrder) { - return { - ...order, - lastTimeFetched: (order.lastTimeFetched || 0) + 100, - }; -} +const mockUseParams = jest.fn, []>(() => ({ + orderId: 'test-order-123', +})); +jest.mock('../../../../../util/navigation/navUtils', () => ({ + ...jest.requireActual('../../../../../util/navigation/navUtils'), + useParams: () => mockUseParams(), +})); -jest.mock('../../index', () => ({ - processFiatOrder: jest.fn().mockImplementation((order, onSuccess) => { - const updatedOrder = mockGetUpdatedOrder(order); - if (onSuccess) { - onSuccess(updatedOrder); - } - return Promise.resolve(); - }), +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, })); -describe('Ramps OrderDetails Component', () => { - const mockRampsOrder: RampsOrder = { - id: 'provider-order-123', - isOnlyLink: false, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - provider: { id: '/providers/transak', name: 'Transak', links: [] } as any, - success: true, - cryptoAmount: 0.05, - fiatAmount: 100, - cryptoCurrency: { - symbol: 'USDC', - decimals: 6, - iconUrl: 'https://example.com/usdc.png', - }, - fiatCurrency: { - symbol: 'USD', - decimals: 2, - denomSymbol: '$', - }, - providerOrderId: 'transak_order_123', - providerOrderLink: 'https://transak.com/order/123', - createdAt: Date.now(), - paymentMethod: { - id: '/payments/card', - name: 'Credit Card', - }, - totalFeesFiat: 2.5, - txHash: '', - walletAddress: '0x1234567890123456789012345678901234567890', - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - status: 'COMPLETED' as any, - network: { - name: 'Ethereum', - chainId: '1', - }, - canBeUpdated: false, - idHasExpired: false, - excludeFromPurchases: false, - timeDescriptionPending: '1-2 minutes', - orderType: 'BUY', +jest.mock('./OrderContent', () => { + /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, @typescript-eslint/no-shadow */ + const ReactActual = require('react'); + const { View, Text } = require('react-native'); + return { + __esModule: true, + default: ({ order }: { order: { providerOrderId: string } }) => + ReactActual.createElement( + View, + { testID: 'order-content' }, + ReactActual.createElement(Text, null, order?.providerOrderId ?? ''), + ), }; +}); - const mockOrder: FiatOrder = { - id: '/providers/transak/orders/123', - provider: FIAT_ORDER_PROVIDERS.RAMPS_V2, - createdAt: Date.now(), - amount: 100, - currency: 'USD', - cryptoAmount: 0.05, - cryptocurrency: 'USDC', - fee: 2.5, - state: FIAT_ORDER_STATES.COMPLETED, - account: '0x1234567890123456789012345678901234567890', - network: '1', - excludeFromPurchases: false, - orderType: 'BUY', - data: mockRampsOrder, - }; +jest.mock('../../Aggregator/components/ScreenLayout', () => { + /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, @typescript-eslint/no-shadow */ + const ReactActual = require('react'); + const { View } = require('react-native'); + const Layout = ({ + children, + testID, + }: { + children: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, { testID }, children); + Layout.Body = ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(View, null, children); + Layout.Content = ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(View, null, children); + return { __esModule: true, default: Layout }; +}); + +const mockOrder = { + providerOrderId: 'ord-123', + status: RampsOrderStatus.Completed, + provider: { id: 'paypal' }, + walletAddress: '0x123', +}; + +function render() { + return renderScreen(OrderDetails, { + name: Routes.RAMP.RAMPS_ORDER_DETAILS, + }); +} +describe('OrderDetails', () => { beforeEach(() => { jest.clearAllMocks(); - (getOrderById as jest.Mock).mockReturnValue(mockOrder); - (processFiatOrder as jest.Mock).mockResolvedValue(undefined); + mockGetOrderById.mockReturnValue(mockOrder); + mockUseParams.mockReturnValue({ orderId: 'ord-123' }); }); - afterEach(() => { - jest.clearAllMocks(); + it('matches snapshot when order exists', async () => { + mockRefreshOrder.mockResolvedValue(undefined); + const { toJSON } = render(); + await waitFor(() => { + expect(mockGetOrderById).toHaveBeenCalledWith('ord-123'); + }); + expect(toJSON()).toMatchSnapshot(); + }); + + it('displays order content when order is loaded', async () => { + const { getByTestId } = render(); + await waitFor(() => { + expect( + getByTestId(RampsOrderDetailsSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + }); + expect(getByTestId('order-content')).toBeOnTheScreen(); + }); + + it('renders empty ScreenLayout when order is not found', () => { + mockGetOrderById.mockReturnValue(undefined); + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('shows loading state when order is pending and refreshing', () => { + mockUseParams.mockReturnValue({ orderId: 'ord-123' }); + mockGetOrderById.mockReturnValue({ + ...mockOrder, + status: RampsOrderStatus.Pending, + }); + // eslint-disable-next-line no-empty-function -- Never-resolving promise for loading state test + mockRefreshOrder.mockImplementation(() => new Promise(() => {})); + const { UNSAFE_getAllByType } = render(); + const indicators = UNSAFE_getAllByType(ActivityIndicator); + expect(indicators.length).toBeGreaterThan(0); + }); + + it('shows error state with retry when refresh fails', async () => { + mockUseParams.mockReturnValue({ orderId: 'ord-pending' }); + mockGetOrderById.mockReturnValue({ + ...mockOrder, + status: RampsOrderStatus.Pending, + }); + mockRefreshOrder.mockRejectedValue(new Error('Refresh failed')); + + const { getByText } = render(); + + await waitFor(() => { + expect(getByText('ramps_order_details.try_again')).toBeOnTheScreen(); + }); + + await act(async () => { + fireEvent.press(getByText('ramps_order_details.try_again')); + }); + expect(mockRefreshOrder).toHaveBeenCalled(); }); - it('renders OrderDetails component', () => { - renderWithProvider(, { - state: { - engine: { - backgroundState, - }, - }, + it('tracks RAMPS_SCREEN_VIEWED when order is displayed', async () => { + render(); + await waitFor(() => { + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + location: 'Order Details', + ramp_type: 'UNIFIED_BUY_2', + }), + ); }); + }); - expect(screen.toJSON()).toBeTruthy(); + it('createRampsOrderDetailsNavDetails returns correct route', () => { + const result = createRampsOrderDetailsNavDetails(); + expect(result[0]).toBe(Routes.RAMP.RAMPS_ORDER_DETAILS); }); - it('sets navigation options on mount', () => { - renderWithProvider(, { - state: { - engine: { - backgroundState, - }, - }, + it('shows error state with retry when initial callback fetch fails', async () => { + mockUseParams.mockReturnValue({ + callbackUrl: 'metamask://on-ramp/providers/paypal?orderId=abc', + providerCode: 'paypal', + walletAddress: '0x123', + }); + mockGetOrderById.mockReturnValue(undefined); + mockGetOrderFromCallback.mockRejectedValue( + new Error('Network request failed'), + ); + + const { getByText } = render(); + + await waitFor(() => { + expect(getByText('Network request failed')).toBeOnTheScreen(); }); + expect(getByText('ramps_order_details.try_again')).toBeOnTheScreen(); - expect(mockSetOptions).toHaveBeenCalled(); + await act(async () => { + fireEvent.press(getByText('ramps_order_details.try_again')); + }); + expect(mockGetOrderFromCallback).toHaveBeenCalledTimes(2); + expect(mockGetOrderFromCallback).toHaveBeenNthCalledWith( + 2, + 'paypal', + 'metamask://on-ramp/providers/paypal?orderId=abc', + '0x123', + ); }); }); diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx index a170648af59..e903408e333 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx @@ -11,7 +11,16 @@ import { IconSize, FontWeight, } from '@metamask/design-system-react-native'; -import { RampsOrderStatus } from '@metamask/ramps-controller'; +import { + normalizeProviderCode, + RampsOrderStatus, +} from '@metamask/ramps-controller'; +import { isBailedOrderStatus } from '../BuildQuote/BuildQuote'; +import { extractOrderCode } from '../../utils/extractOrderCode'; +import { + getNavigateAfterExternalBrowserRoutes, + type RampsOrderDetailsParams, +} from '../../utils/rampsNavigation'; import Button, { ButtonVariants, ButtonSize, @@ -33,11 +42,6 @@ import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { RampsOrderDetailsSelectorsIDs } from './OrderDetails.testIds'; -interface RampsOrderDetailsParams { - orderId: string; - showCloseButton?: boolean; -} - export const createRampsOrderDetailsNavDetails = createNavigationDetails( Routes.RAMP.RAMPS_ORDER_DETAILS, @@ -66,11 +70,16 @@ const styles = StyleSheet.create({ const OrderDetails = () => { const params = useParams(); - const { getOrderById, refreshOrder } = useRampsOrders(); - const order = getOrderById(params.orderId); + const { getOrderById, refreshOrder, getOrderFromCallback, addOrder } = + useRampsOrders(); + const orderCode = params.orderId ? extractOrderCode(params.orderId) : ''; + const order = getOrderById(orderCode); const isPending = order ? PENDING_STATUSES.has(order.status) : false; + const hasCallbackParams = Boolean( + params.callbackUrl && params.providerCode && params.walletAddress, + ); - const [isLoading, setIsLoading] = useState(isPending); + const [isLoading, setIsLoading] = useState(isPending || hasCallbackParams); const [error, setError] = useState(null); const theme = useTheme(); const { colors } = theme; @@ -78,6 +87,72 @@ const OrderDetails = () => { const { trackEvent, createEventBuilder } = useAnalytics(); const [isRefreshing, setIsRefreshing] = useState(false); + const hasFetchedFromCallback = useRef(false); + + const executeCallbackFetch = useCallback( + async ( + providerCode: string, + callbackUrl: string, + walletAddress: string, + logContext: string, + ) => { + try { + setError(null); + const fetchedOrder = await getOrderFromCallback( + providerCode, + callbackUrl, + walletAddress, + ); + if (!fetchedOrder || isBailedOrderStatus(fetchedOrder.status)) { + navigation.reset({ + index: 0, + routes: getNavigateAfterExternalBrowserRoutes({ + returnDestination: 'buildQuote', + }), + }); + return; + } + addOrder(fetchedOrder); + navigation.setParams({ + orderId: fetchedOrder.providerOrderId, + callbackUrl: undefined, + providerCode: undefined, + walletAddress: undefined, + }); + } catch (fetchError) { + Logger.error(fetchError as Error, { + message: `RampsOrderDetails: error fetching order from callback URL${logContext}`, + callbackUrl, + }); + setError( + fetchError instanceof Error && fetchError.message + ? fetchError.message + : strings('ramps_order_details.error_message'), + ); + } finally { + setIsLoading(false); + } + }, + [getOrderFromCallback, addOrder, navigation], + ); + + const handleRetryCallbackFetch = useCallback(async () => { + if (!params.callbackUrl || !params.providerCode || !params.walletAddress) { + return; + } + setIsLoading(true); + await executeCallbackFetch( + params.providerCode, + params.callbackUrl, + params.walletAddress, + ' (retry)', + ); + }, [ + params.callbackUrl, + params.providerCode, + params.walletAddress, + executeCallbackFetch, + ]); useEffect(() => { navigation.setOptions( @@ -119,10 +194,7 @@ const OrderDetails = () => { try { setError(null); setIsRefreshing(true); - const providerCode = (order.provider?.id ?? '').replace( - '/providers/', - '', - ); + const providerCode = normalizeProviderCode(order.provider?.id ?? ''); await refreshOrder( providerCode, order.providerOrderId, @@ -147,15 +219,37 @@ const OrderDetails = () => { }, [order, refreshOrder]); useEffect(() => { - if (isPending) { + if (isPending && !hasCallbackParams) { handleOnRefresh(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - if (!order) { - return ; - } + useEffect(() => { + if ( + !hasCallbackParams || + hasFetchedFromCallback.current || + !params.callbackUrl || + !params.providerCode || + !params.walletAddress + ) { + return; + } + hasFetchedFromCallback.current = true; + + executeCallbackFetch( + params.providerCode, + params.callbackUrl, + params.walletAddress, + '', + ); + }, [ + hasCallbackParams, + params.callbackUrl, + params.providerCode, + params.walletAddress, + executeCallbackFetch, + ]); if (isLoading) { return ( @@ -170,6 +264,9 @@ const OrderDetails = () => { } if (error) { + const onRetry = hasCallbackParams + ? handleRetryCallbackFetch + : handleOnRefresh; return ( @@ -197,7 +294,7 @@ const OrderDetails = () => { size={ButtonSize.Lg} width={ButtonWidthTypes.Full} label={strings('ramps_order_details.try_again')} - onPress={handleOnRefresh} + onPress={onRetry} /> @@ -205,6 +302,10 @@ const OrderDetails = () => { ); } + if (!order) { + return ; + } + return ( - $ - 2.5 USD + $2.50 - $ - 100 USD + $100.00 - 0.05 + ... ETH @@ -1094,3 +1092,601 @@ exports[`OrderContent renders loading state when order has no amount 1`] = ` `; + +exports[`OrderContent shows placeholder for token amount when cryptoAmount is 0 or missing 1`] = ` + + + + + + + + + + + + + + + + + + ... + + ETH + + + + + Status + + + + Processing + + + + + View on Transak + + + + + + + + + Order ID + + + + + ...abc123 + + + + + + + + Date and time + + + Nov 14 at 5:13 pm + + + + + Fees + + + $2.50 + + + + + Total + + + $100.00 + + + + + + + Card purchases typically take a few minutes + + + + + + + +`; diff --git a/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap b/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap new file mode 100644 index 00000000000..3347c1ebb70 --- /dev/null +++ b/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap @@ -0,0 +1,693 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OrderDetails matches snapshot when order exists 1`] = ` + + + + + + + + + + + + + RampsOrderDetails + + + + + + + + + + + + + + + + + + + } + > + + + + + + + ord-123 + + + + + + + + + + + + + + + + + +`; + +exports[`OrderDetails renders empty ScreenLayout when order is not found 1`] = ` + + + + + + + + + + + + + RampsOrderDetails + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.test.tsx b/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.test.tsx index ffe1511ddb2..e33b578df58 100644 --- a/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.test.tsx +++ b/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.test.tsx @@ -112,11 +112,14 @@ const mockUseRampsControllerInitialValues: ReturnType< setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), + addPrecreatedOrder: jest.fn(), removeOrder: jest.fn(), refreshOrder: jest.fn(), getOrderFromCallback: jest.fn(), diff --git a/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.testIds.ts b/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.testIds.ts new file mode 100644 index 00000000000..06233972741 --- /dev/null +++ b/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.testIds.ts @@ -0,0 +1,4 @@ +export const REGION_SELECTOR_TEST_IDS = { + BACK_BUTTON: 'back-button', + CLEAR_BUTTON: 'region-selector-clear-button', +} as const; diff --git a/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.tsx b/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.tsx index 437214318da..292841cb191 100644 --- a/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.tsx +++ b/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.tsx @@ -44,6 +44,7 @@ import { strings } from '../../../../../../../locales/i18n'; import { useAppTheme } from '../../../../../../util/theme'; import { Country, State } from '@metamask/ramps-controller'; import useRampsController from '../../../hooks/useRampsController'; +import { REGION_SELECTOR_TEST_IDS } from './RegionSelector.testIds'; const MAX_REGION_RESULTS = 20; @@ -617,7 +618,10 @@ function RegionSelector() { const stateHeaderLeft = useCallback( () => ( - + ), [handleRegionBackButton], ); @@ -659,7 +663,7 @@ function RegionSelector() { onPressClearButton={clearSearchText} clearButtonProps={{ iconName: IconName.Close, - testID: 'region-selector-clear-button', + testID: REGION_SELECTOR_TEST_IDS.CLEAR_BUTTON, }} onFocus={scrollToTop} onChangeText={handleSearchTextChange} diff --git a/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.test.tsx b/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.test.tsx index 7ae112efc12..93a37ed51be 100644 --- a/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.test.tsx +++ b/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.test.tsx @@ -172,11 +172,14 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), + addPrecreatedOrder: jest.fn(), removeOrder: jest.fn(), refreshOrder: jest.fn(), getOrderFromCallback: jest.fn(), @@ -313,11 +316,14 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), + addPrecreatedOrder: jest.fn(), removeOrder: jest.fn(), refreshOrder: jest.fn(), getOrderFromCallback: jest.fn(), @@ -353,11 +359,14 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), + addPrecreatedOrder: jest.fn(), removeOrder: jest.fn(), refreshOrder: jest.fn(), getOrderFromCallback: jest.fn(), @@ -407,11 +416,14 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), + addPrecreatedOrder: jest.fn(), removeOrder: jest.fn(), refreshOrder: jest.fn(), getOrderFromCallback: jest.fn(), @@ -472,11 +484,14 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), + addPrecreatedOrder: jest.fn(), removeOrder: jest.fn(), refreshOrder: jest.fn(), getOrderFromCallback: jest.fn(), @@ -547,11 +562,14 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), + addPrecreatedOrder: jest.fn(), removeOrder: jest.fn(), refreshOrder: jest.fn(), getOrderFromCallback: jest.fn(), @@ -626,11 +644,14 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), + addPrecreatedOrder: jest.fn(), removeOrder: jest.fn(), refreshOrder: jest.fn(), getOrderFromCallback: jest.fn(), @@ -686,11 +707,14 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), + addPrecreatedOrder: jest.fn(), removeOrder: jest.fn(), refreshOrder: jest.fn(), getOrderFromCallback: jest.fn(), diff --git a/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap b/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap index 1b1313e4bed..682a8a90658 100644 --- a/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap +++ b/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap @@ -1687,6 +1687,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -1721,10 +1723,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -1831,7 +1833,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -1850,7 +1852,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -1863,10 +1865,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -1877,7 +1879,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -1987,6 +1989,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -2021,10 +2025,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -2131,7 +2135,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -2150,7 +2154,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -2163,10 +2167,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -2177,7 +2181,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -2287,6 +2291,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -2321,10 +2327,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -2431,7 +2437,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -2450,7 +2456,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -2463,10 +2469,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -2477,7 +2483,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -2587,6 +2593,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -2621,10 +2629,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -2731,7 +2739,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -2750,7 +2758,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -2763,10 +2771,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -2777,7 +2785,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -2887,6 +2895,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -2921,10 +2931,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -3031,7 +3041,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -3050,7 +3060,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -3063,10 +3073,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -3077,7 +3087,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4007,6 +4017,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -4041,10 +4053,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -4151,7 +4163,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4170,7 +4182,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -4183,10 +4195,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -4197,7 +4209,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4307,6 +4319,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -4341,10 +4355,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -4451,7 +4465,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4470,7 +4484,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -4483,10 +4497,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -4497,7 +4511,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4607,6 +4621,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -4641,10 +4657,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -4751,7 +4767,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4770,7 +4786,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -4783,10 +4799,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -4797,7 +4813,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4907,6 +4923,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -4941,10 +4959,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -5051,7 +5069,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -5070,7 +5088,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -5083,10 +5101,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -5097,7 +5115,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -5207,6 +5225,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -5241,10 +5261,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -5351,7 +5371,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -5370,7 +5390,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -5383,10 +5403,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -5397,7 +5417,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" diff --git a/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.testIds.ts b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.testIds.ts new file mode 100644 index 00000000000..c90105ad653 --- /dev/null +++ b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.testIds.ts @@ -0,0 +1,4 @@ +export const ELIGIBILITY_FAILED_MODAL_TEST_IDS = { + MODAL: 'eligibility-failed-modal', + CLOSE_BUTTON: 'bottomsheetheader-close-button', +} as const; diff --git a/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx index 5504bf6c876..289cdd1edf7 100644 --- a/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx +++ b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx @@ -19,6 +19,7 @@ import { useStyles } from '../../../../hooks/useStyles'; import { createNavigationDetails } from '../../../../../util/navigation/navUtils'; import Routes from '../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../locales/i18n'; +import { ELIGIBILITY_FAILED_MODAL_TEST_IDS } from './EligibilityFailedModal.testIds'; const SUPPORT_URL = 'https://support.metamask.io'; @@ -47,11 +48,13 @@ function EligibilityFailedModal() { ref={sheetRef} shouldNavigateBack isInteractable={false} - testID="eligibility-failed-modal" + testID={ELIGIBILITY_FAILED_MODAL_TEST_IDS.MODAL} > {strings('fiat_on_ramp_aggregator.eligibility_failed_modal.title')} diff --git a/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.testIds.ts b/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.testIds.ts new file mode 100644 index 00000000000..aa27024124a --- /dev/null +++ b/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.testIds.ts @@ -0,0 +1,3 @@ +export const PAYMENT_METHOD_PILL_TEST_IDS = { + CONTAINER: 'payment-method-pill', +} as const; diff --git a/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx b/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx index 749dba3a6f6..535e1877def 100644 --- a/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx +++ b/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx @@ -17,6 +17,7 @@ import Text, { import { useStyles } from '../../../../../component-library/hooks'; import styleSheet from './PaymentMethodPill.styles'; +import { PAYMENT_METHOD_PILL_TEST_IDS } from './PaymentMethodPill.testIds'; export interface PaymentMethodPillProps { /** Payment method label (e.g., "Debit card") */ @@ -33,7 +34,7 @@ const PaymentMethodPill: React.FC = ({ label, onPress, isLoading = false, - testID = 'payment-method-pill', + testID = PAYMENT_METHOD_PILL_TEST_IDS.CONTAINER, }) => { const { styles } = useStyles(styleSheet); diff --git a/app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.testIds.ts b/app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.testIds.ts new file mode 100644 index 00000000000..4a61bec3dab --- /dev/null +++ b/app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.testIds.ts @@ -0,0 +1,4 @@ +export const QUICK_AMOUNTS_TEST_IDS = { + CONTAINER: 'quick-amounts', + BUTTON_PREFIX: 'quick-amounts-button-', +} as const; diff --git a/app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.tsx b/app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.tsx index 1d004304038..448a4457a00 100644 --- a/app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.tsx +++ b/app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.tsx @@ -9,6 +9,7 @@ import { import { useStyles } from '../../../../../component-library/hooks'; import styleSheet from './QuickAmounts.styles'; +import { QUICK_AMOUNTS_TEST_IDS } from './QuickAmounts.testIds'; import { useFormatters } from '../../../../hooks/useFormatters'; const DEFAULT_AMOUNTS = [50, 100, 200, 400]; @@ -28,7 +29,7 @@ const QuickAmounts: React.FC = ({ amounts = DEFAULT_AMOUNTS, currency = 'USD', onAmountPress, - testID = 'quick-amounts', + testID = QUICK_AMOUNTS_TEST_IDS.CONTAINER, }) => { const { styles } = useStyles(styleSheet, {}); const { formatCurrency } = useFormatters(); diff --git a/app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.testIds.ts b/app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.testIds.ts new file mode 100644 index 00000000000..510b87cb35e --- /dev/null +++ b/app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.testIds.ts @@ -0,0 +1,4 @@ +export const RAMP_UNSUPPORTED_MODAL_TEST_IDS = { + MODAL: 'ramp-unsupported-modal', + CLOSE_BUTTON: 'bottomsheetheader-close-button', +} as const; diff --git a/app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.tsx b/app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.tsx index 02c779616d8..b656ef5c0aa 100644 --- a/app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.tsx +++ b/app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.tsx @@ -17,6 +17,7 @@ import Button, { import { createNavigationDetails } from '../../../../../util/navigation/navUtils'; import Routes from '../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../locales/i18n'; +import { RAMP_UNSUPPORTED_MODAL_TEST_IDS } from './RampUnsupportedModal.testIds'; export const createRampUnsupportedModalNavigationDetails = createNavigationDetails( @@ -36,11 +37,13 @@ function RampUnsupportedModal() { ref={sheetRef} shouldNavigateBack isInteractable={false} - testID="ramp-unsupported-modal" + testID={RAMP_UNSUPPORTED_MODAL_TEST_IDS.MODAL} > {strings('fiat_on_ramp_aggregator.unsupported_region_modal.title')} diff --git a/app/components/UI/Ramp/components/TokenListItem/TokenListItem.testIds.ts b/app/components/UI/Ramp/components/TokenListItem/TokenListItem.testIds.ts new file mode 100644 index 00000000000..5cb90361a9e --- /dev/null +++ b/app/components/UI/Ramp/components/TokenListItem/TokenListItem.testIds.ts @@ -0,0 +1,4 @@ +export const TOKEN_LIST_ITEM_TEST_IDS = { + ITEM_PREFIX: 'token-list-item-', + UNSUPPORTED_INFO_BUTTON: 'token-unsupported-info-button', +} as const; diff --git a/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx b/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx index f6e88be11ba..248cb3485a2 100644 --- a/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx +++ b/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx @@ -22,6 +22,7 @@ import { } from '@metamask/design-system-react-native'; import { useTokenNetworkInfo } from '../../hooks/useTokenNetworkInfo'; +import { TOKEN_LIST_ITEM_TEST_IDS } from './TokenListItem.testIds'; interface TokenListItemProps { token: DepositCryptoCurrency; @@ -53,7 +54,11 @@ function TokenListItem({ isSelected={isSelected} onPress={onPress} isDisabled={isDisabled} - testID={`token-list-item-${token.assetId}`} + gap={20} + listItemProps={{ + style: { paddingVertical: 8, paddingHorizontal: 16 }, + }} + testID={`${TOKEN_LIST_ITEM_TEST_IDS.ITEM_PREFIX}${token.assetId}`} > - {token.name} - + {token.name} + {token.symbol} @@ -84,7 +89,7 @@ function TokenListItem({ size={ButtonIconSize.Md} iconName={IconName.Info} onPress={handleInfoPress} - testID="token-unsupported-info-button" + testID={TOKEN_LIST_ITEM_TEST_IDS.UNSUPPORTED_INFO_BUTTON} /> )} diff --git a/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap b/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap index f36a5439299..af0ec048537 100644 --- a/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap +++ b/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap @@ -20,6 +20,8 @@ exports[`TokenListItem basic rendering renders correctly and matches snapshot 1` style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -54,10 +56,10 @@ exports[`TokenListItem basic rendering renders correctly and matches snapshot 1` style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -168,7 +170,7 @@ exports[`TokenListItem basic rendering renders correctly and matches snapshot 1` accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -187,7 +189,7 @@ exports[`TokenListItem basic rendering renders correctly and matches snapshot 1` { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -200,10 +202,10 @@ exports[`TokenListItem basic rendering renders correctly and matches snapshot 1` style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -235,6 +237,8 @@ exports[`TokenListItem basic rendering renders disabled token with info button a style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -269,10 +273,10 @@ exports[`TokenListItem basic rendering renders disabled token with info button a style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -383,7 +387,7 @@ exports[`TokenListItem basic rendering renders disabled token with info button a accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -402,7 +406,7 @@ exports[`TokenListItem basic rendering renders disabled token with info button a { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -415,10 +419,10 @@ exports[`TokenListItem basic rendering renders disabled token with info button a style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -429,7 +433,7 @@ exports[`TokenListItem basic rendering renders disabled token with info button a accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" diff --git a/app/components/UI/Ramp/deeplink/handleRampUrl.test.ts b/app/components/UI/Ramp/deeplink/handleRampUrl.test.ts new file mode 100644 index 00000000000..ce42caf2616 --- /dev/null +++ b/app/components/UI/Ramp/deeplink/handleRampUrl.test.ts @@ -0,0 +1,265 @@ +import { RampType } from '../Aggregator/types'; +import Routes from '../../../../constants/navigation/Routes'; +import handleRampUrl from './handleRampUrl'; +import handleRedirection from './handleRedirection'; +import NavigationService from '../../../../core/NavigationService'; +import { UnifiedRampRoutingType } from '../../../../reducers/fiatOrders'; +import Engine from '../../../../core/Engine'; + +jest.mock('../../../../core/NavigationService', () => ({ + navigation: { + navigate: jest.fn(), + }, +})); + +jest.mock('@react-navigation/native'); +jest.mock('./handleRedirection'); + +jest.mock('../../../../core/redux', () => ({ + __esModule: true, + default: { + store: { + getState: jest.fn(() => ({})), + }, + }, +})); + +const mockIsRampsUnifiedV2Enabled = jest.fn(); +jest.mock('../utils/isRampsUnifiedV2Enabled', () => ({ + isRampsUnifiedV2Enabled: (state: unknown) => + mockIsRampsUnifiedV2Enabled(state), +})); + +const mockGetRampRoutingDecision = jest.fn(); +jest.mock('../../../../reducers/fiatOrders', () => ({ + ...jest.requireActual('../../../../reducers/fiatOrders'), + getRampRoutingDecision: (state: unknown) => mockGetRampRoutingDecision(state), +})); + +const mockCreateEligibilityFailedModalNavigationDetails = jest.fn(() => [ + 'ELIGIBILITY_FAILED_MODAL_ROUTE', +]); +jest.mock( + '../components/EligibilityFailedModal/EligibilityFailedModal', + () => ({ + createEligibilityFailedModalNavigationDetails: () => + mockCreateEligibilityFailedModalNavigationDetails(), + }), +); + +const mockCreateRampUnsupportedModalNavigationDetails = jest.fn(() => [ + 'UNSUPPORTED_MODAL_ROUTE', +]); +jest.mock('../components/RampUnsupportedModal/RampUnsupportedModal', () => ({ + createRampUnsupportedModalNavigationDetails: () => + mockCreateRampUnsupportedModalNavigationDetails(), +})); + +const mockCreateBuildQuoteNavDetails = jest.fn( + (params: { assetId: string }) => ['BUILD_QUOTE_ROUTE', params], +); +jest.mock('../Views/BuildQuote', () => ({ + createBuildQuoteNavDetails: (params: { assetId: string }) => + mockCreateBuildQuoteNavDetails(params), +})); + +const mockCreateTokenSelectionNavDetails = jest.fn(() => [ + 'TOKEN_SELECTION_ROUTE', +]); +jest.mock('../Views/TokenSelection/TokenSelection', () => ({ + createTokenSelectionNavDetails: () => mockCreateTokenSelectionNavDetails(), +})); + +interface SelectTokensReturn { + data: { allTokens: { assetId?: string; chainId?: string }[] }; +} +const mockSelectTokens = jest.fn( + (_state: unknown): SelectTokensReturn => ({ + data: { allTokens: [] }, + }), +); +jest.mock('../../../../selectors/rampsController', () => ({ + selectTokens: (state: unknown) => mockSelectTokens(state), +})); + +const mockResolveRampControllerAssetId = jest.fn( + (assetId: string, _allTokens: unknown[]) => assetId, +); +jest.mock('../utils/resolveRampControllerAssetId', () => ({ + resolveRampControllerAssetId: (assetId: string, allTokens: unknown[]) => + mockResolveRampControllerAssetId(assetId, allTokens), +})); + +jest.mock('../../../../core/Engine', () => ({ + __esModule: true, + default: { + context: { + RampsController: { + setSelectedToken: jest.fn(), + }, + }, + }, +})); + +describe('handleRampUrl', () => { + beforeEach(() => { + jest.clearAllMocks(); + (NavigationService.navigation.navigate as jest.Mock).mockClear(); + (handleRedirection as jest.Mock).mockClear(); + mockIsRampsUnifiedV2Enabled.mockReturnValue(false); + }); + + it('handles redirection with the paths', () => { + handleRampUrl({ + rampPath: '/somePath?as=example', + rampType: RampType.BUY, + }); + expect(handleRedirection).toHaveBeenCalledWith( + ['somePath'], + { as: 'example' }, + RampType.BUY, + ); + }); + + it('navigates to Buy route when rampType is BUY, redirectPaths length is 0 and query params do not have allowed fields', () => { + handleRampUrl({ + rampPath: '?as=example', + rampType: RampType.BUY, + }); + expect(handleRedirection).not.toHaveBeenCalled(); + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + Routes.RAMP.BUY, + ); + }); + + it('navigates to Sell route when rampType is SELL, redirectPaths length is 0 and query param do not have allowed fields', () => { + handleRampUrl({ + rampPath: '?as=example', + rampType: RampType.SELL, + }); + expect(handleRedirection).not.toHaveBeenCalled(); + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + Routes.RAMP.SELL, + ); + }); + + it('navigates to Buy route when rampType is BUY, redirectPaths length is 0 and query param is intent', () => { + handleRampUrl({ + rampPath: '?chainId=1&address=0x123456', + rampType: RampType.BUY, + }); + expect(handleRedirection).not.toHaveBeenCalled(); + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + Routes.RAMP.BUY, + { + screen: Routes.RAMP.ID, + params: { + screen: Routes.RAMP.BUILD_QUOTE, + params: { + assetId: 'eip155:1/erc20:0x123456', + }, + }, + }, + ); + }); + + it('navigates to Sell route when rampType is SELL, redirectPaths length is 0 and query param is intent', () => { + handleRampUrl({ + rampPath: '?chainId=1&address=0x123456', + rampType: RampType.SELL, + }); + expect(handleRedirection).not.toHaveBeenCalled(); + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + Routes.RAMP.SELL, + { + screen: Routes.RAMP.ID, + params: { + screen: Routes.RAMP.BUILD_QUOTE, + params: { + assetId: 'eip155:1/erc20:0x123456', + }, + }, + }, + ); + }); + + describe('when Ramps Unified V2 is enabled', () => { + beforeEach(() => { + mockIsRampsUnifiedV2Enabled.mockReturnValue(true); + }); + + it('navigates to eligibility failed modal when routing decision is ERROR', () => { + mockGetRampRoutingDecision.mockReturnValue(UnifiedRampRoutingType.ERROR); + handleRampUrl({ + rampPath: '?as=example', + rampType: RampType.BUY, + }); + expect(handleRedirection).not.toHaveBeenCalled(); + expect( + mockCreateEligibilityFailedModalNavigationDetails, + ).toHaveBeenCalled(); + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + 'ELIGIBILITY_FAILED_MODAL_ROUTE', + ); + }); + + it('navigates to unsupported modal when routing decision is UNSUPPORTED', () => { + mockGetRampRoutingDecision.mockReturnValue( + UnifiedRampRoutingType.UNSUPPORTED, + ); + handleRampUrl({ + rampPath: '?as=example', + rampType: RampType.BUY, + }); + expect(handleRedirection).not.toHaveBeenCalled(); + expect( + mockCreateRampUnsupportedModalNavigationDetails, + ).toHaveBeenCalled(); + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + 'UNSUPPORTED_MODAL_ROUTE', + ); + }); + + it('navigates to TokenSelection when V2 enabled and no assetId in intent', () => { + mockGetRampRoutingDecision.mockReturnValue(null); + handleRampUrl({ + rampPath: '?as=example', + rampType: RampType.BUY, + }); + expect(handleRedirection).not.toHaveBeenCalled(); + expect(mockCreateTokenSelectionNavDetails).toHaveBeenCalled(); + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + 'TOKEN_SELECTION_ROUTE', + ); + }); + + it('navigates to BuildQuote when V2 enabled and ramp intent has assetId', () => { + mockGetRampRoutingDecision.mockReturnValue(null); + mockResolveRampControllerAssetId.mockReturnValue( + 'eip155:1/erc20:0x123456', + ); + mockSelectTokens.mockReturnValue({ + data: { allTokens: [{ assetId: 'eip155:1/erc20:0x123456' }] }, + }); + handleRampUrl({ + rampPath: '?chainId=1&address=0x123456', + rampType: RampType.BUY, + }); + expect(handleRedirection).not.toHaveBeenCalled(); + expect(mockResolveRampControllerAssetId).toHaveBeenCalledWith( + 'eip155:1/erc20:0x123456', + expect.any(Array), + ); + expect( + Engine.context.RampsController.setSelectedToken, + ).toHaveBeenCalledWith('eip155:1/erc20:0x123456'); + expect(mockCreateBuildQuoteNavDetails).toHaveBeenCalledWith( + expect.objectContaining({ assetId: 'eip155:1/erc20:0x123456' }), + ); + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + 'BUILD_QUOTE_ROUTE', + { assetId: 'eip155:1/erc20:0x123456' }, + ); + }); + }); +}); diff --git a/app/components/UI/Ramp/deeplink/handleRampUrl.ts b/app/components/UI/Ramp/deeplink/handleRampUrl.ts new file mode 100644 index 00000000000..0bcb6decf9e --- /dev/null +++ b/app/components/UI/Ramp/deeplink/handleRampUrl.ts @@ -0,0 +1,106 @@ +import handleRedirection from './handleRedirection'; +import getRedirectPathsAndParams from '../utils/getRedirectPathAndParams'; +import { RampType } from '../Aggregator/types'; +import parseRampIntent from '../utils/parseRampIntent'; +import { + createBuyNavigationDetails, + createSellNavigationDetails, +} from '../Aggregator/routes/utils'; +import Logger from '../../../../util/Logger'; +import NavigationService from '../../../../core/NavigationService'; +import ReduxService from '../../../../core/redux'; +import { isRampsUnifiedV2Enabled } from '../utils/isRampsUnifiedV2Enabled'; +import { + getRampRoutingDecision, + UnifiedRampRoutingType, +} from '../../../../reducers/fiatOrders'; +import { createEligibilityFailedModalNavigationDetails } from '../components/EligibilityFailedModal/EligibilityFailedModal'; +import { createRampUnsupportedModalNavigationDetails } from '../components/RampUnsupportedModal/RampUnsupportedModal'; +import { createBuildQuoteNavDetails } from '../Views/BuildQuote'; +import { createTokenSelectionNavDetails } from '../Views/TokenSelection/TokenSelection'; +import { selectTokens } from '../../../../selectors/rampsController'; +import { resolveRampControllerAssetId } from '../utils/resolveRampControllerAssetId'; +import Engine from '../../../../core/Engine'; + +interface RampUrlOptions { + rampPath: string; + rampType: RampType; +} + +export default function handleRampUrl({ rampPath, rampType }: RampUrlOptions) { + try { + const [redirectPaths, pathParams] = getRedirectPathsAndParams(rampPath); + + if (redirectPaths.length > 0) { + return handleRedirection(redirectPaths, pathParams, rampType); + } + + let rampIntent; + if (pathParams) { + rampIntent = parseRampIntent(pathParams); + } + + switch (rampType) { + case RampType.BUY: { + try { + const state = ReduxService.store.getState(); + if (isRampsUnifiedV2Enabled(state)) { + const routingDecision = getRampRoutingDecision(state); + if (routingDecision === UnifiedRampRoutingType.ERROR) { + NavigationService.navigation.navigate( + ...createEligibilityFailedModalNavigationDetails(), + ); + return; + } + if (routingDecision === UnifiedRampRoutingType.UNSUPPORTED) { + NavigationService.navigation.navigate( + ...createRampUnsupportedModalNavigationDetails(), + ); + return; + } + if (rampIntent?.assetId) { + const allTokens = selectTokens(state).data?.allTokens ?? []; + const controllerAssetId = resolveRampControllerAssetId( + rampIntent.assetId, + allTokens, + ); + try { + Engine.context.RampsController.setSelectedToken( + controllerAssetId, + ); + } catch { + // Token may not be in controller's list yet; navigate anyway + } + NavigationService.navigation.navigate( + ...createBuildQuoteNavDetails({ + assetId: controllerAssetId, + }), + ); + return; + } + NavigationService.navigation.navigate( + ...createTokenSelectionNavDetails(), + ); + return; + } + } catch { + // Store may not be ready; fall through to legacy behavior + } + NavigationService.navigation.navigate( + ...createBuyNavigationDetails(rampIntent), + ); + break; + } + case RampType.SELL: + NavigationService.navigation.navigate( + ...createSellNavigationDetails(rampIntent), + ); + break; + } + } catch (error) { + Logger.error( + error as Error, + `Error in handleRampUrl. rampPath: ${rampPath}`, + ); + } +} diff --git a/app/components/UI/Ramp/Aggregator/deeplink/handleRedirection.test.ts b/app/components/UI/Ramp/deeplink/handleRedirection.test.ts similarity index 74% rename from app/components/UI/Ramp/Aggregator/deeplink/handleRedirection.test.ts rename to app/components/UI/Ramp/deeplink/handleRedirection.test.ts index 17a3e0f0b5d..e570a594f49 100644 --- a/app/components/UI/Ramp/Aggregator/deeplink/handleRedirection.test.ts +++ b/app/components/UI/Ramp/deeplink/handleRedirection.test.ts @@ -1,11 +1,11 @@ -import { RampType } from '../types'; -import Routes from '../../../../../constants/navigation/Routes'; +import { RampType } from '../Aggregator/types'; +import Routes from '../../../../constants/navigation/Routes'; import handleRedirection from './handleRedirection'; -import NavigationService from '../../../../../core/NavigationService'; +import NavigationService from '../../../../core/NavigationService'; jest.mock('@react-navigation/native'); -jest.mock('../../../../../core/NavigationService', () => ({ +jest.mock('../../../../core/NavigationService', () => ({ navigation: { navigate: jest.fn(), }, diff --git a/app/components/UI/Ramp/Aggregator/deeplink/handleRedirection.ts b/app/components/UI/Ramp/deeplink/handleRedirection.ts similarity index 72% rename from app/components/UI/Ramp/Aggregator/deeplink/handleRedirection.ts rename to app/components/UI/Ramp/deeplink/handleRedirection.ts index f64d1bc32e3..47485af2d00 100644 --- a/app/components/UI/Ramp/Aggregator/deeplink/handleRedirection.ts +++ b/app/components/UI/Ramp/deeplink/handleRedirection.ts @@ -1,6 +1,6 @@ -import { RampType } from '../types'; -import Routes from '../../../../../constants/navigation/Routes'; -import NavigationService from '../../../../../core/NavigationService'; +import { RampType } from '../Aggregator/types'; +import Routes from '../../../../constants/navigation/Routes'; +import NavigationService from '../../../../core/NavigationService'; const RAMP_ACTIVITY = 'activity'; diff --git a/app/components/UI/Ramp/hooks/useHydrateRampsController.test.ts b/app/components/UI/Ramp/hooks/useHydrateRampsController.test.ts deleted file mode 100644 index 463170e790a..00000000000 --- a/app/components/UI/Ramp/hooks/useHydrateRampsController.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { renderHook } from '@testing-library/react-native'; -import { Provider } from 'react-redux'; -import { configureStore } from '@reduxjs/toolkit'; -import React from 'react'; -import { useHydrateRampsController } from './useHydrateRampsController'; -import Engine from '../../../../core/Engine'; - -jest.mock('../../../../core/Engine', () => ({ - context: { - RampsController: { - hydrateState: jest.fn().mockResolvedValue(undefined), - init: jest.fn().mockResolvedValue(undefined), - startOrderPolling: jest.fn(), - }, - }, -})); - -const mockUseRampsUnifiedV2Enabled = jest.fn(); -jest.mock('./useRampsUnifiedV2Enabled', () => ({ - __esModule: true, - default: () => mockUseRampsUnifiedV2Enabled(), -})); - -const createMockStore = (userRegion: { regionCode?: string } | null = null) => - configureStore({ - reducer: { - engine: () => ({ - backgroundState: { - RampsController: { - userRegion, - }, - }, - }), - }, - }); - -const wrapper = (store: ReturnType) => - function Wrapper({ children }: { children: React.ReactNode }) { - return React.createElement(Provider, { store } as never, children); - }; - -describe('useHydrateRampsController', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockUseRampsUnifiedV2Enabled.mockReturnValue(true); - }); - - it('calls hydrateState when V2 unified is enabled and userRegion has regionCode', () => { - const store = createMockStore({ regionCode: 'us-ca' }); - renderHook(() => useHydrateRampsController(), { - wrapper: wrapper(store), - }); - - expect(Engine.context.RampsController.hydrateState).toHaveBeenCalledTimes( - 1, - ); - }); - - it('does not call hydrateState when userRegion is null', () => { - const store = createMockStore(null); - renderHook(() => useHydrateRampsController(), { - wrapper: wrapper(store), - }); - - expect(Engine.context.RampsController.hydrateState).not.toHaveBeenCalled(); - }); - - it('does not call hydrateState when userRegion has no regionCode', () => { - const store = createMockStore({}); - renderHook(() => useHydrateRampsController(), { - wrapper: wrapper(store), - }); - - expect(Engine.context.RampsController.hydrateState).not.toHaveBeenCalled(); - }); - - it('calls init once when userRegion is null', () => { - const store = createMockStore(null); - renderHook(() => useHydrateRampsController(), { - wrapper: wrapper(store), - }); - - expect(Engine.context.RampsController.init).toHaveBeenCalledTimes(1); - }); - - it('calls startOrderPolling when backup init resolves (userRegion null)', async () => { - const store = createMockStore(null); - renderHook(() => useHydrateRampsController(), { - wrapper: wrapper(store), - }); - - await Promise.resolve(); - - expect( - Engine.context.RampsController.startOrderPolling, - ).toHaveBeenCalledTimes(1); - }); - - it('calls hydrateState again when regionCode changes', () => { - const store1 = createMockStore({ regionCode: 'us-ca' }); - const { unmount } = renderHook(() => useHydrateRampsController(), { - wrapper: wrapper(store1), - }); - - expect(Engine.context.RampsController.hydrateState).toHaveBeenCalledTimes( - 1, - ); - - unmount(); - - const store2 = createMockStore({ regionCode: 'gb' }); - renderHook(() => useHydrateRampsController(), { - wrapper: wrapper(store2), - }); - - expect(Engine.context.RampsController.hydrateState).toHaveBeenCalledTimes( - 2, - ); - }); - - it('handles hydrateState rejection gracefully', async () => { - ( - Engine.context.RampsController.hydrateState as jest.Mock - ).mockRejectedValueOnce(new Error('Network error')); - - const store = createMockStore({ regionCode: 'us-ca' }); - renderHook(() => useHydrateRampsController(), { - wrapper: wrapper(store), - }); - - await Promise.resolve(); - - expect(Engine.context.RampsController.hydrateState).toHaveBeenCalledTimes( - 1, - ); - }); - - it('does not call hydrateState when V2 unified is disabled', () => { - mockUseRampsUnifiedV2Enabled.mockReturnValue(false); - - const store = createMockStore({ regionCode: 'us-ca' }); - renderHook(() => useHydrateRampsController(), { - wrapper: wrapper(store), - }); - - expect(Engine.context.RampsController.hydrateState).not.toHaveBeenCalled(); - }); -}); diff --git a/app/components/UI/Ramp/hooks/useHydrateRampsController.ts b/app/components/UI/Ramp/hooks/useHydrateRampsController.ts deleted file mode 100644 index 79e7ede1144..00000000000 --- a/app/components/UI/Ramp/hooks/useHydrateRampsController.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; -import { selectUserRegion } from '../../../../selectors/rampsController'; -import useRampsUnifiedV2Enabled from './useRampsUnifiedV2Enabled'; - -/** - * Hook that ensures RampsController has tokens (and providers) loaded. - * - * - When V2 is enabled and userRegion is set: calls hydrateState() to refresh - * tokens and providers (e.g. when region is already loaded from persistence). - * - When V2 is enabled and userRegion is null: calls controller.init() once so - * tokens load when the Ramp UI mounts (backup if Engine init hasn't run or - * completed yet). Uses a ref to avoid duplicate in-flight init. - * - * Should be called from a top-level component that mounts early (e.g. FiatOrders in Main nav). - */ -export function useHydrateRampsController(): void { - const userRegion = useSelector(selectUserRegion); - const isV2UnifiedEnabled = useRampsUnifiedV2Enabled(); - const hasTriggeredInitRef = useRef(false); - - useEffect(() => { - if (!isV2UnifiedEnabled) { - return; - } - - const { RampsController } = Engine.context; - - if (userRegion?.regionCode) { - Promise.resolve(RampsController.hydrateState()).catch(() => { - // Error is stored in state - }); - return; - } - - // userRegion is null: ensure init runs so tokens load when Ramp UI is shown - // (backup to Engine init, which may not have run or completed yet). - if (!hasTriggeredInitRef.current) { - hasTriggeredInitRef.current = true; - Promise.resolve(RampsController.init()) - .then(() => { - RampsController.startOrderPolling(); - }) - .catch(() => { - hasTriggeredInitRef.current = false; - }); - } - }, [isV2UnifiedEnabled, userRegion?.regionCode]); -} - -export default useHydrateRampsController; diff --git a/app/components/UI/Ramp/hooks/useRampNavigation.test.ts b/app/components/UI/Ramp/hooks/useRampNavigation.test.ts index 2441b0ecf1c..a8ebb919bbb 100644 --- a/app/components/UI/Ramp/hooks/useRampNavigation.test.ts +++ b/app/components/UI/Ramp/hooks/useRampNavigation.test.ts @@ -146,13 +146,61 @@ describe('useRampNavigation', () => { expect(mockSetSelectedToken).toHaveBeenCalledWith(intent.assetId); expect(mockCreateBuildQuoteNavDetails).toHaveBeenCalledWith({ assetId: intent.assetId, + buyFlowOrigin: undefined, }); expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); expect(mockCreateRampNavigationDetails).not.toHaveBeenCalled(); expect(mockCreateDepositNavigationDetails).not.toHaveBeenCalled(); }); - it('does not navigate to BuildQuote when assetId is not provided', () => { + it('passes buyFlowOrigin through to BuildQuote params', () => { + const intent = { assetId: 'eip155:1/erc20:0x123' }; + + const { result } = renderHookWithProvider(() => useRampNavigation()); + + result.current.goToBuy(intent, { buyFlowOrigin: 'tokenInfo' }); + + expect(mockCreateBuildQuoteNavDetails).toHaveBeenCalledWith({ + assetId: intent.assetId, + buyFlowOrigin: 'tokenInfo', + }); + }); + + it('passes homeTokenList buyFlowOrigin through to BuildQuote params', () => { + const intent = { assetId: 'eip155:1/erc20:0x123' }; + + const { result } = renderHookWithProvider(() => useRampNavigation()); + + result.current.goToBuy(intent, { buyFlowOrigin: 'homeTokenList' }); + + expect(mockCreateBuildQuoteNavDetails).toHaveBeenCalledWith({ + assetId: intent.assetId, + buyFlowOrigin: 'homeTokenList', + }); + }); + + it('navigates to TokenSelection when no assetId and V1 is disabled (matches handleRampUrl deeplink)', () => { + // V2 on, V1 off (default in this describe): must go to TokenSelection like handleRampUrl, not legacy + const mockNavDetails = [ + Routes.RAMP.TOKEN_SELECTION, + undefined, + ] as const; + mockCreateTokenSelectionNavigationDetails.mockReturnValue( + mockNavDetails, + ); + + const { result } = renderHookWithProvider(() => useRampNavigation()); + + result.current.goToBuy(); + + expect(mockSetSelectedToken).not.toHaveBeenCalled(); + expect(mockCreateBuildQuoteNavDetails).not.toHaveBeenCalled(); + expect(mockCreateTokenSelectionNavigationDetails).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); + expect(mockCreateRampNavigationDetails).not.toHaveBeenCalled(); + }); + + it('navigates to TokenSelection when no assetId and V1 is also enabled', () => { mockUseRampsUnifiedV1Enabled.mockReturnValue(true); const mockNavDetails = [ Routes.RAMP.TOKEN_SELECTION, @@ -215,6 +263,7 @@ describe('useRampNavigation', () => { expect(mockSetSelectedToken).toHaveBeenCalledWith(intent.assetId); expect(mockCreateBuildQuoteNavDetails).toHaveBeenCalledWith({ assetId: intent.assetId, + buyFlowOrigin: undefined, }); expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); expect(mockCreateDepositNavigationDetails).not.toHaveBeenCalled(); diff --git a/app/components/UI/Ramp/hooks/useRampNavigation.ts b/app/components/UI/Ramp/hooks/useRampNavigation.ts index d3ce8bbe533..e8386e716d7 100644 --- a/app/components/UI/Ramp/hooks/useRampNavigation.ts +++ b/app/components/UI/Ramp/hooks/useRampNavigation.ts @@ -9,6 +9,7 @@ import { createRampNavigationDetails } from '../Aggregator/routes/utils'; import { createDepositNavigationDetails } from '../Deposit/routes/utils'; import { createTokenSelectionNavDetails } from '../Views/TokenSelection/TokenSelection'; import { createBuildQuoteNavDetails } from '../Views/BuildQuote'; +import type { BuyFlowOrigin } from '../Views/BuildQuote/BuildQuote'; import useRampsUnifiedV1Enabled from './useRampsUnifiedV1Enabled'; import useRampsUnifiedV2Enabled from './useRampsUnifiedV2Enabled'; import { @@ -18,6 +19,7 @@ import { import { createRampUnsupportedModalNavigationDetails } from '../components/RampUnsupportedModal/RampUnsupportedModal'; import { createEligibilityFailedModalNavigationDetails } from '../components/EligibilityFailedModal/EligibilityFailedModal'; import { useRampsTokens } from './useRampsTokens'; +import { resolveRampControllerAssetId } from '../utils/resolveRampControllerAssetId'; enum RampMode { AGGREGATOR = 'AGGREGATOR', @@ -40,36 +42,13 @@ export const useRampNavigation = () => { const rampRoutingDecision = useSelector(getRampRoutingDecision); const { setSelectedToken, tokens: rampsTokens } = useRampsTokens(); - /** - * Resolves an assetId to the controller's canonical format. - * Handles casing (API lowercase vs checksummed) and native token - * placeholder ('slip44:.' vs 'slip44:{coinType}'). - */ - const resolveControllerAssetId = useCallback( - (assetId: string): string => { - const allTokens = rampsTokens?.allTokens ?? []; - const isNative = assetId.includes('/slip44:'); - const [chainId] = assetId.split('/'); - - const match = allTokens.find((tok) => { - if (!tok.assetId) return false; - if (isNative) { - return tok.chainId === chainId && tok.assetId.includes('/slip44:'); - } - return tok.assetId.toLowerCase() === assetId.toLowerCase(); - }); - - return match?.assetId ?? assetId; - }, - [rampsTokens], - ); - const goToBuy = useCallback( ( intent?: RampIntent, options?: { mode?: RampMode; overrideUnifiedRouting?: boolean; + buyFlowOrigin?: BuyFlowOrigin; }, ) => { const { mode = RampMode.AGGREGATOR, overrideUnifiedRouting = false } = @@ -102,7 +81,10 @@ export const useRampNavigation = () => { !overrideUnifiedRouting ) { // Resolve to the controller's canonical assetId format (lowercase) - const controllerAssetId = resolveControllerAssetId(intent.assetId); + const controllerAssetId = resolveRampControllerAssetId( + intent.assetId, + rampsTokens?.allTokens ?? [], + ); try { setSelectedToken(controllerAssetId); } catch { @@ -110,11 +92,24 @@ export const useRampNavigation = () => { // Navigate anyway — BuildQuote will handle the missing token. } navigation.navigate( - ...createBuildQuoteNavDetails({ assetId: controllerAssetId }), + ...createBuildQuoteNavDetails({ + assetId: controllerAssetId, + buyFlowOrigin: options?.buyFlowOrigin, + }), ); return; } + // V2: If no assetId and V2 is enabled, route to TokenSelection (matches handleRampUrl deeplink behavior) + if ( + isRampsUnifiedV2Enabled && + !intent?.assetId && + !overrideUnifiedRouting + ) { + navigation.navigate(...createTokenSelectionNavDetails()); + return; + } + // V1 routing logic if (isRampsUnifiedV1Enabled && !overrideUnifiedRouting) { // If no assetId is provided, route to TokenSelection @@ -151,11 +146,11 @@ export const useRampNavigation = () => { }, [ setSelectedToken, - resolveControllerAssetId, navigation, isRampsUnifiedV1Enabled, isRampsUnifiedV2Enabled, rampRoutingDecision, + rampsTokens?.allTokens, ], ); diff --git a/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts b/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts index 1868700aa6c..4b73ecb6ddf 100644 --- a/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts +++ b/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts @@ -5,7 +5,7 @@ import { getRampRoutingDecision, UnifiedRampRoutingType, } from '../../../../reducers/fiatOrders'; -import { selectRampsOrders } from '../../../../selectors/rampsController'; +import { selectRampsOrdersForSelectedAccountGroup } from '../../../../selectors/rampsController'; import { getProviderToken } from '../Deposit/utils/ProviderTokenVault'; import { completedOrdersFromFiatOrders, @@ -21,7 +21,9 @@ export interface RampsButtonClickData { export function useRampsButtonClickData(): RampsButtonClickData { const orders = useSelector(getOrders); - const controllerOrders = useSelector(selectRampsOrders); + const controllerOrders = useSelector( + selectRampsOrdersForSelectedAccountGroup, + ); const rampRoutingDecision = useSelector(getRampRoutingDecision); const [isAuthenticated, setIsAuthenticated] = useState(false); diff --git a/app/components/UI/Ramp/hooks/useRampsController.test.ts b/app/components/UI/Ramp/hooks/useRampsController.test.ts index 7956b37ee51..cb839a82c11 100644 --- a/app/components/UI/Ramp/hooks/useRampsController.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsController.test.ts @@ -59,6 +59,9 @@ jest.mock('./useRampsPaymentMethods', () => ({ selectedPaymentMethod: null, setSelectedPaymentMethod: jest.fn(), isLoading: false, + isFetching: false, + status: 'idle', + isSuccess: false, error: null, })), })); @@ -66,7 +69,9 @@ jest.mock('./useRampsPaymentMethods', () => ({ jest.mock('./useRampsQuotes', () => ({ useRampsQuotes: jest.fn(() => ({ getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), + status: 'idle', + isSuccess: false, })), })); @@ -145,7 +150,7 @@ describe('useRampsController', () => { expect(typeof result.current.setSelectedToken).toBe('function'); expect(typeof result.current.setSelectedPaymentMethod).toBe('function'); expect(typeof result.current.getQuotes).toBe('function'); - expect(typeof result.current.getWidgetUrl).toBe('function'); + expect(typeof result.current.getBuyWidgetData).toBe('function'); expect(result.current.orders).toEqual([]); expect(typeof result.current.getOrderById).toBe('function'); diff --git a/app/components/UI/Ramp/hooks/useRampsController.ts b/app/components/UI/Ramp/hooks/useRampsController.ts index e7ca1dcc1f2..a70e4bc1afd 100644 --- a/app/components/UI/Ramp/hooks/useRampsController.ts +++ b/app/components/UI/Ramp/hooks/useRampsController.ts @@ -53,16 +53,19 @@ export interface UseRampsControllerResult { selectedPaymentMethod: UseRampsPaymentMethodsResult['selectedPaymentMethod']; setSelectedPaymentMethod: UseRampsPaymentMethodsResult['setSelectedPaymentMethod']; paymentMethodsLoading: UseRampsPaymentMethodsResult['isLoading']; + paymentMethodsFetching: UseRampsPaymentMethodsResult['isFetching']; + paymentMethodsStatus: UseRampsPaymentMethodsResult['status']; paymentMethodsError: UseRampsPaymentMethodsResult['error']; // Quotes getQuotes: UseRampsQuotesResult['getQuotes']; - getWidgetUrl: UseRampsQuotesResult['getWidgetUrl']; + getBuyWidgetData: UseRampsQuotesResult['getBuyWidgetData']; // Orders orders: UseRampsOrdersResult['orders']; getOrderById: UseRampsOrdersResult['getOrderById']; addOrder: UseRampsOrdersResult['addOrder']; + addPrecreatedOrder: UseRampsOrdersResult['addPrecreatedOrder']; removeOrder: UseRampsOrdersResult['removeOrder']; refreshOrder: UseRampsOrdersResult['refreshOrder']; getOrderFromCallback: UseRampsOrdersResult['getOrderFromCallback']; @@ -109,7 +112,7 @@ export interface UseRampsControllerResult { * * // Quotes * getQuotes, - * getWidgetUrl, + * getBuyWidgetData, * * } = useRampsController(); * ``` @@ -144,15 +147,18 @@ export function useRampsController(): UseRampsControllerResult { selectedPaymentMethod, setSelectedPaymentMethod, isLoading: paymentMethodsLoading, + isFetching: paymentMethodsFetching, + status: paymentMethodsStatus, error: paymentMethodsError, } = useRampsPaymentMethods(); - const { getQuotes, getWidgetUrl } = useRampsQuotes(); + const { getQuotes, getBuyWidgetData } = useRampsQuotes(); const { orders, getOrderById, addOrder, + addPrecreatedOrder, removeOrder, refreshOrder, getOrderFromCallback, @@ -183,14 +189,17 @@ export function useRampsController(): UseRampsControllerResult { selectedPaymentMethod, setSelectedPaymentMethod, paymentMethodsLoading, + paymentMethodsFetching, + paymentMethodsStatus, paymentMethodsError, getQuotes, - getWidgetUrl, + getBuyWidgetData, orders, getOrderById, addOrder, + addPrecreatedOrder, removeOrder, refreshOrder, getOrderFromCallback, diff --git a/app/components/UI/Ramp/hooks/useRampsOrders.test.ts b/app/components/UI/Ramp/hooks/useRampsOrders.test.ts index e5996b24eb5..6f1b6557191 100644 --- a/app/components/UI/Ramp/hooks/useRampsOrders.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsOrders.test.ts @@ -2,10 +2,25 @@ import { renderHook, act } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import React from 'react'; +import { AccountGroupType } from '@metamask/account-api'; import { RampsOrderStatus, type RampsOrder } from '@metamask/ramps-controller'; +import { createMockInternalAccount } from '../../../../util/test/accountsControllerTestUtils'; import { useRampsOrders } from './useRampsOrders'; +const RAMP_HOOKS_TEST_WALLET_ID = 'keyring:use-ramps-orders-test' as const; +const RAMP_HOOKS_TEST_GROUP_ID = + `${RAMP_HOOKS_TEST_WALLET_ID}/ethereum` as const; +const RAMP_HOOKS_TEST_ACCOUNT_ID = 'account-rh-1'; +/** Must be a valid EVM address (20 bytes) so `areAddressesEqual` treats it as EVM. */ +const RAMP_HOOKS_TEST_ADDRESS = '0x2990079bcdee240329a520d2444386fc119da21a'; + +const rampHooksTestInternalAccount = { + ...createMockInternalAccount(RAMP_HOOKS_TEST_ADDRESS, 'Test'), + id: RAMP_HOOKS_TEST_ACCOUNT_ID, +}; + const mockAddOrder = jest.fn(); +const mockAddPrecreatedOrder = jest.fn(); const mockRemoveOrder = jest.fn(); const mockGetOrder = jest.fn(); const mockGetOrderFromCallback = jest.fn(); @@ -14,6 +29,8 @@ jest.mock('../../../../core/Engine', () => ({ context: { RampsController: { addOrder: (...args: unknown[]) => mockAddOrder(...args), + addPrecreatedOrder: (...args: unknown[]) => + mockAddPrecreatedOrder(...args), removeOrder: (...args: unknown[]) => mockRemoveOrder(...args), getOrder: (...args: unknown[]) => mockGetOrder(...args), getOrderFromCallback: (...args: unknown[]) => @@ -32,7 +49,7 @@ const createMockOrder = (overrides: Partial = {}): RampsOrder => ({ createdAt: Date.now(), totalFeesFiat: 5, txHash: '0xabc', - walletAddress: '0x123', + walletAddress: RAMP_HOOKS_TEST_ADDRESS, status: RampsOrderStatus.Completed, network: { name: 'Ethereum', chainId: 'eip155:1' }, canBeUpdated: false, @@ -51,6 +68,45 @@ const createMockStore = (orders: RampsOrder[] = []) => RampsController: { orders, }, + AccountTreeController: { + accountTree: { + wallets: { + [RAMP_HOOKS_TEST_WALLET_ID]: { + id: RAMP_HOOKS_TEST_WALLET_ID, + metadata: { name: 'Test wallet' }, + groups: { + [RAMP_HOOKS_TEST_GROUP_ID]: { + id: RAMP_HOOKS_TEST_GROUP_ID, + type: AccountGroupType.SingleAccount, + accounts: [RAMP_HOOKS_TEST_ACCOUNT_ID], + metadata: { name: 'Test Group' }, + }, + }, + }, + }, + selectedAccountGroup: RAMP_HOOKS_TEST_GROUP_ID, + }, + }, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + enableMultichainAccounts: { + enabled: true, + featureVersion: '1', + minimumVersion: '1.0.0', + }, + }, + }, + AccountsController: { + internalAccounts: { + accounts: { + [RAMP_HOOKS_TEST_ACCOUNT_ID]: rampHooksTestInternalAccount, + }, + selectedAccount: RAMP_HOOKS_TEST_ACCOUNT_ID, + }, + }, + KeyringController: { + keyrings: [], + }, }, }), }, @@ -75,7 +131,7 @@ describe('useRampsOrders', () => { expect(result.current.orders).toEqual([]); }); - it('returns orders from the store', () => { + it('returns orders from the store when walletAddress matches the selected account group', () => { const order = createMockOrder(); const store = createMockStore([order]); const { result } = renderHook(() => useRampsOrders(), { @@ -85,6 +141,19 @@ describe('useRampsOrders', () => { expect(result.current.orders).toEqual([order]); }); + it('excludes orders whose walletAddress is not in the selected account group', () => { + const foreignOrder = createMockOrder({ + providerOrderId: 'foreign-order', + walletAddress: '0x0000000000000000000000000000000000000001', + }); + const store = createMockStore([foreignOrder]); + const { result } = renderHook(() => useRampsOrders(), { + wrapper: wrapper(store), + }); + + expect(result.current.orders).toEqual([]); + }); + it('finds an order by providerOrderId', () => { const order1 = createMockOrder({ providerOrderId: 'order-1' }); const order2 = createMockOrder({ providerOrderId: 'order-2' }); @@ -182,6 +251,29 @@ describe('useRampsOrders', () => { expect(returnedOrder).toEqual(callbackOrder); }); + it('calls Engine.context.RampsController.addPrecreatedOrder', () => { + const store = createMockStore(); + const { result } = renderHook(() => useRampsOrders(), { + wrapper: wrapper(store), + }); + + act(() => { + result.current.addPrecreatedOrder({ + orderId: '/providers/transak/orders/abc-123', + providerCode: 'transak', + walletAddress: '0xabc', + chainId: '1', + }); + }); + + expect(mockAddPrecreatedOrder).toHaveBeenCalledWith({ + orderId: '/providers/transak/orders/abc-123', + providerCode: 'transak', + walletAddress: '0xabc', + chainId: '1', + }); + }); + it('exposes all expected functions', () => { const store = createMockStore(); const { result } = renderHook(() => useRampsOrders(), { @@ -190,6 +282,7 @@ describe('useRampsOrders', () => { expect(typeof result.current.getOrderById).toBe('function'); expect(typeof result.current.addOrder).toBe('function'); + expect(typeof result.current.addPrecreatedOrder).toBe('function'); expect(typeof result.current.removeOrder).toBe('function'); expect(typeof result.current.refreshOrder).toBe('function'); expect(typeof result.current.getOrderFromCallback).toBe('function'); diff --git a/app/components/UI/Ramp/hooks/useRampsOrders.ts b/app/components/UI/Ramp/hooks/useRampsOrders.ts index fac5e7ead23..48a78cb0480 100644 --- a/app/components/UI/Ramp/hooks/useRampsOrders.ts +++ b/app/components/UI/Ramp/hooks/useRampsOrders.ts @@ -1,13 +1,22 @@ import { useCallback } from 'react'; import { useSelector } from 'react-redux'; import type { RampsOrder } from '@metamask/ramps-controller'; +import { extractOrderCode } from '../utils/extractOrderCode'; import Engine from '../../../../core/Engine'; -import { selectRampsOrders } from '../../../../selectors/rampsController'; +import { selectRampsOrdersForSelectedAccountGroup } from '../../../../selectors/rampsController'; + +export interface AddPrecreatedOrderParams { + orderId: string; + providerCode: string; + walletAddress: string; + chainId?: string; +} export interface UseRampsOrdersResult { orders: RampsOrder[]; getOrderById: (providerOrderId: string) => RampsOrder | undefined; addOrder: (order: RampsOrder) => void; + addPrecreatedOrder: (params: AddPrecreatedOrderParams) => void; removeOrder: (providerOrderId: string) => void; refreshOrder: ( providerCode: string, @@ -22,11 +31,13 @@ export interface UseRampsOrdersResult { } export function useRampsOrders(): UseRampsOrdersResult { - const orders = useSelector(selectRampsOrders); + const orders = useSelector(selectRampsOrdersForSelectedAccountGroup); const getOrderById = useCallback( - (providerOrderId: string) => - orders.find((o) => o.providerOrderId === providerOrderId), + (providerOrderId: string) => { + const orderCode = extractOrderCode(providerOrderId); + return orders.find((o) => o.providerOrderId === orderCode); + }, [orders], ); @@ -35,6 +46,12 @@ export function useRampsOrders(): UseRampsOrdersResult { [], ); + const addPrecreatedOrder = useCallback( + (params: AddPrecreatedOrderParams) => + Engine.context.RampsController.addPrecreatedOrder(params), + [], + ); + const removeOrder = useCallback( (providerOrderId: string) => Engine.context.RampsController.removeOrder(providerOrderId), @@ -61,6 +78,7 @@ export function useRampsOrders(): UseRampsOrdersResult { orders, getOrderById, addOrder, + addPrecreatedOrder, removeOrder, refreshOrder, getOrderFromCallback, diff --git a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts index bc9a1b9436f..9f717066d84 100644 --- a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts @@ -1,4 +1,5 @@ -import { renderHook, act } from '@testing-library/react-native'; +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import React from 'react'; @@ -9,6 +10,7 @@ import Engine from '../../../../core/Engine'; jest.mock('../../../../core/Engine', () => ({ context: { RampsController: { + getPaymentMethods: jest.fn(), setSelectedPaymentMethod: jest.fn(), }, }, @@ -31,144 +33,233 @@ const mockPaymentMethods: PaymentMethod[] = [ }, ]; -const createMockStore = (paymentMethodsState = {}) => +const baseRampsState = { + userRegion: { + country: { + currency: 'USD', + quickAmounts: [50, 100, 200], + }, + state: null, + regionCode: 'us', + }, + providers: { + data: [], + selected: { + id: '/providers/transak', + name: 'Transak', + }, + isLoading: false, + error: null, + }, + tokens: { + data: null, + selected: { + assetId: 'eip155:1/slip44:60', + chainId: 'eip155:1', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + iconUrl: '', + tokenSupported: true, + }, + isLoading: false, + error: null, + }, + paymentMethods: { + data: [], + selected: null, + isLoading: false, + error: null, + }, +}; + +const createMockStore = (rampsControllerOverrides = {}) => configureStore({ reducer: { engine: () => ({ backgroundState: { RampsController: { - paymentMethods: { - data: [], - selected: null, - isLoading: false, - error: null, - ...paymentMethodsState, - }, + ...baseRampsState, + ...rampsControllerOverrides, }, }, }), }, }); -const wrapper = (store: ReturnType) => - function Wrapper({ children }: { children: React.ReactNode }) { - return React.createElement(Provider, { store } as never, children); - }; +const createWrapper = (store: ReturnType) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement( + Provider, + { store } as never, + React.createElement( + QueryClientProvider, + { client: queryClient }, + children, + ), + ); + + return { Wrapper, queryClient }; +}; describe('useRampsPaymentMethods', () => { beforeEach(() => { jest.clearAllMocks(); }); - describe('return value structure', () => { - it('returns paymentMethods, selectedPaymentMethod, setSelectedPaymentMethod, isLoading, and error', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); - expect(result.current).toMatchObject({ - paymentMethods: [], - selectedPaymentMethod: null, - isLoading: false, - error: null, - }); - expect(typeof result.current.setSelectedPaymentMethod).toBe('function'); + it('returns idle before an active request exists', () => { + const store = createMockStore({ + providers: { ...baseRampsState.providers, selected: null }, }); - }); + const { Wrapper } = createWrapper(store); - describe('paymentMethods state', () => { - it('returns paymentMethods from state', () => { - const store = createMockStore({ data: mockPaymentMethods }); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); - expect(result.current.paymentMethods).toEqual(mockPaymentMethods); + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: Wrapper, }); - it('returns empty array when paymentMethods are not available', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); - expect(result.current.paymentMethods).toEqual([]); + expect(result.current).toMatchObject({ + paymentMethods: [], + selectedPaymentMethod: null, + isLoading: false, + status: 'idle', + isSuccess: false, + error: null, }); + expect( + Engine.context.RampsController.getPaymentMethods, + ).not.toHaveBeenCalled(); }); - describe('selectedPaymentMethod state', () => { - it('returns selectedPaymentMethod from state', () => { - const store = createMockStore({ + it('returns loading while the active query is in flight', () => { + const store = createMockStore(); + const { Wrapper } = createWrapper(store); + + ( + Engine.context.RampsController.getPaymentMethods as jest.Mock + ).mockImplementation(() => new Promise(() => undefined)); + + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: Wrapper, + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.status).toBe('loading'); + }); + + it('returns success with data and preserves controller-backed selection', async () => { + const store = createMockStore({ + paymentMethods: { + ...baseRampsState.paymentMethods, selected: mockPaymentMethods[0], - }); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); - expect(result.current.selectedPaymentMethod).toEqual( - mockPaymentMethods[0], - ); - }); - - it('returns null when selectedPaymentMethod is not available', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); - expect(result.current.selectedPaymentMethod).toBeNull(); + }, + }); + const { Wrapper } = createWrapper(store); + + ( + Engine.context.RampsController.getPaymentMethods as jest.Mock + ).mockResolvedValue({ + payments: mockPaymentMethods, + }); + + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(result.current.status).toBe('success'); }); + + expect(result.current.paymentMethods).toEqual(mockPaymentMethods); + expect(result.current.selectedPaymentMethod).toEqual(mockPaymentMethods[0]); + expect(result.current.isSuccess).toBe(true); }); - describe('loading state', () => { - it('returns isLoading true when isLoading is true', () => { - const store = createMockStore({ - isLoading: true, - }); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); - expect(result.current.isLoading).toBe(true); + it('returns success with an empty array when the request completes empty', async () => { + const store = createMockStore(); + const { Wrapper } = createWrapper(store); + + ( + Engine.context.RampsController.getPaymentMethods as jest.Mock + ).mockResolvedValue({ + payments: [], }); + + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(result.current.status).toBe('success'); + }); + + expect(result.current.paymentMethods).toEqual([]); + expect(result.current.error).toBeNull(); }); - describe('error state', () => { - it('returns error from state', () => { - const store = createMockStore({ - error: 'Network error', - }); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); - expect(result.current.error).toBe('Network error'); + it('returns error when the request rejects', async () => { + const store = createMockStore(); + const { Wrapper } = createWrapper(store); + + ( + Engine.context.RampsController.getPaymentMethods as jest.Mock + ).mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(result.current.status).toBe('error'); }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe('Network error'); + expect(result.current.paymentMethods).toEqual([]); }); - describe('setSelectedPaymentMethod', () => { - it('calls Engine.context.RampsController.setSelectedPaymentMethod with payment method id', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); + it('calls Engine.context.RampsController.setSelectedPaymentMethod with payment method id', () => { + const store = createMockStore({ + providers: { ...baseRampsState.providers, selected: null }, + }); + const { Wrapper } = createWrapper(store); - act(() => { - result.current.setSelectedPaymentMethod(mockPaymentMethods[0]); - }); + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: Wrapper, + }); - expect( - Engine.context.RampsController.setSelectedPaymentMethod, - ).toHaveBeenCalledWith(mockPaymentMethods[0].id); + act(() => { + result.current.setSelectedPaymentMethod(mockPaymentMethods[0]); }); - it('calls Engine.context.RampsController.setSelectedPaymentMethod with undefined when payment method is null', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); + expect( + Engine.context.RampsController.setSelectedPaymentMethod, + ).toHaveBeenCalledWith(mockPaymentMethods[0].id); + }); - act(() => { - result.current.setSelectedPaymentMethod(null); - }); + it('calls Engine.context.RampsController.setSelectedPaymentMethod with undefined when payment method is null', () => { + const store = createMockStore({ + providers: { ...baseRampsState.providers, selected: null }, + }); + const { Wrapper } = createWrapper(store); - expect( - Engine.context.RampsController.setSelectedPaymentMethod, - ).toHaveBeenCalledWith(undefined); + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: Wrapper, }); + + act(() => { + result.current.setSelectedPaymentMethod(null); + }); + + expect( + Engine.context.RampsController.setSelectedPaymentMethod, + ).toHaveBeenCalledWith(undefined); }); }); diff --git a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts index 8bf26ec9407..432b76a11c0 100644 --- a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts +++ b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts @@ -1,8 +1,17 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import { selectPaymentMethods } from '../../../../selectors/rampsController'; +import { + selectPaymentMethods, + selectProviders, + selectTokens, + selectUserRegion, +} from '../../../../selectors/rampsController'; import { type PaymentMethod } from '@metamask/ramps-controller'; import Engine from '../../../../core/Engine'; +import { rampsQueries } from '../queries'; + +export type RampsQueryStatus = 'idle' | 'loading' | 'success' | 'error'; /** * Result returned by the useRampsPaymentMethods hook. @@ -22,9 +31,21 @@ export interface UseRampsPaymentMethodsResult { */ setSelectedPaymentMethod: (paymentMethod: PaymentMethod | null) => void; /** - * Whether the payment methods request is currently loading. + * Whether the payment methods request is currently loading (no cached data). */ isLoading: boolean; + /** + * Whether a fetch is in-flight (includes background refetches with cached data). + */ + isFetching: boolean; + /** + * Query lifecycle status for the active payment methods request. + */ + status: RampsQueryStatus; + /** + * Whether the active payment methods request completed successfully. + */ + isSuccess: boolean; /** * The error message if the request failed, or null. */ @@ -38,12 +59,34 @@ export interface UseRampsPaymentMethodsResult { * @returns Payment methods state. */ export function useRampsPaymentMethods(): UseRampsPaymentMethodsResult { - const { - data: paymentMethods, - selected: selectedPaymentMethod, - isLoading, - error, - } = useSelector(selectPaymentMethods); + const { selected: selectedPaymentMethod } = useSelector(selectPaymentMethods); + const { selected: selectedProvider } = useSelector(selectProviders); + const { selected: selectedToken } = useSelector(selectTokens); + const userRegion = useSelector(selectUserRegion); + + const tokenSupportedByProvider = selectedProvider?.supportedCryptoCurrencies + ? selectedProvider.supportedCryptoCurrencies[ + selectedToken?.assetId ?? '' + ] === true + : true; + + const queryEnabled = Boolean( + userRegion?.regionCode && + userRegion?.country?.currency && + selectedToken?.assetId && + selectedProvider?.id && + tokenSupportedByProvider, + ); + + const paymentMethodsQuery = useQuery({ + ...rampsQueries.paymentMethods.options({ + regionCode: userRegion?.regionCode ?? '', + fiat: userRegion?.country?.currency ?? '', + assetId: selectedToken?.assetId ?? '', + providerId: selectedProvider?.id ?? '', + }), + enabled: queryEnabled, + }); const setSelectedPaymentMethod = useCallback( (paymentMethod: PaymentMethod | null) => @@ -53,12 +96,35 @@ export function useRampsPaymentMethods(): UseRampsPaymentMethodsResult { [], ); + const status = useMemo(() => { + if (!queryEnabled) { + return 'idle'; + } + if (paymentMethodsQuery.isPending) { + return 'loading'; + } + if (paymentMethodsQuery.isError) { + return 'error'; + } + return 'success'; + }, [ + paymentMethodsQuery.isError, + paymentMethodsQuery.isPending, + queryEnabled, + ]); + return { - paymentMethods, + paymentMethods: paymentMethodsQuery.data ?? [], selectedPaymentMethod, setSelectedPaymentMethod, - isLoading, - error, + isLoading: status === 'loading', + isFetching: paymentMethodsQuery.isFetching, + status, + isSuccess: status === 'success', + error: + paymentMethodsQuery.error instanceof Error + ? paymentMethodsQuery.error.message + : null, }; } diff --git a/app/components/UI/Ramp/hooks/useRampsProviders.test.ts b/app/components/UI/Ramp/hooks/useRampsProviders.test.ts index 06ddbdf0826..4a7c3d7614b 100644 --- a/app/components/UI/Ramp/hooks/useRampsProviders.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsProviders.test.ts @@ -270,5 +270,19 @@ describe('useRampsProviders', () => { expect(mockDeterminePreferredProvider).not.toHaveBeenCalled(); }); + + it('does not call determinePreferredProvider when providers is undefined', () => { + const store = createMockStore({ data: undefined }); + mockDeterminePreferredProvider.mockClear(); + + renderHook(() => useRampsProviders(), { + wrapper: wrapper(store), + }); + + expect(mockDeterminePreferredProvider).not.toHaveBeenCalled(); + expect( + Engine.context.RampsController.setSelectedProvider, + ).not.toHaveBeenCalled(); + }); }); }); diff --git a/app/components/UI/Ramp/hooks/useRampsProviders.ts b/app/components/UI/Ramp/hooks/useRampsProviders.ts index 66447e34fd2..017e6f626ec 100644 --- a/app/components/UI/Ramp/hooks/useRampsProviders.ts +++ b/app/components/UI/Ramp/hooks/useRampsProviders.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { selectProviders, - selectRampsOrders, + selectRampsOrdersForSelectedAccountGroup, } from '../../../../selectors/rampsController'; import { type Provider } from '@metamask/ramps-controller'; import Engine from '../../../../core/Engine'; @@ -55,7 +55,9 @@ export function useRampsProviders(): UseRampsProvidersResult { } = useSelector(selectProviders); const legacyOrders = useSelector(getOrders); - const controllerOrders = useSelector(selectRampsOrders); + const controllerOrders = useSelector( + selectRampsOrdersForSelectedAccountGroup, + ); const completedOrders = useMemo( () => [ @@ -72,7 +74,7 @@ export function useRampsProviders(): UseRampsProvidersResult { ); useEffect(() => { - if (providers.length > 0 && !selectedProvider) { + if (providers && providers.length > 0 && !selectedProvider) { setSelectedProvider( determinePreferredProvider(completedOrders, providers), ); diff --git a/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts b/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts index 0a0d4a7736c..09e4f07d78e 100644 --- a/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts @@ -1,4 +1,5 @@ import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import React from 'react'; @@ -6,11 +7,12 @@ import { useRampsQuotes, type GetQuotesOptions } from './useRampsQuotes'; import type { Quote } from '../types'; import Engine from '../../../../core/Engine'; +const mockGetBuyWidgetData = jest.fn(); jest.mock('../../../../core/Engine', () => ({ context: { RampsController: { getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: (...args: unknown[]) => mockGetBuyWidgetData(...args), }, }, })); @@ -26,10 +28,28 @@ const createMockStore = () => }, }); -const wrapper = (store: ReturnType) => - function Wrapper({ children }: { children: React.ReactNode }) { - return React.createElement(Provider, { store } as never, children); - }; +const createWrapper = (store: ReturnType) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement( + Provider, + { store } as never, + React.createElement( + QueryClientProvider, + { client: queryClient }, + children, + ), + ); + + return { Wrapper, queryClient }; +}; const mockQuotesResponse = { success: [{ provider: 'test', quote: { amountIn: 100 } }], @@ -44,60 +64,69 @@ describe('useRampsQuotes', () => { }); describe('return value structure', () => { - it('returns getQuotes and getWidgetUrl functions', () => { + it('returns getQuotes and getBuyWidgetData functions', () => { const store = createMockStore(); + const { Wrapper } = createWrapper(store); + const { result } = renderHook(() => useRampsQuotes(), { - wrapper: wrapper(store), + wrapper: Wrapper, }); expect(typeof result.current.getQuotes).toBe('function'); - expect(typeof result.current.getWidgetUrl).toBe('function'); + expect(typeof result.current.getBuyWidgetData).toBe('function'); }); + }); - it('returns data, loading, error with default values when no options', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsQuotes(), { - wrapper: wrapper(store), - }); + it('returns idle state when no options are provided', () => { + const store = createMockStore(); + const { Wrapper } = createWrapper(store); - expect(result.current.data).toBeNull(); - expect(result.current.loading).toBe(false); - expect(result.current.error).toBeNull(); + const { result } = renderHook(() => useRampsQuotes(), { + wrapper: Wrapper, }); - }); - describe('getQuotes', () => { - it('calls Engine.context.RampsController.getQuotes with options', async () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsQuotes(), { - wrapper: wrapper(store), - }); + expect(result.current.data).toBeNull(); + expect(result.current.loading).toBe(false); + expect(result.current.status).toBe('idle'); + expect(result.current.isSuccess).toBe(false); + expect(result.current.error).toBeNull(); + }); - (Engine.context.RampsController.getQuotes as jest.Mock).mockResolvedValue( - { success: [], sorted: [], error: [], customActions: [] }, - ); + it('calls Engine.context.RampsController.getQuotes with options', async () => { + const store = createMockStore(); + const { Wrapper } = createWrapper(store); + const { result } = renderHook(() => useRampsQuotes(), { + wrapper: Wrapper, + }); - const options = { - amount: 100, - walletAddress: '0x123', - assetId: 'eip155:1/slip44:60', - }; + (Engine.context.RampsController.getQuotes as jest.Mock).mockResolvedValue({ + success: [], + sorted: [], + error: [], + customActions: [], + }); - await act(async () => { - await result.current.getQuotes(options); - }); + const options = { + amount: 100, + walletAddress: '0x123', + assetId: 'eip155:1/slip44:60', + }; - expect(Engine.context.RampsController.getQuotes).toHaveBeenCalledWith( - options, - ); + await act(async () => { + await result.current.getQuotes(options); }); + + expect(Engine.context.RampsController.getQuotes).toHaveBeenCalledWith( + options, + ); }); - describe('getWidgetUrl', () => { - it('calls Engine.context.RampsController.getWidgetUrl with quote', async () => { + describe('getBuyWidgetData', () => { + it('calls Engine.context.RampsController.getBuyWidgetData with quote', async () => { const store = createMockStore(); + const { Wrapper } = createWrapper(store); const { result } = renderHook(() => useRampsQuotes(), { - wrapper: wrapper(store), + wrapper: Wrapper, }); const testQuote: Quote = { @@ -110,170 +139,110 @@ describe('useRampsQuotes', () => { }, } as Quote; - ( - Engine.context.RampsController.getWidgetUrl as jest.Mock - ).mockResolvedValue('https://global.transak.com/?apiKey=test'); + const mockBuyWidget = { + url: 'https://global.transak.com/?apiKey=test', + orderId: null, + }; + mockGetBuyWidgetData.mockResolvedValue(mockBuyWidget); + let resolvedValue: Awaited< + ReturnType + > = null; await act(async () => { - await result.current.getWidgetUrl(testQuote); + resolvedValue = await result.current.getBuyWidgetData(testQuote); }); - expect(Engine.context.RampsController.getWidgetUrl).toHaveBeenCalledWith( - testQuote, - ); + expect(mockGetBuyWidgetData).toHaveBeenCalledWith(testQuote); + expect(resolvedValue).toEqual(mockBuyWidget); }); }); describe('fetch mode', () => { - const options = { + const options: GetQuotesOptions = { amount: 100, walletAddress: '0x123', assetId: 'eip155:1/slip44:60', + paymentMethods: ['/payments/card'], + providers: ['/providers/transak'], }; - it('fetches and updates data/loading when options is provided', async () => { + it('fetches and updates data/loading when options are provided', async () => { const store = createMockStore(); + const { Wrapper } = createWrapper(store); (Engine.context.RampsController.getQuotes as jest.Mock).mockResolvedValue( mockQuotesResponse, ); const { result } = renderHook(() => useRampsQuotes(options), { - wrapper: wrapper(store), + wrapper: Wrapper, }); expect(result.current.loading).toBe(true); + expect(result.current.status).toBe('loading'); expect(result.current.data).toBeNull(); await waitFor(() => { - expect(result.current.loading).toBe(false); + expect(result.current.status).toBe('success'); }); + expect(result.current.loading).toBe(false); expect(result.current.data).toEqual(mockQuotesResponse); + expect(result.current.isSuccess).toBe(true); expect(Engine.context.RampsController.getQuotes).toHaveBeenCalledWith( - options, + expect.objectContaining({ + amount: 100, + walletAddress: '0x123', + assetId: 'eip155:1/slip44:60', + paymentMethods: ['/payments/card'], + providers: ['/providers/transak'], + }), ); }); - it('skips fetch when options is null', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsQuotes(null), { - wrapper: wrapper(store), - }); - - expect(result.current.data).toBeNull(); - expect(result.current.loading).toBe(false); - expect(result.current.error).toBeNull(); - expect(Engine.context.RampsController.getQuotes).not.toHaveBeenCalled(); - }); - - it('always sets loading to false in finally when effect cleanup runs', async () => { - const store = createMockStore(); - let resolve: ((value: typeof mockQuotesResponse) => void) | undefined; - const fetchPromise = new Promise((r) => { - resolve = r; - }); - (Engine.context.RampsController.getQuotes as jest.Mock).mockReturnValue( - fetchPromise, - ); - - const { result, rerender } = renderHook( - ({ params }: { params: GetQuotesOptions | null }) => - useRampsQuotes(params), - { - wrapper: wrapper(store), - initialProps: { params: options } as { - params: GetQuotesOptions | null; - }, - }, - ); - - expect(result.current.loading).toBe(true); - - rerender({ params: null }); - - await act(async () => { - if (resolve) resolve(mockQuotesResponse); - }); - - expect(result.current.loading).toBe(false); - expect(result.current.data).toBeNull(); - }); - - it('does not apply stale data when cancelled', async () => { - const store = createMockStore(); - let resolveFirst: - | ((value: typeof mockQuotesResponse) => void) - | undefined; - const firstPromise = new Promise((r) => { - resolveFirst = r; - }); - (Engine.context.RampsController.getQuotes as jest.Mock).mockReturnValue( - firstPromise, - ); - - const { result, rerender } = renderHook( - ({ params }: { params: GetQuotesOptions | null }) => - useRampsQuotes(params), - { - wrapper: wrapper(store), - initialProps: { params: options } as { - params: GetQuotesOptions | null; - }, - }, - ); - - rerender({ params: null }); - - await act(async () => { - if (resolveFirst) resolveFirst(mockQuotesResponse); - }); - - expect(result.current.data).toBeNull(); - }); - - it('populates error when fetch rejects', async () => { + it('returns error when the request rejects', async () => { const store = createMockStore(); + const { Wrapper } = createWrapper(store); (Engine.context.RampsController.getQuotes as jest.Mock).mockRejectedValue( new Error('Network error'), ); const { result } = renderHook(() => useRampsQuotes(options), { - wrapper: wrapper(store), + wrapper: Wrapper, }); await waitFor(() => { - expect(result.current.loading).toBe(false); + expect(result.current.status).toBe('error'); }); + expect(result.current.loading).toBe(false); expect(result.current.error).toBe('Network error'); expect(result.current.data).toBeNull(); }); - it('clears data when options becomes null', async () => { + it('returns idle and clears data when options become null', async () => { const store = createMockStore(); + const { Wrapper } = createWrapper(store); (Engine.context.RampsController.getQuotes as jest.Mock).mockResolvedValue( mockQuotesResponse, ); - const { result, rerender } = renderHook( - ({ params }: { params: GetQuotesOptions | null }) => - useRampsQuotes(params), - { - wrapper: wrapper(store), - initialProps: { params: options } as { - params: GetQuotesOptions | null; - }, - }, - ); + const { result, rerender } = renderHook< + ReturnType, + { params: GetQuotesOptions | null } + >(({ params }) => useRampsQuotes(params), { + wrapper: Wrapper, + initialProps: { params: options }, + }); await waitFor(() => { - expect(result.current.data).toEqual(mockQuotesResponse); + expect(result.current.status).toBe('success'); }); rerender({ params: null }); expect(result.current.data).toBeNull(); expect(result.current.loading).toBe(false); + expect(result.current.status).toBe('idle'); expect(result.current.error).toBeNull(); }); }); diff --git a/app/components/UI/Ramp/hooks/useRampsQuotes.ts b/app/components/UI/Ramp/hooks/useRampsQuotes.ts index 6d1eb84c844..dfed6f18d40 100644 --- a/app/components/UI/Ramp/hooks/useRampsQuotes.ts +++ b/app/components/UI/Ramp/hooks/useRampsQuotes.ts @@ -1,11 +1,11 @@ -import { useCallback, useEffect, useState } from 'react'; -import type { QuotesResponse } from '@metamask/ramps-controller'; +import { useCallback, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { BuyWidget, QuotesResponse } from '@metamask/ramps-controller'; import type { Quote } from '../types'; import Engine from '../../../../core/Engine'; +import { rampsQueries } from '../queries'; +import type { RampsQueryStatus } from './useRampsPaymentMethods'; -/** - * Options for fetching quotes (matches RampsController.getQuotes). - */ export interface GetQuotesOptions { region?: string; fiat?: string; @@ -19,39 +19,16 @@ export interface GetQuotesOptions { ttl?: number; } -/** - * Result returned by the useRampsQuotes hook. - */ export interface UseRampsQuotesResult { - /** - * Fetches quotes and returns the response. Uses controller cache; callers manage response in local state. - */ getQuotes: (options: GetQuotesOptions) => Promise; - /** - * Fetches the widget URL from a quote for redirect providers. - * Makes a request to the buyURL endpoint to get the actual provider widget URL. - * @param quote - The quote to fetch the widget URL from. - * @returns Promise resolving to the widget URL string, or null if not available. - */ - getWidgetUrl: (quote: Quote) => Promise; - /** Fetched quotes response when options is used. Null when not fetching or fetch skipped. */ + getBuyWidgetData: (quote: Quote) => Promise; data: QuotesResponse | null; - /** True while a fetch is in progress. Reset when fetch settles, unless the effect was cancelled (component unmounted). */ loading: boolean; - /** Error message when fetch rejects. */ + status: RampsQueryStatus; + isSuccess: boolean; error: string | null; } -/** - * Hook to get quote-related functions from RampsController. - * Components call getQuotes() and manage quotes/selection locally. - * - * When options is provided, runs an effect to fetch quotes and returns data, loading, and error. - * Loading is reset when the fetch settles unless the effect was cancelled (avoids setState on unmounted component). - * - * @param options - GetQuotesOptions to fetch, or null/undefined to skip fetch. - * @returns getQuotes, getWidgetUrl, and when options used: data, loading, error. - */ export function useRampsQuotes( options?: GetQuotesOptions | null, ): UseRampsQuotesResult { @@ -60,59 +37,54 @@ export function useRampsQuotes( [], ); - const getWidgetUrl = useCallback( - (quote: Quote) => Engine.context.RampsController.getWidgetUrl(quote), - [], + const getBuyWidgetData = useCallback((quote: Quote) => { + const ramps = Engine.context + .RampsController as typeof Engine.context.RampsController & { + getBuyWidgetData: (q: Quote) => Promise; + }; + return ramps.getBuyWidgetData(quote); + }, []); + + const queryEnabled = Boolean( + options?.assetId && options.walletAddress && options.amount > 0, ); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const quotesQuery = useQuery({ + ...rampsQueries.quotes.options({ + assetId: options?.assetId, + amount: options?.amount ?? 0, + walletAddress: options?.walletAddress ?? '', + redirectUrl: options?.redirectUrl, + paymentMethods: options?.paymentMethods, + providers: options?.providers, + forceRefresh: options?.forceRefresh, + ttl: options?.ttl, + }), + enabled: queryEnabled, + }); - useEffect(() => { - if (options == null) { - setData(null); - setLoading(false); - setError(null); - return; + const status = useMemo(() => { + if (!queryEnabled) { + return 'idle'; } - - let cancelled = false; - setLoading(true); - setData(null); - setError(null); - - getQuotes(options) - .then((response) => { - if (!cancelled) { - setData(response); - } - }) - .catch((err) => { - if (!cancelled) { - setData(null); - setError( - err instanceof Error ? err.message : 'Failed to fetch quotes', - ); - } - }) - .finally(() => { - if (!cancelled) { - setLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [options, getQuotes]); + if (quotesQuery.isPending) { + return 'loading'; + } + if (quotesQuery.isError) { + return 'error'; + } + return 'success'; + }, [queryEnabled, quotesQuery.isError, quotesQuery.isPending]); return { getQuotes, - getWidgetUrl, - data, - loading, - error, + getBuyWidgetData, + data: quotesQuery.data ?? null, + loading: status === 'loading', + status, + isSuccess: status === 'success', + error: + quotesQuery.error instanceof Error ? quotesQuery.error.message : null, }; } diff --git a/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.test.ts b/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.test.ts index cab3107eea9..e6b4193e728 100644 --- a/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.test.ts @@ -6,11 +6,11 @@ import useRampsUnifiedV2Enabled from './useRampsUnifiedV2Enabled'; import { getVersion } from 'react-native-device-info'; function mockInitialState({ - rampsUnifiedBuyV2ActiveFlag = true, - rampsUnifiedBuyV2MinimumVersionFlag, + enabled = true, + minimumVersion, }: { - rampsUnifiedBuyV2ActiveFlag?: boolean; - rampsUnifiedBuyV2MinimumVersionFlag?: string | null; + enabled?: boolean; + minimumVersion?: string | null; } = {}) { return { ...initialRootState, @@ -20,9 +20,9 @@ function mockInitialState({ RemoteFeatureFlagController: { remoteFeatureFlags: { rampsUnifiedBuyV2: { - active: rampsUnifiedBuyV2ActiveFlag, - ...(rampsUnifiedBuyV2MinimumVersionFlag !== undefined && { - minimumVersion: rampsUnifiedBuyV2MinimumVersionFlag, + enabled, + ...(minimumVersion !== undefined && { + minimumVersion, }), }, }, @@ -59,8 +59,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: false, - rampsUnifiedBuyV2MinimumVersionFlag: '2.0.0', + enabled: false, + minimumVersion: '2.0.0', }), }, ); @@ -76,8 +76,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: '1.0.0', + enabled: true, + minimumVersion: '1.0.0', }), }, ); @@ -93,8 +93,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: '2.0.0', + enabled: true, + minimumVersion: '2.0.0', }), }, ); @@ -111,8 +111,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0', + enabled: true, + minimumVersion: '7.63.0', }), }, ); @@ -127,8 +127,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: false, - rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0', + enabled: false, + minimumVersion: '7.63.0', }), }, ); @@ -143,8 +143,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0', + enabled: true, + minimumVersion: '7.63.0', }), }, ); @@ -159,8 +159,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: null, + enabled: true, + minimumVersion: null, }), }, ); @@ -175,8 +175,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: undefined, + enabled: true, + minimumVersion: undefined, }), }, ); @@ -191,8 +191,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0', + enabled: true, + minimumVersion: '7.63.0', }), }, ); @@ -200,15 +200,15 @@ describe('useRampsUnifiedV2Enabled', () => { expect(result.current).toBe(true); }); - it('returns false when both active flag and minimum version are not set', () => { + it('returns false when both enabled flag and minimum version are not set', () => { mockGetVersion.mockReturnValue('8.0.0'); const { result } = renderHookWithProvider( () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: false, - rampsUnifiedBuyV2MinimumVersionFlag: null, + enabled: false, + minimumVersion: null, }), }, ); diff --git a/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.ts b/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.ts index 2259af515b8..a586257ae1c 100644 --- a/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.ts +++ b/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.ts @@ -1,33 +1,13 @@ import { useSelector } from 'react-redux'; -import { - selectRampsUnifiedBuyV2ActiveFlag, - selectRampsUnifiedBuyV2MinimumVersionFlag, -} from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2'; -import { hasMinimumRequiredVersion } from '../utils/hasMinimumRequiredVersion'; +import { selectRampsUnifiedBuyV2Enabled } from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2'; export default function useRampsUnifiedV2Enabled() { - const rampsUnifiedBuyV2MinimumVersionFlag = useSelector( - selectRampsUnifiedBuyV2MinimumVersionFlag, - ); - const rampsUnifiedBuyV2ActiveFlag = useSelector( - selectRampsUnifiedBuyV2ActiveFlag, - ); + const isEnabled = useSelector(selectRampsUnifiedBuyV2Enabled); - const rampsUnifiedBuyV2BuildFlag = - process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED; - - // if build flag is defined, it takes precedence over remote feature flag - if ( - rampsUnifiedBuyV2BuildFlag === 'true' || - rampsUnifiedBuyV2BuildFlag === 'false' - ) { - return rampsUnifiedBuyV2BuildFlag === 'true'; + const buildFlag = process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED; + if (buildFlag === 'true' || buildFlag === 'false') { + return buildFlag === 'true'; } - const isRampsUnifiedV2Enabled = hasMinimumRequiredVersion( - rampsUnifiedBuyV2MinimumVersionFlag, - rampsUnifiedBuyV2ActiveFlag, - ); - - return isRampsUnifiedV2Enabled; + return isEnabled; } diff --git a/app/components/UI/Ramp/hooks/useTokenBuyability.test.ts b/app/components/UI/Ramp/hooks/useTokenBuyability.test.ts index c0a8bfd706f..d489f046a8b 100644 --- a/app/components/UI/Ramp/hooks/useTokenBuyability.test.ts +++ b/app/components/UI/Ramp/hooks/useTokenBuyability.test.ts @@ -1,5 +1,5 @@ import { renderHook } from '@testing-library/react-native'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as tokenBuyabilityModule from './useTokenBuyability'; import { useRampTokens, diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts index 0a8acfdf5ef..708fcd90549 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts @@ -149,8 +149,16 @@ jest.mock('../Views/Checkout', () => ({ ), })); +let capturedHandleNavigationStateChange: + | ((nav: { url: string }) => void) + | null = null; jest.mock('../utils/checkoutCallbackRegistry', () => ({ - registerCheckoutCallback: jest.fn(() => 'mock-callback-key'), + registerCheckoutCallback: jest.fn( + (callback: (nav: { url: string }) => void) => { + capturedHandleNavigationStateChange = callback; + return 'mock-callback-key'; + }, + ), })); jest.mock('../../../../util/Logger', () => ({ @@ -163,6 +171,7 @@ jest.mock('@metamask/ramps-controller', () => ({ (orderId: string, _env: string) => `transformed-${orderId}`, ), }, + normalizeProviderCode: (code: string) => code.replace(/^\/providers\//, ''), })); jest.mock('../Deposit/constants', () => ({ @@ -214,14 +223,20 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: mockQuote.fiatAmount }, + }), expect.objectContaining({ name: 'RampBasicInfo', params: expect.objectContaining({ quote: mockQuote }), @@ -258,7 +273,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockRequestOtt).toHaveBeenCalled(); @@ -266,13 +284,16 @@ describe('useTransakRouting', () => { 'test-ott', mockQuote, MOCK_WALLET_ADDRESS, - expect.any(Object), + { theme: 'light' }, ); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: mockQuote.fiatAmount }, + }), expect.objectContaining({ name: 'Checkout', params: expect.objectContaining({ @@ -319,7 +340,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockTransakCreateOrder).toHaveBeenCalledWith( @@ -352,14 +376,20 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: mockQuote.fiatAmount }, + }), expect.objectContaining({ name: 'RampKycProcessing', params: expect.objectContaining({ quote: mockQuote }), @@ -380,7 +410,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockLogoutFromProvider).toHaveBeenCalledWith(false); @@ -414,7 +447,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockSubmitPurposeOfUsageForm).toHaveBeenCalledWith([ @@ -445,6 +481,55 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); + await act(async () => { + await result.current.routeAfterAuthentication(mockQuote as never, 25); + }); + + expect(mockReset).toHaveBeenCalledWith( + expect.objectContaining({ + index: 1, + routes: [ + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: 25 }, + }), + expect.objectContaining({ + name: 'RampAdditionalVerification', + params: expect.objectContaining({ + quote: mockQuote, + kycUrl: 'https://kyc.example.com', + workFlowRunId: 'wf-123', + amount: 25, + }), + }), + ], + }), + ); + }); + + it('handles ADDITIONAL_FORMS_REQUIRED with IDPROOF when user amount is omitted', async () => { + mockGetUserDetails.mockResolvedValue({ + firstName: 'John', + address: {}, + }); + mockGetKycRequirement.mockResolvedValue({ + status: 'ADDITIONAL_FORMS_REQUIRED', + kycType: 'STANDARD', + }); + mockGetAdditionalRequirements.mockResolvedValue({ + formsRequired: [ + { + type: 'IDPROOF', + metadata: { + kycUrl: 'https://kyc.example.com', + workFlowRunId: 'wf-123', + }, + }, + ], + }); + + const { result } = renderHook(() => useTransakRouting()); + await act(async () => { await result.current.routeAfterAuthentication(mockQuote as never); }); @@ -453,13 +538,17 @@ describe('useTransakRouting', () => { expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: undefined }, + }), expect.objectContaining({ name: 'RampAdditionalVerification', params: expect.objectContaining({ quote: mockQuote, kycUrl: 'https://kyc.example.com', workFlowRunId: 'wf-123', + amount: undefined, }), }), ], @@ -484,7 +573,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -506,7 +598,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -528,7 +623,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -551,7 +649,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockRequestOtt).toHaveBeenCalled(); @@ -577,7 +678,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockRequestOtt).toHaveBeenCalled(); @@ -597,7 +701,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -613,7 +720,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -634,14 +744,20 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: mockQuote.fiatAmount }, + }), expect.objectContaining({ name: 'RampKycProcessing', }), @@ -661,7 +777,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -684,7 +803,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -708,7 +830,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -731,7 +856,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockRequestOtt).toHaveBeenCalled(); @@ -743,14 +871,20 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); act(() => { - result.current.navigateToVerifyIdentity({ quote: mockQuote as never }); + result.current.navigateToVerifyIdentity({ + quote: mockQuote as never, + amount: 30, + }); }); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: 30 }, + }), expect.objectContaining({ name: 'RampVerifyIdentity', params: expect.objectContaining({ quote: mockQuote }), @@ -762,12 +896,13 @@ describe('useTransakRouting', () => { }); describe('navigateToKycWebview', () => { - it('resets navigation stack to the KYC webview', () => { + it('resets navigation stack to the KYC webview with amount preserved', () => { const { result } = renderHook(() => useTransakRouting()); act(() => { result.current.navigateToKycWebview({ kycUrl: 'https://kyc.example.com', + amount: 30, }); }); @@ -775,7 +910,10 @@ describe('useTransakRouting', () => { expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: 30 }, + }), expect.objectContaining({ name: 'Checkout', params: expect.objectContaining({ @@ -788,4 +926,303 @@ describe('useTransakRouting', () => { ); }); }); + + describe('handleNavigationStateChange', () => { + const runApprovedFlowToCaptureCallback = async () => { + mockGetUserDetails.mockResolvedValue({ + firstName: 'John', + lastName: 'Doe', + mobileNumber: '+1', + dob: '1990-01-01', + address: {}, + }); + mockGetKycRequirement.mockResolvedValue({ + status: 'APPROVED', + kycType: 'SIMPLE', + }); + mockGetUserLimits.mockResolvedValue({ + remaining: { '1': 10000, '30': 50000, '365': 200000 }, + }); + mockRequestOtt.mockResolvedValue({ ott: 'test-ott' }); + mockGeneratePaymentWidgetUrl.mockReturnValue( + 'https://payment.example.com', + ); + + const { result } = renderHook(() => useTransakRouting()); + + await act(async () => { + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); + }); + + return capturedHandleNavigationStateChange; + }; + + it('returns early when url does not start with REDIRECTION_URL', async () => { + const handler = await runApprovedFlowToCaptureCallback(); + expect(handler).not.toBeNull(); + if (!handler) return; + + await act(async () => { + await handler({ url: 'https://other-site.com/path' }); + }); + + expect(mockGetOrder).not.toHaveBeenCalled(); + expect(mockAddOrder).not.toHaveBeenCalled(); + }); + + it('returns early when url has no orderId query param', async () => { + const handler = await runApprovedFlowToCaptureCallback(); + expect(handler).not.toBeNull(); + if (!handler) return; + + await act(async () => { + await handler({ + url: 'https://redirect.example.com/callback', + }); + }); + + expect(mockGetOrder).not.toHaveBeenCalled(); + }); + + it('logs error and returns when URL parsing throws', async () => { + const Logger = jest.requireMock('../../../../util/Logger'); + const mockLoggerError = Logger.error as jest.Mock; + + const parseThrowingUrl = + 'https://redirect.example.com?orderId=parse-error'; + const OriginalURL = global.URL; + const urlSpy = jest + .spyOn(global, 'URL') + .mockImplementation((url: string | URL, base?: string | URL) => { + const urlStr = typeof url === 'string' ? url : url.href; + if (urlStr === parseThrowingUrl) { + throw new TypeError('Invalid URL'); + } + return new OriginalURL(url, base); + }); + + const handler = await runApprovedFlowToCaptureCallback(); + expect(handler).not.toBeNull(); + if (!handler) return; + + await act(async () => { + await handler({ url: parseThrowingUrl }); + }); + + expect(mockLoggerError).toHaveBeenCalledWith( + expect.any(Error), + 'useTransakRouting: Error parsing redirect URL', + ); + expect(mockGetOrder).not.toHaveBeenCalled(); + + urlSpy.mockRestore(); + }); + + it('skips processing when orderId matches processingOrderIdRef', async () => { + const handler = await runApprovedFlowToCaptureCallback(); + expect(handler).not.toBeNull(); + if (!handler) return; + + const depositOrder = { + id: 'order-123', + providerOrderId: 'order-123', + provider: 'transak-native', + walletAddress: MOCK_WALLET_ADDRESS, + paymentDetails: {}, + }; + mockGetOrder.mockResolvedValue(depositOrder); + mockRefreshOrder.mockResolvedValue({ + providerOrderId: 'order-123', + cryptoCurrency: { symbol: 'ETH' }, + cryptoAmount: '0.05', + status: 'Pending', + fiatAmount: 100, + exchangeRate: 2000, + networkFees: 0, + partnerFees: 0, + totalFeesFiat: 0, + paymentMethod: { id: 'card' }, + network: { chainId: '1', name: 'Ethereum' }, + fiatCurrency: { symbol: 'USD' }, + }); + + const url = 'https://redirect.example.com?orderId=order-123'; + + await act(async () => { + await handler({ url }); + }); + + expect(mockGetOrder).toHaveBeenCalledTimes(1); + + await act(async () => { + await handler({ url }); + }); + + expect(mockGetOrder).toHaveBeenCalledTimes(1); + }); + + it('adds order, shows toast, navigates and tracks event on success', async () => { + const mockShowV2OrderToast = jest.requireMock('../utils/v2OrderToast') + .showV2OrderToast as jest.Mock; + + const handler = await runApprovedFlowToCaptureCallback(); + expect(handler).not.toBeNull(); + if (!handler) return; + + const depositOrder = { + id: 'order-123', + providerOrderId: 'order-123', + provider: 'transak-native', + walletAddress: MOCK_WALLET_ADDRESS, + paymentDetails: { instructions: 'Wire transfer' }, + }; + mockGetOrder.mockResolvedValue(depositOrder); + mockRefreshOrder.mockResolvedValue({ + providerOrderId: 'order-123', + cryptoCurrency: { symbol: 'ETH' }, + cryptoAmount: '0.05', + status: 'Pending', + fiatAmount: 100, + exchangeRate: 2000, + networkFees: 1, + partnerFees: 2, + totalFeesFiat: 3, + paymentMethod: { id: 'card' }, + network: { chainId: '1', name: 'Ethereum' }, + fiatCurrency: { symbol: 'USD' }, + }); + + await act(async () => { + await handler({ + url: 'https://redirect.example.com?orderId=order-123', + }); + }); + + expect(mockGetOrder).toHaveBeenCalledWith( + 'order-123', + MOCK_WALLET_ADDRESS, + ); + expect(mockRefreshOrder).toHaveBeenCalledWith( + 'transak-native', + 'order-123', + MOCK_WALLET_ADDRESS, + ); + expect(mockAddOrder).toHaveBeenCalledWith( + expect.objectContaining({ + providerOrderId: 'order-123', + paymentDetails: { instructions: 'Wire transfer' }, + }), + ); + expect(mockShowV2OrderToast).toHaveBeenCalledWith({ + orderId: 'order-123', + cryptocurrency: 'ETH', + cryptoAmount: '0.05', + status: 'Pending', + }); + expect(mockReset).toHaveBeenCalledWith( + expect.objectContaining({ + index: 0, + routes: [ + expect.objectContaining({ + params: expect.objectContaining({ orderId: 'order-123' }), + }), + ], + }), + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_TRANSACTION_CONFIRMED', + expect.objectContaining({ + ramp_type: 'DEPOSIT', + amount_source: 100, + amount_destination: 0.05, + exchange_rate: 2000, + }), + ); + }); + + it('resets processingOrderIdRef and logs when getOrder fails', async () => { + const Logger = jest.requireMock('../../../../util/Logger'); + const mockLoggerError = Logger.error as jest.Mock; + + const handler = await runApprovedFlowToCaptureCallback(); + expect(handler).not.toBeNull(); + if (!handler) return; + + mockGetOrder.mockRejectedValue(new Error('Network error')); + + await act(async () => { + await handler({ + url: 'https://redirect.example.com?orderId=order-123', + }); + }); + + expect(mockLoggerError).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + message: 'useTransakRouting: Failed to process order after checkout', + }), + ); + expect(mockAddOrder).not.toHaveBeenCalled(); + }); + + it('resets processingOrderIdRef and logs when depositOrder is null', async () => { + const Logger = jest.requireMock('../../../../util/Logger'); + const mockLoggerError = Logger.error as jest.Mock; + + const handler = await runApprovedFlowToCaptureCallback(); + expect(handler).not.toBeNull(); + if (!handler) return; + + mockGetOrder.mockResolvedValue(null); + + await act(async () => { + await handler({ + url: 'https://redirect.example.com?orderId=order-123', + }); + }); + + expect(mockLoggerError).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + message: 'useTransakRouting: Failed to process order after checkout', + }), + ); + expect(mockAddOrder).not.toHaveBeenCalled(); + }); + + it('resets processingOrderIdRef and logs when refreshOrder fails', async () => { + const Logger = jest.requireMock('../../../../util/Logger'); + const mockLoggerError = Logger.error as jest.Mock; + + const handler = await runApprovedFlowToCaptureCallback(); + expect(handler).not.toBeNull(); + if (!handler) return; + + mockGetOrder.mockResolvedValue({ + id: 'order-123', + providerOrderId: 'order-123', + provider: 'transak-native', + walletAddress: MOCK_WALLET_ADDRESS, + paymentDetails: {}, + }); + mockRefreshOrder.mockRejectedValue(new Error('Refresh failed')); + + await act(async () => { + await handler({ + url: 'https://redirect.example.com?orderId=order-123', + }); + }); + + expect(mockLoggerError).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + message: 'useTransakRouting: Failed to process order after checkout', + }), + ); + expect(mockAddOrder).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.ts b/app/components/UI/Ramp/hooks/useTransakRouting.ts index 850a4ff187e..342e15b0441 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.ts @@ -5,7 +5,10 @@ import { useSelector } from 'react-redux'; import type { CaipChainId } from '@metamask/utils'; import { strings } from '../../../../../locales/i18n'; import { useTheme } from '../../../../util/theme'; -import { type TransakBuyQuote } from '@metamask/ramps-controller'; +import { + normalizeProviderCode, + type TransakBuyQuote, +} from '@metamask/ramps-controller'; import { REDIRECTION_URL } from '../Deposit/constants'; import { generateThemeParameters } from '../Deposit/utils'; import { BasicInfoFormData } from '../Deposit/Views/BasicInfo/BasicInfo'; @@ -37,6 +40,8 @@ interface RampStackParamList { quote: TransakBuyQuote; kycUrl: string; workFlowRunId: string; + /** User-entered fiat from BuildQuote; used when resetting stack so amount screen keeps the typed value. */ + amount?: number; }; RampKycProcessing: { quote: TransakBuyQuote }; RampEnterEmail: undefined; @@ -156,11 +161,14 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ); const navigateToVerifyIdentityCallback = useCallback( - ({ quote }: { quote: TransakBuyQuote }) => { + ({ quote, amount }: { quote: TransakBuyQuote; amount?: number }) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: Routes.RAMP.VERIFY_IDENTITY, params: { quote } }, ], }); @@ -172,14 +180,19 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ({ quote, previousFormData, + amount, }: { quote: TransakBuyQuote; previousFormData?: BasicInfoFormData & AddressFormData; + amount?: number; }) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: Routes.RAMP.BASIC_INFO, params: { quote, previousFormData }, @@ -231,18 +244,23 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { quote, kycUrl, workFlowRunId, + amount, }: { quote: TransakBuyQuote; kycUrl: string; workFlowRunId: string; + amount?: number; }) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: Routes.RAMP.ADDITIONAL_VERIFICATION, - params: { quote, kycUrl, workFlowRunId }, + params: { quote, kycUrl, workFlowRunId, amount }, }, ], }); @@ -276,9 +294,9 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { throw new Error('Missing order'); } - const providerCode = ( - depositOrder.provider || 'transak-native' - ).replace('/providers/', ''); + const providerCode = normalizeProviderCode( + String(depositOrder.provider ?? 'transak-native'), + ); const rampsOrder = await refreshOrder( providerCode, depositOrder.providerOrderId, @@ -338,7 +356,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ); const navigateToWebviewModalCallback = useCallback( - ({ paymentUrl }: { paymentUrl: string }) => { + ({ paymentUrl, amount }: { paymentUrl: string; amount?: number }) => { const callbackKey = registerCheckoutCallback(handleNavigationStateChange); const [routeName, routeParams] = createCheckoutNavDetails({ url: paymentUrl, @@ -348,7 +366,10 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: routeName, params: routeParams }, ], }); @@ -357,11 +378,14 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ); const navigateToKycProcessingCallback = useCallback( - ({ quote }: { quote: TransakBuyQuote }) => { + ({ quote, amount }: { quote: TransakBuyQuote; amount?: number }) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: Routes.RAMP.KYC_PROCESSING, params: { quote } }, ], }); @@ -370,7 +394,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ); const navigateToKycWebviewCallback = useCallback( - ({ kycUrl }: { kycUrl: string }) => { + ({ kycUrl, amount }: { kycUrl: string; amount?: number }) => { const [routeName, routeParams] = createCheckoutNavDetails({ url: kycUrl, providerName: 'Transak', @@ -378,7 +402,10 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: routeName, params: routeParams }, ], }); @@ -387,7 +414,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ); const routeAfterAuthentication = useCallback( - async (quote: TransakBuyQuote, depth = 0) => { + async (quote: TransakBuyQuote, amount?: number, depth = 0) => { try { const userDetails = await getUserDetails(); const previousFormData = { @@ -427,9 +454,9 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { throw new Error('Missing order'); } - const providerCode = ( - depositOrder.provider || 'transak-native' - ).replace('/providers/', ''); + const providerCode = normalizeProviderCode( + String(depositOrder.provider ?? 'transak-native'), + ); const rampsOrder = await refreshOrder( providerCode, depositOrder.providerOrderId, @@ -470,7 +497,10 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { throw new Error('Failed to generate payment URL'); } - navigateToWebviewModalCallback({ paymentUrl }); + navigateToWebviewModalCallback({ + paymentUrl, + amount, + }); } return true; } catch (error) { @@ -490,7 +520,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { region: regionIsoCode, }); - navigateToBasicInfoCallback({ quote, previousFormData }); + navigateToBasicInfoCallback({ quote, previousFormData, amount }); return; case 'ADDITIONAL_FORMS_REQUIRED': { @@ -508,7 +538,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { await submitPurposeOfUsageForm([ 'Buying/selling crypto for investments', ]); - await routeAfterAuthentication(quote, depth + 1); + await routeAfterAuthentication(quote, amount, depth + 1); } else { Logger.error( new Error(`Submit of purpose depth exceeded: ${depth}`), @@ -535,16 +565,17 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { quote, kycUrl: metadata.kycUrl, workFlowRunId: metadata.workFlowRunId, + amount, }); return; } - navigateToKycProcessingCallback({ quote }); + navigateToKycProcessingCallback({ quote, amount }); return; } case 'SUBMITTED': { - navigateToKycProcessingCallback({ quote }); + navigateToKycProcessingCallback({ quote, amount }); return; } diff --git a/app/components/UI/Ramp/orderProcessor/unifiedOrderProcessor.ts b/app/components/UI/Ramp/orderProcessor/unifiedOrderProcessor.ts index 1150981f533..18505ac2e64 100644 --- a/app/components/UI/Ramp/orderProcessor/unifiedOrderProcessor.ts +++ b/app/components/UI/Ramp/orderProcessor/unifiedOrderProcessor.ts @@ -1,4 +1,7 @@ -import type { RampsOrder } from '@metamask/ramps-controller'; +import { + normalizeProviderCode, + type RampsOrder, +} from '@metamask/ramps-controller'; import { FIAT_ORDER_PROVIDERS, FIAT_ORDER_STATES, @@ -101,7 +104,7 @@ export async function processUnifiedOrder( try { const data = order.data as RampsOrder; const orderCode = data.providerOrderId; - const providerCode = data.provider?.id?.replace('/providers/', '') ?? ''; + const providerCode = normalizeProviderCode(data.provider?.id ?? ''); if (!providerCode || !orderCode) { throw new Error( diff --git a/app/components/UI/Ramp/queries/index.ts b/app/components/UI/Ramp/queries/index.ts new file mode 100644 index 00000000000..93c0650976f --- /dev/null +++ b/app/components/UI/Ramp/queries/index.ts @@ -0,0 +1,16 @@ +import { + rampsPaymentMethodsKeys, + rampsPaymentMethodsOptions, +} from './paymentMethods'; +import { rampsQuotesKeys, rampsQuotesOptions } from './quotes'; + +export const rampsQueries = { + paymentMethods: { + keys: rampsPaymentMethodsKeys, + options: rampsPaymentMethodsOptions, + }, + quotes: { + keys: rampsQuotesKeys, + options: rampsQuotesOptions, + }, +}; diff --git a/app/components/UI/Ramp/queries/paymentMethods.test.ts b/app/components/UI/Ramp/queries/paymentMethods.test.ts new file mode 100644 index 00000000000..1fcd15be431 --- /dev/null +++ b/app/components/UI/Ramp/queries/paymentMethods.test.ts @@ -0,0 +1,44 @@ +import { + rampsPaymentMethodsKeys, + rampsPaymentMethodsOptions, +} from './paymentMethods'; + +describe('rampsPaymentMethodsOptions', () => { + it('creates a stable normalized query key', () => { + expect( + rampsPaymentMethodsKeys.detail({ + regionCode: 'US ', + fiat: ' USD', + assetId: 'eip155:1/slip44:60', + providerId: '/providers/transak', + }), + ).toEqual([ + 'ramps', + 'paymentMethods', + 'us', + 'usd', + 'eip155:1/slip44:60', + '/providers/transak', + ]); + }); + + it('builds query options for payment methods', () => { + const opts = rampsPaymentMethodsOptions({ + regionCode: 'us', + fiat: 'usd', + assetId: 'eip155:1/slip44:60', + providerId: '/providers/transak', + }); + + expect(opts.queryKey).toEqual([ + 'ramps', + 'paymentMethods', + 'us', + 'usd', + 'eip155:1/slip44:60', + '/providers/transak', + ]); + expect(typeof opts.queryFn).toBe('function'); + expect(opts.staleTime).toBe(0); + }); +}); diff --git a/app/components/UI/Ramp/queries/paymentMethods.ts b/app/components/UI/Ramp/queries/paymentMethods.ts new file mode 100644 index 00000000000..33dc6122a46 --- /dev/null +++ b/app/components/UI/Ramp/queries/paymentMethods.ts @@ -0,0 +1,49 @@ +import { queryOptions } from '@tanstack/react-query'; +import type { + PaymentMethod, + PaymentMethodsResponse, +} from '@metamask/ramps-controller'; +import Engine from '../../../../core/Engine'; + +interface PaymentMethodsQueryParams { + regionCode: string; + fiat: string; + assetId: string; + providerId: string; +} + +export const rampsPaymentMethodsKeys = { + all: () => ['ramps', 'paymentMethods'] as const, + detail: ({ + regionCode, + fiat, + assetId, + providerId, + }: PaymentMethodsQueryParams) => + [ + ...rampsPaymentMethodsKeys.all(), + regionCode.trim().toLowerCase(), + fiat.trim().toLowerCase(), + assetId, + providerId, + ] as const, +}; + +export const rampsPaymentMethodsOptions = (params: PaymentMethodsQueryParams) => + queryOptions({ + queryKey: rampsPaymentMethodsKeys.detail(params), + queryFn: async (): Promise => { + const response: PaymentMethodsResponse = + await Engine.context.RampsController.getPaymentMethods( + params.regionCode, + { + fiat: params.fiat, + assetId: params.assetId, + provider: params.providerId, + }, + ); + + return response.payments; + }, + staleTime: 0, + }); diff --git a/app/components/UI/Ramp/queries/quotes.test.ts b/app/components/UI/Ramp/queries/quotes.test.ts new file mode 100644 index 00000000000..fe6d6c7a742 --- /dev/null +++ b/app/components/UI/Ramp/queries/quotes.test.ts @@ -0,0 +1,46 @@ +import { rampsQuotesKeys, rampsQuotesOptions } from './quotes'; + +describe('rampsQuotesOptions', () => { + it('creates a stable query key for quotes', () => { + expect( + rampsQuotesKeys.detail({ + assetId: 'eip155:1/slip44:60', + amount: 100, + walletAddress: '0x123', + paymentMethods: ['/payments/card'], + providers: ['/providers/transak'], + }), + ).toEqual([ + 'ramps', + 'quotes', + 'eip155:1/slip44:60', + 100, + '0x123', + '/payments/card', + '/providers/transak', + ]); + }); + + it('builds query options for quotes', () => { + const opts = rampsQuotesOptions({ + assetId: 'eip155:1/slip44:60', + amount: 100, + walletAddress: '0x123', + paymentMethods: ['/payments/card'], + providers: ['/providers/transak'], + forceRefresh: true, + }); + + expect(opts.queryKey).toEqual([ + 'ramps', + 'quotes', + 'eip155:1/slip44:60', + 100, + '0x123', + '/payments/card', + '/providers/transak', + ]); + expect(typeof opts.queryFn).toBe('function'); + expect(opts.staleTime).toBe(0); + }); +}); diff --git a/app/components/UI/Ramp/queries/quotes.ts b/app/components/UI/Ramp/queries/quotes.ts new file mode 100644 index 00000000000..470547c856c --- /dev/null +++ b/app/components/UI/Ramp/queries/quotes.ts @@ -0,0 +1,46 @@ +import { queryOptions } from '@tanstack/react-query'; +import type { QuotesResponse } from '@metamask/ramps-controller'; +import type { GetQuotesOptions } from '../hooks/useRampsQuotes'; +import Engine from '../../../../core/Engine'; + +type RampsQuotesQueryParams = Pick< + GetQuotesOptions, + | 'assetId' + | 'amount' + | 'walletAddress' + | 'redirectUrl' + | 'forceRefresh' + | 'ttl' + | 'paymentMethods' + | 'providers' +>; + +export const rampsQuotesKeys = { + all: () => ['ramps', 'quotes'] as const, + detail: (params: RampsQuotesQueryParams) => + [ + ...rampsQuotesKeys.all(), + params.assetId ?? '', + params.amount, + params.walletAddress, + (params.paymentMethods ?? []).join(','), + (params.providers ?? []).join(','), + ] as const, +}; + +export const rampsQuotesOptions = (params: RampsQuotesQueryParams) => + queryOptions({ + queryKey: rampsQuotesKeys.detail(params), + queryFn: async (): Promise => + Engine.context.RampsController.getQuotes({ + assetId: params.assetId, + amount: params.amount, + walletAddress: params.walletAddress, + redirectUrl: params.redirectUrl, + paymentMethods: params.paymentMethods, + providers: params.providers, + forceRefresh: params.forceRefresh, + ttl: params.ttl, + }), + staleTime: 0, + }); diff --git a/app/components/UI/Ramp/routes.tsx b/app/components/UI/Ramp/routes.tsx index 19e56f3e73c..915d408bd6e 100644 --- a/app/components/UI/Ramp/routes.tsx +++ b/app/components/UI/Ramp/routes.tsx @@ -1,5 +1,7 @@ import React, { useEffect } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; import { createStackNavigator } from '@react-navigation/stack'; +import reactQueryService from '../../../core/ReactQueryService/ReactQueryService'; import Routes from '../../../constants/navigation/Routes'; import TokenSelection from './Views/TokenSelection'; import BuildQuote from './Views/BuildQuote'; @@ -141,23 +143,25 @@ const TokenListRoutes = () => { }, []); return ( - - - - + + + + + + ); }; diff --git a/app/components/UI/Ramp/types/index.test.ts b/app/components/UI/Ramp/types/index.test.ts index b9b01f6166a..a76398192db 100644 --- a/app/components/UI/Ramp/types/index.test.ts +++ b/app/components/UI/Ramp/types/index.test.ts @@ -1,219 +1,34 @@ -import { - getQuoteBuyUserAgent, - getQuoteProviderName, - isNativeProvider, - type Quote, -} from './index'; +import { isCustomAction } from './index'; -describe('getQuoteProviderName', () => { - it('returns providerInfo.name when present (canonical display name)', () => { - const quote: Quote = { - provider: '/providers/ramp-network', - quote: { - amountIn: 100, - amountOut: 0.05, - paymentMethod: '/payments/debit-credit-card', - }, - providerInfo: { - id: '/providers/ramp-network', - name: 'Ramp Network', - type: 'aggregator', - }, - } as Quote; - - expect(getQuoteProviderName(quote)).toBe('Ramp Network'); - }); - - it('returns "Provider" when providerInfo is missing', () => { +describe('isCustomAction', () => { + it('returns true when quote.quote.isCustomAction is true', () => { const quote = { - provider: '/providers/mercuryo', - quote: { - amountIn: 100, - amountOut: 0.05, - paymentMethod: '/payments/debit-credit-card', - }, - } as Quote; - - expect(getQuoteProviderName(quote)).toBe('Provider'); + provider: 'paypal', + quote: { isCustomAction: true }, + } as unknown as Parameters[0]; + expect(isCustomAction(quote)).toBe(true); }); - it('returns "Provider" when providerInfo.name is missing', () => { + it('returns false when isCustomAction is false', () => { const quote = { - provider: '/providers/transak', - quote: { - amountIn: 100, - amountOut: 0.05, - paymentMethod: '/payments/debit-credit-card', - }, - providerInfo: { id: '/providers/transak', name: '', type: 'aggregator' }, - } as Quote; - - expect(getQuoteProviderName(quote)).toBe('Provider'); + provider: 'paypal', + quote: { isCustomAction: false }, + } as unknown as Parameters[0]; + expect(isCustomAction(quote)).toBe(false); }); - it('uses providerInfo.name not quote.provider path (avoids slug as title)', () => { - const quote: Quote = { - provider: '/providers/ramp-network', - quote: { - amountIn: 100, - amountOut: 0.05, - paymentMethod: '/payments/debit-credit-card', - }, - providerInfo: { - id: '/providers/ramp-network', - name: 'Ramp Network', - type: 'aggregator', - }, - } as Quote; - - const name = getQuoteProviderName(quote); - expect(name).toBe('Ramp Network'); - expect(name).not.toBe('ramp-network'); - expect(name).not.toContain('/'); - }); -}); - -describe('isNativeProvider', () => { - it('returns true when providerInfo.type is "native"', () => { - const quote: Quote = { - provider: '/providers/transak-native', - quote: { - amountIn: 100, - amountOut: 0.05, - paymentMethod: '/payments/debit-credit-card', - }, - providerInfo: { - id: '/providers/transak-native', - name: 'Transak Native', - type: 'native', - }, - } as Quote; - - expect(isNativeProvider(quote)).toBe(true); - }); - - it('returns false when providerInfo.type is "aggregator"', () => { - const quote: Quote = { - provider: '/providers/transak', - quote: { - amountIn: 100, - amountOut: 0.05, - paymentMethod: '/payments/debit-credit-card', - }, - providerInfo: { - id: '/providers/transak', - name: 'Transak', - type: 'aggregator', - }, - } as Quote; - - expect(isNativeProvider(quote)).toBe(false); - }); - - it('returns false when providerInfo is missing', () => { - const quote: Quote = { - provider: '/providers/transak-native', - quote: { - amountIn: 100, - amountOut: 0.05, - paymentMethod: '/payments/debit-credit-card', - }, - } as Quote; - - expect(isNativeProvider(quote)).toBe(false); - }); -}); - -describe('getQuoteBuyUserAgent', () => { - it('returns userAgent when providerInfo.features.buy.userAgent is set', () => { - const quote = { - provider: '/providers/example', - quote: { - amountIn: 100, - amountOut: 0.05, - paymentMethod: '/payments/debit-credit-card', - }, - providerInfo: { - id: '/providers/example', - name: 'Example', - type: 'aggregator' as const, - features: { - buy: { - userAgent: 'CustomProvider/1.0 (MetaMask)', - }, - }, - }, - } as Quote; - - expect(getQuoteBuyUserAgent(quote)).toBe('CustomProvider/1.0 (MetaMask)'); - }); - - it('returns undefined when providerInfo.features.buy.userAgent is null', () => { - const quote = { - provider: '/providers/example', - quote: { - amountIn: 100, - amountOut: 0.05, - paymentMethod: '/payments/debit-credit-card', - }, - providerInfo: { - id: '/providers/example', - name: 'Example', - type: 'aggregator' as const, - features: { buy: { userAgent: null } }, - }, - } as Quote; - - expect(getQuoteBuyUserAgent(quote)).toBeUndefined(); - }); - - it('returns undefined when providerInfo.features.buy.userAgent is empty string', () => { - const quote = { - provider: '/providers/example', - quote: { - amountIn: 100, - amountOut: 0.05, - paymentMethod: '/payments/debit-credit-card', - }, - providerInfo: { - id: '/providers/example', - name: 'Example', - type: 'aggregator' as const, - features: { buy: { userAgent: '' } }, - }, - } as Quote; - - expect(getQuoteBuyUserAgent(quote)).toBeUndefined(); - }); - - it('returns undefined when providerInfo is missing', () => { + it('returns false when isCustomAction is undefined', () => { const quote = { - provider: '/providers/example', - quote: { - amountIn: 100, - amountOut: 0.05, - paymentMethod: '/payments/debit-credit-card', - }, - } as Quote; - - expect(getQuoteBuyUserAgent(quote)).toBeUndefined(); + provider: 'moonpay', + quote: {}, + } as unknown as Parameters[0]; + expect(isCustomAction(quote)).toBe(false); }); - it('returns undefined when providerInfo.features.buy is missing', () => { + it('returns false when quote.quote is undefined', () => { const quote = { - provider: '/providers/example', - quote: { - amountIn: 100, - amountOut: 0.05, - paymentMethod: '/payments/debit-credit-card', - }, - providerInfo: { - id: '/providers/example', - name: 'Example', - type: 'aggregator' as const, - }, - } as Quote; - - expect(getQuoteBuyUserAgent(quote)).toBeUndefined(); + provider: 'test', + } as unknown as Parameters[0]; + expect(isCustomAction(quote)).toBe(false); }); }); diff --git a/app/components/UI/Ramp/types/index.ts b/app/components/UI/Ramp/types/index.ts index 8b7abcc9bd3..8570898f57b 100644 --- a/app/components/UI/Ramp/types/index.ts +++ b/app/components/UI/Ramp/types/index.ts @@ -34,6 +34,16 @@ export function isNativeProvider(quote: Quote): boolean { return quote.providerInfo?.type === 'native'; } +/** + * Checks if a quote is for a custom action provider (e.g. PayPal). + * + * @param quote - The quote to check. + * @returns True if the quote has isCustomAction set, false otherwise. + */ +export function isCustomAction(quote: Quote): boolean { + return (quote.quote as { isCustomAction?: boolean })?.isCustomAction === true; +} + /** * Gets the display name for the quote's provider. * Uses only quote.providerInfo.name so Checkout and other UI show correct diff --git a/app/components/UI/Ramp/utils/__snapshots__/displayOrder.test.ts.snap b/app/components/UI/Ramp/utils/__snapshots__/displayOrder.test.ts.snap index 070652a10f9..0a8ea74231b 100644 --- a/app/components/UI/Ramp/utils/__snapshots__/displayOrder.test.ts.snap +++ b/app/components/UI/Ramp/utils/__snapshots__/displayOrder.test.ts.snap @@ -4,7 +4,7 @@ exports[`displayOrder fiatOrderToDisplayOrder converts a legacy FiatOrder to a D { "account": "0xabc", "createdAt": 1000, - "cryptoAmount": 0, + "cryptoAmount": "...", "cryptoCurrencySymbol": "ETH", "fiatAmount": "100", "fiatCurrencyCode": "USD", diff --git a/app/components/UI/Ramp/utils/buildQuoteWithRedirectUrl.test.ts b/app/components/UI/Ramp/utils/buildQuoteWithRedirectUrl.test.ts new file mode 100644 index 00000000000..5c5e6114c66 --- /dev/null +++ b/app/components/UI/Ramp/utils/buildQuoteWithRedirectUrl.test.ts @@ -0,0 +1,29 @@ +import { getCheckoutContext } from './buildQuoteWithRedirectUrl'; + +describe('getCheckoutContext', () => { + describe('network from chainId', () => { + it('extracts network as part after colon when chainId contains colon', () => { + const result = getCheckoutContext({ chainId: 'eip155:1' }, '0xabc', null); + + expect(result.network).toBe('1'); + }); + + it('uses full chainId as network when chainId has no colon', () => { + const result = getCheckoutContext({ chainId: '0x1' }, '0xabc', null); + + expect(result.network).toBe('0x1'); + }); + + it('returns empty network when chainId is undefined', () => { + const result = getCheckoutContext(null, '0xabc', null); + + expect(result.network).toBe(''); + }); + + it('returns empty network when chainId ends with colon and no suffix', () => { + const result = getCheckoutContext({ chainId: 'eip155:' }, '0xabc', null); + + expect(result.network).toBe(''); + }); + }); +}); diff --git a/app/components/UI/Ramp/utils/buildQuoteWithRedirectUrl.ts b/app/components/UI/Ramp/utils/buildQuoteWithRedirectUrl.ts new file mode 100644 index 00000000000..89cbea345c4 --- /dev/null +++ b/app/components/UI/Ramp/utils/buildQuoteWithRedirectUrl.ts @@ -0,0 +1,85 @@ +import type { Quote } from '@metamask/ramps-controller'; +import { getRampCallbackBaseUrl } from './getRampCallbackBaseUrl'; + +/** + * Returns a quote with buyURL rewritten to use the given redirect URL. + * Ideally this logic would live in the API or controller — the client + * shouldn't need to rewrite URLs before fetching. Kept here until then. + */ +export function buildQuoteWithRedirectUrl( + quote: Quote, + redirectUrl: string, +): Quote { + const buyURL = quote.quote?.buyURL; + if (!buyURL) return quote; + + const buyUrl = new URL(buyURL); + buyUrl.searchParams.set('redirectUrl', redirectUrl); + return { + ...quote, + quote: { + ...quote.quote, + buyURL: buyUrl.toString(), + }, + }; +} + +function getProviderDeeplinkRedirectUrl(providerCode: string): string { + return `metamask://on-ramp/providers/${providerCode}`; +} + +/** + * Returns redirect config for aggregator flow: deeplink when quote indicates + * external browser, callbackBaseUrl for Checkout WebView. + */ +export function getAggregatorRedirectConfig( + quote: Quote, + providerCode: string, +): { useExternalBrowser: boolean; redirectUrl: string } { + const useExternalBrowser = + quote.quote?.buyWidget?.browser === 'IN_APP_OS_BROWSER'; + return { + useExternalBrowser, + redirectUrl: useExternalBrowser + ? getProviderDeeplinkRedirectUrl(providerCode) + : getRampCallbackBaseUrl(), + }; +} + +/** + * Returns redirect config for widget providers (custom actions or aggregators). + * Unifies the logic so redirectUrl and useExternalBrowser come from one place. + */ +export function getWidgetRedirectConfig( + quote: Quote, + providerCode: string, + isCustom: boolean, +): { useExternalBrowser: boolean; redirectUrl: string } { + if (isCustom) { + return { + useExternalBrowser: true, + redirectUrl: getProviderDeeplinkRedirectUrl(providerCode), + }; + } + return getAggregatorRedirectConfig(quote, providerCode); +} + +export function getCheckoutContext( + selectedToken: { chainId?: string } | null, + walletAddress: string | null | undefined, + rawOrderId?: string | null | undefined, +): { + network: string; + effectiveWallet: string; + effectiveOrderId: string | null; +} { + const chainId = selectedToken?.chainId; + const network = chainId?.includes(':') + ? chainId.split(':')[1] || '' + : chainId || ''; + return { + network, + effectiveWallet: walletAddress ?? '', + effectiveOrderId: rawOrderId?.trim() || null, + }; +} diff --git a/app/components/UI/Ramp/utils/computeAmountUpdate.test.ts b/app/components/UI/Ramp/utils/computeAmountUpdate.test.ts new file mode 100644 index 00000000000..e6c5e37cf2a --- /dev/null +++ b/app/components/UI/Ramp/utils/computeAmountUpdate.test.ts @@ -0,0 +1,52 @@ +import { computeAmountUpdate } from './computeAmountUpdate'; + +describe('computeAmountUpdate', () => { + describe('when valueOrNumber is a string', () => { + it('returns amount "0" and amountAsNumber 0 for empty string', () => { + const result = computeAmountUpdate(''); + expect(result).toEqual({ amount: '0', amountAsNumber: 0 }); + }); + + it('returns the string as amount and parsed number when valueAsNumber not provided', () => { + const result = computeAmountUpdate('50'); + expect(result).toEqual({ amount: '50', amountAsNumber: 50 }); + }); + + it('uses valueAsNumber when provided instead of parsing the string', () => { + const result = computeAmountUpdate('50.99', 42); + expect(result).toEqual({ amount: '50.99', amountAsNumber: 42 }); + }); + + it('uses parseFloat when valueAsNumber is undefined', () => { + const result = computeAmountUpdate('123.45'); + expect(result).toEqual({ amount: '123.45', amountAsNumber: 123.45 }); + }); + + it('returns 0 for amountAsNumber when string is non-numeric (parseFloat NaN)', () => { + const result = computeAmountUpdate('abc'); + expect(result).toEqual({ amount: 'abc', amountAsNumber: 0 }); + }); + + it('returns 0 for amountAsNumber when string is empty and valueAsNumber not provided', () => { + const result = computeAmountUpdate(''); + expect(result.amountAsNumber).toBe(0); + }); + }); + + describe('when valueOrNumber is a number', () => { + it('converts number to string for amount and uses number for amountAsNumber', () => { + const result = computeAmountUpdate(100); + expect(result).toEqual({ amount: '100', amountAsNumber: 100 }); + }); + + it('handles zero', () => { + const result = computeAmountUpdate(0); + expect(result).toEqual({ amount: '0', amountAsNumber: 0 }); + }); + + it('handles decimal numbers', () => { + const result = computeAmountUpdate(99.5); + expect(result).toEqual({ amount: '99.5', amountAsNumber: 99.5 }); + }); + }); +}); diff --git a/app/components/UI/Ramp/utils/computeAmountUpdate.ts b/app/components/UI/Ramp/utils/computeAmountUpdate.ts new file mode 100644 index 00000000000..27b228330cd --- /dev/null +++ b/app/components/UI/Ramp/utils/computeAmountUpdate.ts @@ -0,0 +1,19 @@ +/** + * Computes the new amount string and numeric value from a keypad/quick-amount + * input. Used by BuildQuote's updateAmount callback. + */ +export function computeAmountUpdate( + valueOrNumber: string | number, + valueAsNumber?: number, +): { amount: string; amountAsNumber: number } { + if (typeof valueOrNumber === 'string') { + const amount = valueOrNumber === '' ? '0' : valueOrNumber; + const amountAsNumber = + valueAsNumber != null ? valueAsNumber : parseFloat(valueOrNumber) || 0; + return { amount, amountAsNumber }; + } + return { + amount: String(valueOrNumber), + amountAsNumber: valueOrNumber, + }; +} diff --git a/app/components/UI/Ramp/utils/displayOrder.test.ts b/app/components/UI/Ramp/utils/displayOrder.test.ts index 942059efd25..19bf3719368 100644 --- a/app/components/UI/Ramp/utils/displayOrder.test.ts +++ b/app/components/UI/Ramp/utils/displayOrder.test.ts @@ -74,10 +74,16 @@ describe('displayOrder', () => { expect(result).toMatchSnapshot(); }); - it('defaults cryptoAmount to 0 when undefined', () => { - const fiatOrder = createMockFiatOrder({ cryptoAmount: undefined }); - const result = fiatOrderToDisplayOrder(fiatOrder); - expect(result.cryptoAmount).toBe(0); + it('uses placeholder when cryptoAmount is undefined or zero', () => { + expect( + fiatOrderToDisplayOrder( + createMockFiatOrder({ cryptoAmount: undefined }), + ).cryptoAmount, + ).toBe('...'); + expect( + fiatOrderToDisplayOrder(createMockFiatOrder({ cryptoAmount: 0 })) + .cryptoAmount, + ).toBe('...'); }); }); @@ -141,6 +147,23 @@ describe('displayOrder', () => { const result = rampsOrderToDisplayOrder(order); expect(result.createdAt).toBe(0); }); + + it('uses placeholder when cryptoAmount is 0 or missing', () => { + expect( + rampsOrderToDisplayOrder(createMockRampsOrder({ cryptoAmount: 0 })) + .cryptoAmount, + ).toBe('...'); + expect( + rampsOrderToDisplayOrder( + createMockRampsOrder({ cryptoAmount: undefined }), + ).cryptoAmount, + ).toBe('...'); + expect( + rampsOrderToDisplayOrder( + createMockRampsOrder({ cryptoAmount: null as unknown as string }), + ).cryptoAmount, + ).toBe('...'); + }); }); describe('mergeDisplayOrders', () => { @@ -250,5 +273,28 @@ describe('displayOrder', () => { expect(result[0].source).toBe('v2'); expect(result[1].source).toBe('legacy'); }); + + it('filters out precreated and expired V2 orders', () => { + const precreatedOrder = createMockRampsOrder({ + providerOrderId: 'precreated-1', + status: RampsOrderStatus.Precreated, + }); + const expiredOrder = createMockRampsOrder({ + providerOrderId: 'expired-1', + status: RampsOrderStatus.IdExpired, + }); + const visibleOrder = createMockRampsOrder({ + providerOrderId: 'visible-1', + status: RampsOrderStatus.Completed, + }); + + const result = mergeDisplayOrders( + [], + [precreatedOrder, expiredOrder, visibleOrder], + ); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('visible-1'); + }); }); }); diff --git a/app/components/UI/Ramp/utils/displayOrder.ts b/app/components/UI/Ramp/utils/displayOrder.ts index 2b9499b6077..ad111e7b051 100644 --- a/app/components/UI/Ramp/utils/displayOrder.ts +++ b/app/components/UI/Ramp/utils/displayOrder.ts @@ -5,6 +5,8 @@ import { } from '../../../../reducers/fiatOrders'; import { FIAT_ORDER_PROVIDERS } from '../../../../constants/on-ramp'; +const AMOUNT_PLACEHOLDER = '...'; + export interface DisplayOrder { id: string; source: 'legacy' | 'v2'; @@ -37,7 +39,10 @@ export function fiatOrderToDisplayOrder(order: FiatOrder): DisplayOrder { createdAt: toEpochMs(order.createdAt), fiatAmount: order.amount, fiatCurrencyCode: order.currency, - cryptoAmount: order.cryptoAmount ?? 0, + cryptoAmount: + order.cryptoAmount != null && Number(order.cryptoAmount) > 0 + ? order.cryptoAmount + : AMOUNT_PLACEHOLDER, cryptoCurrencySymbol: order.cryptocurrency, network: order.network, status: order.state, @@ -65,7 +70,10 @@ export function rampsOrderToDisplayOrder(order: RampsOrder): DisplayOrder { createdAt: toEpochMs(order.createdAt), fiatAmount: order.fiatAmount, fiatCurrencyCode: order.fiatCurrency?.symbol ?? '', - cryptoAmount: order.cryptoAmount, + cryptoAmount: + order.cryptoAmount != null && Number(order.cryptoAmount) > 0 + ? order.cryptoAmount + : AMOUNT_PLACEHOLDER, cryptoCurrencySymbol: order.cryptoCurrency?.symbol ?? '', network: order.network?.chainId ?? '', status: RAMPS_STATUS_TO_DISPLAY[order.status] ?? 'PENDING', @@ -74,11 +82,20 @@ export function rampsOrderToDisplayOrder(order: RampsOrder): DisplayOrder { }; } +const HIDDEN_ORDER_STATUSES = new Set([ + RampsOrderStatus.Precreated, + RampsOrderStatus.IdExpired, + RampsOrderStatus.Unknown, +]); + export function mergeDisplayOrders( legacyOrders: FiatOrder[], v2Orders: RampsOrder[], ): DisplayOrder[] { - const v2Ids = new Set(v2Orders.map((o) => o.providerOrderId)); + const visibleV2Orders = v2Orders.filter( + (o) => !HIDDEN_ORDER_STATUSES.has(o.status), + ); + const v2Ids = new Set(visibleV2Orders.map((o) => o.providerOrderId)); const legacy = legacyOrders .filter((o) => { @@ -88,7 +105,7 @@ export function mergeDisplayOrders( }) .map(fiatOrderToDisplayOrder); - const v2 = v2Orders.map(rampsOrderToDisplayOrder); + const v2 = visibleV2Orders.map(rampsOrderToDisplayOrder); return [...legacy, ...v2].sort((a, b) => b.createdAt - a.createdAt); } diff --git a/app/components/UI/Ramp/utils/extractOrderCode.test.ts b/app/components/UI/Ramp/utils/extractOrderCode.test.ts new file mode 100644 index 00000000000..30a4dcfcf61 --- /dev/null +++ b/app/components/UI/Ramp/utils/extractOrderCode.test.ts @@ -0,0 +1,23 @@ +import { extractOrderCode } from './extractOrderCode'; + +describe('extractOrderCode', () => { + it('extracts code from full path with /orders/', () => { + expect(extractOrderCode('/providers/paypal/orders/abc-123')).toBe( + 'abc-123', + ); + }); + + it('returns plain order code as-is when path has no /orders/', () => { + expect(extractOrderCode('abc-123')).toBe('abc-123'); + }); + + it('extracts code when path has multiple segments after /orders/', () => { + expect(extractOrderCode('/providers/paypal/orders/ORDER-XYZ-789')).toBe( + 'ORDER-XYZ-789', + ); + }); + + it('returns empty string when path ends with /orders/', () => { + expect(extractOrderCode('/providers/paypal/orders/')).toBe(''); + }); +}); diff --git a/app/components/UI/Ramp/utils/extractOrderCode.ts b/app/components/UI/Ramp/utils/extractOrderCode.ts new file mode 100644 index 00000000000..787aac5f204 --- /dev/null +++ b/app/components/UI/Ramp/utils/extractOrderCode.ts @@ -0,0 +1,11 @@ +/** + * Extracts the order code from an order ID that may be a full path + * (e.g. "/providers/paypal/orders/abc-123") or a plain order code (e.g. "abc-123"). + * The RampsController stores orders by this extracted code (providerOrderId) + * for consistent lookup. + */ +export function extractOrderCode(orderId: string): string { + return orderId.includes('/orders/') + ? (orderId.split('/orders/')[1] ?? orderId) + : orderId; +} diff --git a/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.test.ts b/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.test.ts index fb04ee960b9..cc0c454ac77 100644 --- a/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.test.ts +++ b/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.test.ts @@ -9,10 +9,10 @@ jest.mock('react-native-device-info', () => ({ })); function buildState({ - active = true, + enabled = true, minimumVersion, }: { - active?: boolean; + enabled?: boolean; minimumVersion?: string | null; } = {}) { return { @@ -24,7 +24,7 @@ function buildState({ ...backgroundState.RemoteFeatureFlagController, remoteFeatureFlags: { rampsUnifiedBuyV2: { - active, + enabled, ...(minimumVersion !== undefined && { minimumVersion }), }, }, @@ -53,7 +53,7 @@ describe('isRampsUnifiedV2Enabled', () => { process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED = 'true'; const result = isRampsUnifiedV2Enabled( - buildState({ active: false, minimumVersion: '99.0.0' }), + buildState({ enabled: false, minimumVersion: '99.0.0' }), ); expect(result).toBe(true); @@ -63,7 +63,7 @@ describe('isRampsUnifiedV2Enabled', () => { process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED = 'false'; const result = isRampsUnifiedV2Enabled( - buildState({ active: true, minimumVersion: '1.0.0' }), + buildState({ enabled: true, minimumVersion: '1.0.0' }), ); expect(result).toBe(false); @@ -71,21 +71,21 @@ describe('isRampsUnifiedV2Enabled', () => { }); describe('remote feature flag behavior when build flag is not set', () => { - it('returns true when active and version meets minimum requirement', () => { + it('returns true when enabled and version meets minimum requirement', () => { mockGetVersion.mockReturnValue('8.0.0'); const result = isRampsUnifiedV2Enabled( - buildState({ active: true, minimumVersion: '7.63.0' }), + buildState({ enabled: true, minimumVersion: '7.63.0' }), ); expect(result).toBe(true); }); - it('returns false when active flag is false', () => { + it('returns false when enabled flag is false', () => { mockGetVersion.mockReturnValue('8.0.0'); const result = isRampsUnifiedV2Enabled( - buildState({ active: false, minimumVersion: '7.63.0' }), + buildState({ enabled: false, minimumVersion: '7.63.0' }), ); expect(result).toBe(false); @@ -95,7 +95,7 @@ describe('isRampsUnifiedV2Enabled', () => { mockGetVersion.mockReturnValue('7.0.0'); const result = isRampsUnifiedV2Enabled( - buildState({ active: true, minimumVersion: '7.63.0' }), + buildState({ enabled: true, minimumVersion: '7.63.0' }), ); expect(result).toBe(false); @@ -105,7 +105,7 @@ describe('isRampsUnifiedV2Enabled', () => { mockGetVersion.mockReturnValue('8.0.0'); const result = isRampsUnifiedV2Enabled( - buildState({ active: true, minimumVersion: null }), + buildState({ enabled: true, minimumVersion: null }), ); expect(result).toBe(false); @@ -115,7 +115,7 @@ describe('isRampsUnifiedV2Enabled', () => { mockGetVersion.mockReturnValue('7.63.0'); const result = isRampsUnifiedV2Enabled( - buildState({ active: true, minimumVersion: '7.63.0' }), + buildState({ enabled: true, minimumVersion: '7.63.0' }), ); expect(result).toBe(true); diff --git a/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.ts b/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.ts index 5065e228945..956ccd194b1 100644 --- a/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.ts +++ b/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.ts @@ -1,8 +1,4 @@ -import { - selectRampsUnifiedBuyV2ActiveFlag, - selectRampsUnifiedBuyV2MinimumVersionFlag, -} from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2'; -import { hasMinimumRequiredVersion } from './hasMinimumRequiredVersion'; +import { selectRampsUnifiedBuyV2Enabled } from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2'; import { RootState } from '../../../../reducers'; /** @@ -16,7 +12,5 @@ export function isRampsUnifiedV2Enabled(state: RootState): boolean { return buildFlag === 'true'; } - const activeFlag = selectRampsUnifiedBuyV2ActiveFlag(state); - const minimumVersion = selectRampsUnifiedBuyV2MinimumVersionFlag(state); - return hasMinimumRequiredVersion(minimumVersion, activeFlag); + return selectRampsUnifiedBuyV2Enabled(state); } diff --git a/app/components/UI/Ramp/utils/rampsNavigation.test.ts b/app/components/UI/Ramp/utils/rampsNavigation.test.ts new file mode 100644 index 00000000000..5e9a705ff5a --- /dev/null +++ b/app/components/UI/Ramp/utils/rampsNavigation.test.ts @@ -0,0 +1,83 @@ +import Routes from '../../../../constants/navigation/Routes'; +import { + createBuildQuoteRoute, + createRampsOrderDetailsRoute, + getNavigateAfterExternalBrowserRoutes, +} from './rampsNavigation'; + +describe('rampsNavigation', () => { + describe('getNavigateAfterExternalBrowserRoutes', () => { + it('returns BuildQuote route when returnDestination is buildQuote', () => { + const routes = getNavigateAfterExternalBrowserRoutes({ + returnDestination: 'buildQuote', + }); + + expect(routes).toHaveLength(1); + expect(routes[0]).toEqual({ + name: Routes.RAMP.BUILD_QUOTE, + params: {}, + }); + expect(routes[0]).toEqual(createBuildQuoteRoute()); + }); + + it('returns order details route when returnDestination is order', () => { + const routes = getNavigateAfterExternalBrowserRoutes({ + returnDestination: 'order', + orderCode: 'ord-abc-123', + providerCode: 'moonpay', + }); + + expect(routes).toHaveLength(1); + expect(routes[0]).toEqual( + createRampsOrderDetailsRoute({ + orderId: 'ord-abc-123', + showCloseButton: true, + }), + ); + expect(routes[0]).toEqual({ + name: Routes.RAMP.RAMPS_ORDER_DETAILS, + params: { + orderId: 'ord-abc-123', + showCloseButton: true, + }, + }); + }); + + it('accepts walletAddress in opts (order route params unchanged)', () => { + const routes = getNavigateAfterExternalBrowserRoutes({ + returnDestination: 'order', + orderCode: 'ord-456', + providerCode: 'paypal', + walletAddress: '0xabcdef', + }); + + expect(routes[0]).toEqual({ + name: Routes.RAMP.RAMPS_ORDER_DETAILS, + params: { + orderId: 'ord-456', + showCloseButton: true, + }, + }); + }); + + it('returns order details route with callbackUrl when returning from external browser', () => { + const routes = getNavigateAfterExternalBrowserRoutes({ + returnDestination: 'order', + callbackUrl: 'metamask://on-ramp/providers/paypal?orderId=abc123', + providerCode: 'paypal-staging', + walletAddress: '0x1234', + }); + + expect(routes).toHaveLength(1); + expect(routes[0]).toEqual({ + name: Routes.RAMP.RAMPS_ORDER_DETAILS, + params: { + callbackUrl: 'metamask://on-ramp/providers/paypal?orderId=abc123', + providerCode: 'paypal-staging', + walletAddress: '0x1234', + showCloseButton: true, + }, + }); + }); + }); +}); diff --git a/app/components/UI/Ramp/utils/rampsNavigation.ts b/app/components/UI/Ramp/utils/rampsNavigation.ts new file mode 100644 index 00000000000..844941fedae --- /dev/null +++ b/app/components/UI/Ramp/utils/rampsNavigation.ts @@ -0,0 +1,73 @@ +import Routes from '../../../../constants/navigation/Routes'; + +export interface RampsOrderDetailsParams { + orderId?: string; + showCloseButton?: boolean; + callbackUrl?: string; + providerCode?: string; + walletAddress?: string; +} + +export function createRampsOrderDetailsRoute(params: RampsOrderDetailsParams): { + name: string; + params: RampsOrderDetailsParams; +} { + return { + name: Routes.RAMP.RAMPS_ORDER_DETAILS, + params, + }; +} + +export function createBuildQuoteRoute(): { + name: string; + params: Record; +} { + return { name: Routes.RAMP.BUILD_QUOTE, params: {} }; +} + +export type NavigateAfterExternalBrowserOpts = + | { returnDestination: 'buildQuote' } + | { + returnDestination: 'order'; + orderCode: string; + providerCode: string; + walletAddress?: string; + } + | { + returnDestination: 'order'; + callbackUrl: string; + providerCode: string; + walletAddress: string; + }; + +/** + * Returns the routes array for navigation.reset() when returning from an + * external browser (e.g. InAppBrowser, system browser). Used by BuildQuote + * after widget checkout flows (PayPal, Moonpay, etc.). + */ +export function getNavigateAfterExternalBrowserRoutes( + opts: NavigateAfterExternalBrowserOpts, +): ( + | ReturnType + | ReturnType +)[] { + if (opts.returnDestination === 'order') { + if ('callbackUrl' in opts) { + return [ + createRampsOrderDetailsRoute({ + callbackUrl: opts.callbackUrl, + providerCode: opts.providerCode, + walletAddress: opts.walletAddress, + showCloseButton: true, + }), + ]; + } + return [ + createRampsOrderDetailsRoute({ + orderId: opts.orderCode, + showCloseButton: true, + }), + ]; + } + return [createBuildQuoteRoute()]; +} diff --git a/app/components/UI/Ramp/utils/reportRampsError.test.ts b/app/components/UI/Ramp/utils/reportRampsError.test.ts new file mode 100644 index 00000000000..42d9078f361 --- /dev/null +++ b/app/components/UI/Ramp/utils/reportRampsError.test.ts @@ -0,0 +1,40 @@ +import Logger from '../../../../util/Logger'; +import { reportRampsError } from './reportRampsError'; + +jest.mock('../../../../util/Logger', () => ({ + error: jest.fn(), +})); + +jest.mock('./parseUserFacingError', () => ({ + parseUserFacingError: (_err: unknown, fallback: string) => fallback, +})); + +describe('reportRampsError', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls Logger.error with error and context', () => { + const err = new Error('Test error'); + reportRampsError( + err, + { provider: 'paypal', message: 'Widget failed' }, + 'Fallback', + ); + expect(Logger.error).toHaveBeenCalledWith(err, { + provider: 'paypal', + message: 'Widget failed', + }); + }); + + it('passes empty object when context is undefined', () => { + reportRampsError(new Error('x'), undefined, 'Fallback'); + expect(Logger.error).toHaveBeenCalledWith(expect.any(Error), {}); + }); + + it('returns result of parseUserFacingError', () => { + const fallback = 'Something went wrong'; + const result = reportRampsError(new Error('x'), {}, fallback); + expect(result).toBe(fallback); + }); +}); diff --git a/app/components/UI/Ramp/utils/reportRampsError.ts b/app/components/UI/Ramp/utils/reportRampsError.ts new file mode 100644 index 00000000000..6579e316d26 --- /dev/null +++ b/app/components/UI/Ramp/utils/reportRampsError.ts @@ -0,0 +1,25 @@ +import Logger from '../../../../util/Logger'; +import { parseUserFacingError } from './parseUserFacingError'; + +export interface ReportRampsErrorContext { + provider?: string; + message?: string; +} + +/** + * Logs a ramps error and returns a user-facing message. Use with setNativeFlowError + * in catch blocks. + * + * @param error - The caught error. + * @param context - Optional context for logging (e.g. provider, message). + * @param fallback - Fallback string when no message can be extracted. + * @returns User-facing error message to pass to setNativeFlowError. + */ +export function reportRampsError( + error: unknown, + context: ReportRampsErrorContext | undefined, + fallback: string, +): string { + Logger.error(error as Error, context ?? {}); + return parseUserFacingError(error, fallback); +} diff --git a/app/components/UI/Ramp/utils/resolveRampControllerAssetId.test.ts b/app/components/UI/Ramp/utils/resolveRampControllerAssetId.test.ts new file mode 100644 index 00000000000..5754c38b30e --- /dev/null +++ b/app/components/UI/Ramp/utils/resolveRampControllerAssetId.test.ts @@ -0,0 +1,98 @@ +import { + resolveRampControllerAssetId, + TokenForResolve, +} from './resolveRampControllerAssetId'; + +function createTokens(overrides: TokenForResolve[] = []): TokenForResolve[] { + return overrides; +} + +describe('resolveRampControllerAssetId', () => { + describe('when allTokens is empty or has no match', () => { + it('returns the input assetId when allTokens is empty', () => { + const assetId = + 'eip155:1/erc20:0x1234567890123456789012345678901234567890'; + + const result = resolveRampControllerAssetId(assetId, []); + + expect(result).toBe(assetId); + }); + + it('returns the input assetId when allTokens has no matching token', () => { + const assetId = 'eip155:1/erc20:0xabcdef'; + const allTokens = createTokens([ + { assetId: 'eip155:1/erc20:0x123456', chainId: '1' }, + ]); + + const result = resolveRampControllerAssetId(assetId, allTokens); + + expect(result).toBe(assetId); + }); + }); + + describe('ERC20 assetId resolution (case-insensitive)', () => { + it('resolves to controller canonical format when input is lowercase and controller has checksummed', () => { + const inputAssetId = 'eip155:1/erc20:0xabc123'; + const controllerAssetId = 'eip155:1/erc20:0xABC123'; + const allTokens = createTokens([ + { assetId: controllerAssetId, chainId: '1' }, + ]); + + const result = resolveRampControllerAssetId(inputAssetId, allTokens); + + expect(result).toBe(controllerAssetId); + }); + + it('resolves to controller canonical format when input is uppercase and controller has lowercase', () => { + const inputAssetId = 'eip155:1/erc20:0xABCDEF'; + const controllerAssetId = 'eip155:1/erc20:0xabcdef'; + const allTokens = createTokens([ + { assetId: controllerAssetId, chainId: '1' }, + ]); + + const result = resolveRampControllerAssetId(inputAssetId, allTokens); + + expect(result).toBe(controllerAssetId); + }); + }); + + describe('native token (slip44) resolution', () => { + it('resolves by chainId and slip44 presence to controller canonical assetId', () => { + const inputAssetId = 'eip155:1/slip44:.'; + const controllerAssetId = 'eip155:1/slip44:60'; + const allTokens = createTokens([ + { assetId: controllerAssetId, chainId: 'eip155:1' }, + { assetId: 'eip155:137/slip44:60', chainId: 'eip155:137' }, + ]); + + const result = resolveRampControllerAssetId(inputAssetId, allTokens); + + expect(result).toBe(controllerAssetId); + }); + + it('returns input assetId when no token matches chainId for native asset', () => { + const inputAssetId = 'eip155:1/slip44:.'; + const allTokens = createTokens([ + { assetId: 'eip155:137/slip44:60', chainId: 'eip155:137' }, + ]); + + const result = resolveRampControllerAssetId(inputAssetId, allTokens); + + expect(result).toBe(inputAssetId); + }); + }); + + describe('tokens without assetId', () => { + it('skips tokens without assetId and matches next token', () => { + const assetId = 'eip155:1/erc20:0x123'; + const allTokens = createTokens([ + { chainId: '1' }, + { assetId: 'eip155:1/erc20:0x123', chainId: '1' }, + ]); + + const result = resolveRampControllerAssetId(assetId, allTokens); + + expect(result).toBe('eip155:1/erc20:0x123'); + }); + }); +}); diff --git a/app/components/UI/Ramp/utils/resolveRampControllerAssetId.ts b/app/components/UI/Ramp/utils/resolveRampControllerAssetId.ts new file mode 100644 index 00000000000..4d0ddca2016 --- /dev/null +++ b/app/components/UI/Ramp/utils/resolveRampControllerAssetId.ts @@ -0,0 +1,36 @@ +/** + * Token shape required for resolving assetId. + * Matches the fields used from RampsController token list. + */ +export interface TokenForResolve { + assetId?: string; + chainId?: string; +} + +/** + * Resolves an assetId to the controller's canonical format. + * Handles casing (API lowercase vs checksummed) and native token + * placeholder ('slip44:.' vs 'slip44:{coinType}'). + * Use in both useRampNavigation and handleRampUrl for consistent behavior. + * + * @param assetId - Asset ID from URL/param (e.g. eip155:1/erc20:0x... or eip155:1/slip44:.) + * @param allTokens - List of tokens from RampsController (e.g. selectTokens(state).data?.allTokens) + * @returns Controller's canonical assetId, or the input assetId if no match + */ +export function resolveRampControllerAssetId( + assetId: string, + allTokens: TokenForResolve[], +): string { + const isNative = assetId.includes('/slip44:'); + const [chainId] = assetId.split('/'); + + const match = allTokens.find((tok) => { + if (!tok.assetId) return false; + if (isNative) { + return tok.chainId === chainId && tok.assetId.includes('/slip44:'); + } + return tok.assetId.toLowerCase() === assetId.toLowerCase(); + }); + + return match?.assetId ?? assetId; +} diff --git a/app/components/UI/ReceiveRequest/RequestPaymentModal.testIds.ts b/app/components/UI/ReceiveRequest/RequestPaymentModal.testIds.ts deleted file mode 100644 index 1e8b2aa9a8c..00000000000 --- a/app/components/UI/ReceiveRequest/RequestPaymentModal.testIds.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const RequestPaymentModalSelectorsIDs = { - REQUEST_BUTTON: 'request-payment-button', -} as const; diff --git a/app/components/UI/ReceiveRequest/RequestPaymentView.testIds.ts b/app/components/UI/ReceiveRequest/RequestPaymentView.testIds.ts deleted file mode 100644 index 0fc4ad7c77e..00000000000 --- a/app/components/UI/ReceiveRequest/RequestPaymentView.testIds.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const RequestPaymentViewSelectors = { - BACK_BUTTON_ID: 'request-search-asset-back-button', - REQUEST_PAYMENT_CONTAINER_ID: 'request-screen', - REQUEST_ASSET_LIST_ID: 'searched-asset-results', - REQUEST_AMOUNT_INPUT_BOX_ID: 'request-amount-input', - TOKEN_SEARCH_INPUT_BOX: 'request-search-asset-input', -} as const; diff --git a/app/components/UI/ReceiveRequest/SendLinkView.testIds.ts b/app/components/UI/ReceiveRequest/SendLinkView.testIds.ts deleted file mode 100644 index 27dbfe20855..00000000000 --- a/app/components/UI/ReceiveRequest/SendLinkView.testIds.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const SendLinkViewSelectorsIDs = { - CONTAINER_ID: 'send-link-screen', - QR_CODE_BUTTON: 'request-qrcode-button', - QR_MODAL: 'payment-request-qrcode', - CLOSE_QR_MODAL_BUTTON: 'payment-request-qrcode-close-button', - CLOSE_SEND_LINK_VIEW_BUTTON: 'send-link-close-button', -} as const; diff --git a/app/components/UI/ReceiveRequest/__snapshots__/index.test.tsx.snap b/app/components/UI/ReceiveRequest/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 49565ff43a3..00000000000 --- a/app/components/UI/ReceiveRequest/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,1561 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ReceiveRequest render matches snapshot 1`] = ` - - - - - - - - - - - - - ReceiveRequest - - - - - - - - - - - - - - - - - - - - - - QR: ethereum:0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756@0x1 - - - - - - 0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756 - - - - - - - - - Request payment - - - - - - - - - - - - - - - -`; - -exports[`ReceiveRequest render with different ticker matches snapshot 1`] = ` - - - - - - - - - - - - - ReceiveRequest - - - - - - - - - - - - - - - - - - - - - - QR: ethereum:0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756@0x1 - - - - - - 0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756 - - - - - - - - - Request payment - - - - - - - - - - - - - - - -`; - -exports[`ReceiveRequest render without buy matches snapshot 1`] = ` - - - - - - - - - - - - - ReceiveRequest - - - - - - - - - - - - - - - - - - - - - - QR: ethereum:0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756@0x1 - - - - - - 0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756 - - - - - - - - - Request payment - - - - - - - - - - - - - - - -`; diff --git a/app/components/UI/ReceiveRequest/index.js b/app/components/UI/ReceiveRequest/index.js deleted file mode 100644 index 0f91e586dee..00000000000 --- a/app/components/UI/ReceiveRequest/index.js +++ /dev/null @@ -1,231 +0,0 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { SafeAreaView, Dimensions, Alert } from 'react-native'; -import { - Box, - BoxFlexDirection, - BoxAlignItems, - BoxJustifyContent, - Button, - ButtonVariant, - ButtonSize, -} from '@metamask/design-system-react-native'; -import QRCode from 'react-native-qrcode-svg'; -import { connect } from 'react-redux'; - -import { MetaMetricsEvents } from '../../../core/Analytics'; -import { strings } from '../../../../locales/i18n'; -import { showAlert } from '../../../actions/alert'; -import { protectWalletModalVisible } from '../../../actions/user'; - -import GlobalAlert from '../GlobalAlert'; -import ClipboardManager from '../../../core/ClipboardManager'; -import { ThemeContext, mockTheme } from '../../../util/theme'; -import { selectChainId } from '../../../selectors/networkController'; -import { isNetworkRampSupported } from '../Ramp/Aggregator/utils'; -import { withRampNavigation } from '../Ramp/hooks/withRampNavigation'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; -import { getRampNetworks } from '../../../reducers/fiatOrders'; -import { RequestPaymentModalSelectorsIDs } from './RequestPaymentModal.testIds'; -import { analytics } from '../../../util/analytics/analytics'; -import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; -import QRAccountDisplay from '../../Views/QRAccountDisplay'; -import PNG_MM_LOGO_PATH from '../../../images/branding/fox.png'; -import { isEthAddress } from '../../../util/address'; -import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; - -const { height: windowHeight } = Dimensions.get('window'); - -const createStyles = (theme) => ({ - wrapper: { - backgroundColor: theme.colors.background.default, - borderTopLeftRadius: 10, - borderTopRightRadius: 10, - marginTop: windowHeight * 0.05 + 160, - marginBottom: 20, - marginHorizontal: 0, - paddingHorizontal: 0, - height: windowHeight * 0.95 - 180, - width: '100%', - }, -}); - -/** - * PureComponent that renders receive options - */ -class ReceiveRequest extends PureComponent { - static propTypes = { - /** - * The navigator object - */ - navigation: PropTypes.object, - /** - * Selected address as string - */ - selectedAddress: PropTypes.string, - /** - * Asset to receive, could be not defined - */ - receiveAsset: PropTypes.object, - /** - /* Triggers global alert - */ - showAlert: PropTypes.func, - /** - * Function to navigate to ramp flows - */ - goToBuy: PropTypes.func, - /** - * Network provider chain id - */ - chainId: PropTypes.string, - /** - * Prompts protect wallet modal - */ - protectWalletModalVisible: PropTypes.func, - /** - * Hides the modal that contains the component - */ - hideModal: PropTypes.func, - /** - * redux flag that indicates if the user - * completed the seed phrase backup flow - */ - seedphraseBackedUp: PropTypes.bool, - /** - * Boolean that indicates if the network supports buy - */ - isNetworkBuySupported: PropTypes.bool, - /** - * Boolean that indicates if the evm network is selected - */ - isEvmNetworkSelected: PropTypes.bool, - }; - - state = { - qrModalVisible: false, - buyModalVisible: false, - }; - - /** - * Shows an alert message with a coming soon message - */ - onBuy = async () => { - const { isNetworkBuySupported, goToBuy } = this.props; - if (!isNetworkBuySupported) { - Alert.alert( - strings('fiat_on_ramp.network_not_supported'), - strings('fiat_on_ramp.switch_network'), - ); - } else { - goToBuy(); - // TODO: Add RAMPS_BUTTON_CLICKED analytics tracking when this component is refactored to a functional component - // This will allow access to the useRampsButtonClickData hook for the expanded analytics payload - } - }; - - copyAccountToClipboard = async () => { - const { selectedAddress } = this.props; - ClipboardManager.setString(selectedAddress); - this.props.showAlert({ - isVisible: true, - autodismiss: 1500, - content: 'clipboard-alert', - data: { msg: strings('account_details.account_copied_to_clipboard') }, - }); - if (!this.props.seedphraseBackedUp) { - setTimeout(() => this.props.hideModal(), 1000); - setTimeout(() => this.props.protectWalletModalVisible(), 1500); - } - }; - - onReceive = () => { - this.props.navigation.navigate('PaymentRequestView', { - screen: 'PaymentRequest', - params: { receiveAsset: this.props.receiveAsset }, - }); - - analytics.trackEvent( - AnalyticsEventBuilder.createEventBuilder( - MetaMetricsEvents.RECEIVE_OPTIONS_PAYMENT_REQUEST, - ) - .addProperties({ action: 'Receive Options', name: 'Payment Request' }) - .build(), - ); - }; - - render() { - const theme = this.context || mockTheme; - const styles = createStyles(theme); - - const qrValue = isEthAddress(this.props.selectedAddress) - ? `ethereum:${this.props.selectedAddress}@${this.props.chainId}` - : this.props.selectedAddress; - - return ( - - - - - - - - - - - - - - {this.props.isEvmNetworkSelected && ( - - - - )} - - - - ); - } -} - -ReceiveRequest.contextType = ThemeContext; - -const mapStateToProps = (state) => ({ - chainId: selectChainId(state), - selectedAddress: selectSelectedInternalAccountFormattedAddress(state), - receiveAsset: state.modals.receiveAsset, - seedphraseBackedUp: state.user.seedphraseBackedUp, - isNetworkBuySupported: isNetworkRampSupported( - selectChainId(state), - getRampNetworks(state), - ), - isEvmNetworkSelected: selectIsEvmNetworkSelected(state), -}); - -const mapDispatchToProps = (dispatch) => ({ - showAlert: (config) => dispatch(showAlert(config)), - protectWalletModalVisible: () => dispatch(protectWalletModalVisible()), -}); - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(withRampNavigation(ReceiveRequest)); diff --git a/app/components/UI/ReceiveRequest/index.test.tsx b/app/components/UI/ReceiveRequest/index.test.tsx deleted file mode 100644 index 9202bed008b..00000000000 --- a/app/components/UI/ReceiveRequest/index.test.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import React from 'react'; -import { cloneDeep } from 'lodash'; -import { RpcEndpointType } from '@metamask/network-controller'; -import ReceiveRequest from './'; -import { renderScreen } from '../../../util/test/renderWithProvider'; -import { backgroundState } from '../../../util/test/initial-root-state'; -import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../util/test/accountsControllerTestUtils'; -import { mockNetworkState } from '../../../util/test/network'; -import { RequestPaymentModalSelectorsIDs } from './RequestPaymentModal.testIds'; -import { fireEvent } from '@testing-library/react-native'; - -const initialState = { - engine: { - backgroundState: { - ...backgroundState, - NetworkController: { - ...mockNetworkState({ - id: 'mainnet', - nickname: 'Ethereum', - ticker: 'ETH', - chainId: '0x1', - type: RpcEndpointType.Infura, - }), - }, - AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, - }, - }, - fiatOrders: { - networks: [ - { - active: true, - chainId: '1', - nativeTokenSupported: true, - }, - ], - }, -}; - -jest.mock('../../../util/address', () => ({ - ...jest.requireActual('../../../util/address'), - renderAccountName: jest.fn(), -})); - -jest.mock('react-native-share', () => ({ - open: jest.fn(), -})); - -jest.mock('../../../core/ClipboardManager', () => ({ - setString: jest.fn(), -})); - -jest.mock('../../../util/analytics/analytics', () => ({ - analytics: { - trackEvent: jest.fn(), - }, -})); - -jest.mock('../../../util/analytics/AnalyticsEventBuilder', () => ({ - AnalyticsEventBuilder: { - createEventBuilder: jest.fn(() => ({ - addProperties: jest.fn(() => ({ build: jest.fn() })), - build: jest.fn(), - })), - }, -})); - -// Mock QRCode component to test props -jest.mock('react-native-qrcode-svg', () => { - const actualReact = jest.requireActual('react'); - const { Text } = jest.requireActual('react-native'); - return function MockQRCode({ - value, - size, - logoSize, - logoBorderRadius, - }: { - value: string; - size?: number; - logoSize?: number; - logoBorderRadius?: number; - }) { - return actualReact.createElement( - Text, - { - testID: 'receive-request-qr-code', - accessibilityLabel: `QR Code: ${value}, size: ${size}, logoSize: ${logoSize}, logoBorderRadius: ${logoBorderRadius}`, - }, - `QR: ${value}`, - ); - }; -}); - -// Mock QRAccountDisplay to test integration -jest.mock('../../Views/QRAccountDisplay', () => { - const actualReact = jest.requireActual('react'); - const { View, Text } = jest.requireActual('react-native'); - return function MockQRAccountDisplay({ - accountAddress, - }: { - accountAddress: string; - }) { - return actualReact.createElement( - View, - { testID: 'receive-request-qr-account-display' }, - actualReact.createElement( - Text, - { testID: 'qr-account-address' }, - accountAddress, - ), - ); - }; -}); - -describe('ReceiveRequest', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('render matches snapshot', () => { - const { toJSON } = renderScreen( - ReceiveRequest, - { name: 'ReceiveRequest' }, - { state: initialState }, - ); - expect(toJSON()).toMatchSnapshot(); - }); - - it('renders QR code with correct properties', () => { - // Arrange & Act - const { getByTestId } = renderScreen( - ReceiveRequest, - { name: 'ReceiveRequest' }, - { state: initialState }, - ); - const qrCode = getByTestId('receive-request-qr-code'); - - // Assert - expect(qrCode).toBeOnTheScreen(); - expect(qrCode.props.accessibilityLabel).toContain('size: 200'); - expect(qrCode.props.accessibilityLabel).toContain('logoSize: 32'); - expect(qrCode.props.accessibilityLabel).toContain('logoBorderRadius: 8'); - }); - - it('displays account address in QR account display', () => { - // Arrange & Act - const { getByTestId } = renderScreen( - ReceiveRequest, - { name: 'ReceiveRequest' }, - { state: initialState }, - ); - - // Assert - expect(getByTestId('receive-request-qr-account-display')).toBeOnTheScreen(); - expect(getByTestId('qr-account-address')).toBeOnTheScreen(); - }); - - it('render with different ticker matches snapshot', () => { - const state = cloneDeep(initialState); - state.engine.backgroundState.NetworkController.networkConfigurationsByChainId[ - '0x1' - ].nativeCurrency = 'DIFF'; - const { toJSON } = renderScreen( - ReceiveRequest, - { name: 'ReceiveRequest' }, - { state }, - ); - expect(toJSON()).toMatchSnapshot(); - }); - - it('render without buy matches snapshot', () => { - const state = { - ...initialState, - fiatOrders: undefined, - }; - const { toJSON } = renderScreen( - ReceiveRequest, - { name: 'ReceiveRequest' }, - { state }, - ); - expect(toJSON()).toMatchSnapshot(); - }); - - it('renders request payment button when EVM network is selected', () => { - // Arrange & Act - const { getByTestId } = renderScreen( - ReceiveRequest, - { name: 'ReceiveRequest' }, - { state: initialState }, - ); - const requestButton = getByTestId( - RequestPaymentModalSelectorsIDs.REQUEST_BUTTON, - ); - - // Assert - expect(requestButton).toBeOnTheScreen(); - }); - - it('does not render request button when EVM network is not selected', () => { - // Arrange - const state = { - ...initialState, - engine: { - backgroundState: { - ...initialState.engine.backgroundState, - MultichainNetworkController: { - isEvmSelected: false, - }, - }, - }, - }; - - // Act - const { queryByTestId } = renderScreen( - ReceiveRequest, - { name: 'ReceiveRequest' }, - { state }, - ); - - // Assert - expect( - queryByTestId(RequestPaymentModalSelectorsIDs.REQUEST_BUTTON), - ).toBeNull(); - }); - - it('navigates to payment request when request button is pressed', () => { - // Arrange - const mockNavigate = jest.fn(); - const receiveAsset = { symbol: 'ETH' }; - - // Act - const { getByTestId } = renderScreen( - () => - React.createElement(ReceiveRequest, { - navigation: { navigate: mockNavigate }, - selectedAddress: '0x123', - receiveAsset, - }), - { name: 'ReceiveRequest' }, - { state: initialState }, - ); - const requestButton = getByTestId( - RequestPaymentModalSelectorsIDs.REQUEST_BUTTON, - ); - fireEvent.press(requestButton); - - // Assert - expect(mockNavigate).toHaveBeenCalledWith( - 'PaymentRequestView', - expect.objectContaining({ - screen: 'PaymentRequest', - params: expect.any(Object), - }), - ); - }); -}); diff --git a/app/components/UI/ReviewModal/styles.ts b/app/components/UI/ReviewModal/styles.ts index 28156e4c40b..33d3b78aaa6 100644 --- a/app/components/UI/ReviewModal/styles.ts +++ b/app/components/UI/ReviewModal/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; // TODO: Replace "any" with type diff --git a/app/components/UI/Rewards/RewardsNavigator.test.tsx b/app/components/UI/Rewards/RewardsNavigator.test.tsx index c18e4857bfc..beea1bf411d 100644 --- a/app/components/UI/Rewards/RewardsNavigator.test.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.test.tsx @@ -69,24 +69,51 @@ jest.mock('./Views/RewardsSettingsView', () => { }; }); -// Mock Skeleton component -jest.mock('../../../component-library/components/Skeleton/Skeleton', () => { +jest.mock('./Views/CampaignDetailsView', () => { const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return function MockSkeleton({ - width, - height, - }: { - width: string; - height: string; - }) { - return ReactActual.createElement(View, { - testID: 'skeleton-loader', - style: { width, height }, - }); + const { View, Text } = jest.requireActual('react-native'); + return function MockCampaignDetailsView() { + return ReactActual.createElement( + View, + { testID: 'campaign-details-view' }, + ReactActual.createElement(Text, null, 'Campaign Details View'), + ); + }; +}); + +jest.mock('./Views/CampaignMechanicsView', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return function MockCampaignMechanicsView() { + return ReactActual.createElement( + View, + { testID: 'campaign-mechanics-view' }, + ReactActual.createElement(Text, null, 'Campaign Mechanics View'), + ); }; }); +// Mock Skeleton component +jest.mock( + '../../../component-library/components-temp/Skeleton/Skeleton', + () => { + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return function MockSkeleton({ + width, + height, + }: { + width: string; + height: string; + }) { + return React.createElement(View, { + testID: 'skeleton-loader', + style: { width, height }, + }); + }; + }, +); + // Mock ErrorBoundary jest.mock('../../Views/ErrorBoundary', () => ({ __esModule: true, @@ -151,6 +178,11 @@ jest.mock('./hooks/useCandidateSubscriptionId', () => ({ useCandidateSubscriptionId: jest.fn(), })); +// Mock useRewardCampaigns hook +jest.mock('./hooks/useRewardCampaigns', () => ({ + useRewardCampaigns: jest.fn(), +})); + // Mock useSeasonStatus hook jest.mock('./hooks/useSeasonStatus', () => ({ useSeasonStatus: jest.fn(), @@ -397,6 +429,19 @@ describe('RewardsNavigator', () => { expect(queryByTestId('rewards-dashboard-view')).toBeNull(); }); }); + + it('registers CAMPAIGN_DETAILS and CAMPAIGN_MECHANICS routes when subscription exists', async () => { + // Both views are registered inside the subscriptionId-guarded block, + // so they are present in the navigator only when the user is enrolled. + mockSelectRewardsSubscriptionId.mockReturnValue('test-subscription-id'); + + // Rendering should not throw even with the new screens registered + const { getByTestId } = renderWithNavigation(); + + await waitFor(() => { + expect(getByTestId('rewards-dashboard-view')).toBeOnTheScreen(); + }); + }); }); // Note: Removed AuthErrorView tests as they don't match the actual implementation diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx index 544b5991a99..c75ef4136de 100644 --- a/app/components/UI/Rewards/RewardsNavigator.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.tsx @@ -5,6 +5,10 @@ import OnboardingNavigator from './OnboardingNavigator'; import RewardsDashboard from './Views/RewardsDashboard'; import ReferralRewardsView from './Views/RewardsReferralView'; import RewardsSettingsView from './Views/RewardsSettingsView'; +import CampaignsView from './Views/CampaignsView'; +import CampaignDetailsView from './Views/CampaignDetailsView'; +import CampaignMechanicsView from './Views/CampaignMechanicsView'; +import PreviousSeasonView from './Views/PreviousSeasonView'; import { useSelector } from 'react-redux'; import { selectRewardsSubscriptionId } from '../../../selectors/rewards'; import { useCandidateSubscriptionId } from './hooks/useCandidateSubscriptionId'; @@ -62,13 +66,33 @@ const RewardsNavigator: React.FC = () => { + + + + ) : null} diff --git a/app/components/UI/Rewards/Views/CampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/CampaignDetailsView.test.tsx new file mode 100644 index 00000000000..a68efde45f7 --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignDetailsView.test.tsx @@ -0,0 +1,436 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import CampaignDetailsView, { + CAMPAIGN_DETAILS_TEST_IDS, +} from './CampaignDetailsView'; +import { + type CampaignDto, + CampaignType, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import Routes from '../../../../constants/navigation/Routes'; + +const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }), + useRoute: () => ({ params: { campaignId: 'campaign-1' } }), +})); + +jest.mock('react-native-safe-area-context', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const actual = jest.requireActual('react-native-safe-area-context'); + return { + ...actual, + useSafeAreaInsets: jest.fn(() => ({ + top: 0, + right: 0, + bottom: 0, + left: 0, + })), + SafeAreaView: ({ + children, + testID, + ...props + }: { + children: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, { ...props, testID }, children), + }; +}); + +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, + 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 { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, 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/CampaignOptInSheet', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ onClose: _onClose }: { onClose?: () => void }) => + ReactActual.createElement(View, { + testID: 'campaign-opt-in-sheet', + // expose onClose so tests can trigger it + accessible: true, + accessibilityLabel: 'opt-in-sheet', + }), + }; +}); + +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: 'error-banner' }, + ReactActual.createElement(Text, null, title), + confirmButtonLabel && + ReactActual.createElement( + Pressable, + { onPress: onConfirm, testID: 'error-retry-button' }, + ReactActual.createElement(Text, null, confirmButtonLabel), + ), + ), + }; +}); + +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('../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaigns_view.error_title': 'Unable to load', + 'rewards.campaigns_view.error_description': 'Please try again.', + 'rewards.campaigns_view.retry_button': 'Retry', + 'rewards.campaign_details.checking_opt_in_status': 'Checking...', + 'rewards.campaign_details.join_campaign': 'Join Campaign', + }; + return translations[key] || key; + }, +})); + +const createTestCampaign = ( + overrides: Partial = {}, +): CampaignDto => ({ + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2027-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +const mockFetchCampaigns = jest.fn(); +const emptyCategorized = { active: [], upcoming: [], previous: [] }; +const hookDefaults = { + campaigns: [], + categorizedCampaigns: emptyCategorized, + isLoading: false, + hasError: false, + fetchCampaigns: mockFetchCampaigns, +}; + +describe('CampaignDetailsView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRewardCampaigns.mockReturnValue(hookDefaults); + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: null, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + }); + + it('renders the container', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + const { getByTestId } = render(); + expect(getByTestId(CAMPAIGN_DETAILS_TEST_IDS.CONTAINER)).toBeDefined(); + }); + + it('renders campaign name in the header', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign({ name: 'My Special Campaign' })], + }); + const { getAllByText } = render(); + // Name appears in both the header title and the CampaignStatus mock + expect(getAllByText('My Special Campaign').length).toBeGreaterThan(0); + }); + + describe('loading state', () => { + it('shows no error banner or campaign status while loading with no campaign', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [], + isLoading: true, + }); + const { queryByTestId } = render(); + expect(queryByTestId('error-banner')).toBeNull(); + expect(queryByTestId('campaign-status')).toBeNull(); + }); + }); + + describe('error state', () => { + it('shows error banner when hasError and no campaign', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [], + hasError: true, + }); + const { getByTestId } = render(); + expect(getByTestId('error-banner')).toBeDefined(); + }); + + it('calls fetchCampaigns when retry is pressed', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [], + hasError: true, + }); + const { getByTestId } = render(); + fireEvent.press(getByTestId('error-retry-button')); + expect(mockFetchCampaigns).toHaveBeenCalledTimes(1); + }); + + it('does not show error banner when campaign is found even with hasError', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + hasError: true, + }); + const { queryByTestId } = render(); + expect(queryByTestId('error-banner')).toBeNull(); + }); + }); + + describe('campaign content', () => { + it('renders CampaignStatus when campaign is found', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + const { getByTestId } = render(); + expect(getByTestId('campaign-status')).toBeDefined(); + }); + + it('renders CampaignHowItWorks when campaign has howItWorks details', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Description', + phases: [], + }, + }, + }), + ], + }); + const { getByTestId } = render(); + expect(getByTestId('campaign-how-it-works')).toBeDefined(); + }); + + it('does not render CampaignHowItWorks when campaign has no details', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign({ details: null })], + }); + const { queryByTestId } = render(); + expect(queryByTestId('campaign-how-it-works')).toBeNull(); + }); + }); + + describe('opt-in CTA', () => { + it('renders the join CTA when participant status is null', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + // status null → participantStatus?.optedIn !== true + const { getByTestId } = render(); + expect(getByTestId(CAMPAIGN_DETAILS_TEST_IDS.CTA_BUTTON)).toBeDefined(); + }); + + it('renders the join CTA when participant is not opted in', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: { optedIn: false, participantCount: 0 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + const { getByTestId } = render(); + expect(getByTestId(CAMPAIGN_DETAILS_TEST_IDS.CTA_BUTTON)).toBeDefined(); + }); + + it('does not render the CTA when participant is already opted in', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: { optedIn: true, participantCount: 1 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + const { queryByTestId } = render(); + expect(queryByTestId(CAMPAIGN_DETAILS_TEST_IDS.CTA_BUTTON)).toBeNull(); + }); + + it('renders the CTA as disabled and loading when participant status is loading', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: null, + isLoading: true, + hasError: false, + refetch: jest.fn(), + }); + const { getByTestId, getByText } = render(); + const cta = getByTestId(CAMPAIGN_DETAILS_TEST_IDS.CTA_BUTTON); + expect(cta).toBeDefined(); + expect( + cta.props.isDisabled ?? cta.props.accessibilityState?.disabled, + ).toBeTruthy(); + expect(getByText('Checking...')).toBeDefined(); + }); + + it('does not render CTA when no campaign is loaded', () => { + const { queryByTestId } = render(); + expect(queryByTestId(CAMPAIGN_DETAILS_TEST_IDS.CTA_BUTTON)).toBeNull(); + }); + + it('opens the opt-in sheet when CTA is pressed', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + const { getByTestId } = render(); + fireEvent.press(getByTestId(CAMPAIGN_DETAILS_TEST_IDS.CTA_BUTTON)); + expect(getByTestId('campaign-opt-in-sheet')).toBeDefined(); + }); + }); + + describe('navigation', () => { + it('navigates back when the back button is pressed', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + const { getByTestId } = render(); + fireEvent.press(getByTestId('header-back-button')); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('navigates to campaign mechanics when the mechanics button is pressed', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + const { getByTestId } = render(); + fireEvent.press(getByTestId('campaign-details-mechanics-button')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.CAMPAIGN_MECHANICS, { + campaignId: 'campaign-1', + }); + }); + }); +}); diff --git a/app/components/UI/Rewards/Views/CampaignDetailsView.tsx b/app/components/UI/Rewards/Views/CampaignDetailsView.tsx new file mode 100644 index 00000000000..6c037d238d7 --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignDetailsView.tsx @@ -0,0 +1,157 @@ +import React, { useMemo, useState } from 'react'; +import { ScrollView } from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { + Box, + Button, + ButtonVariant, + ButtonSize, + IconName, + 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 CampaignStatus from '../components/Campaigns/CampaignStatus'; +import CampaignHowItWorks from '../components/Campaigns/CampaignHowItWorks'; +import CampaignOptInSheet from '../components/Campaigns/CampaignOptInSheet'; +import RewardsErrorBanner from '../components/RewardsErrorBanner'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; + +// ParamListBase requires an index signature, which interfaces don't support +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type CampaignDetailsRouteParams = { + CampaignDetails: { campaignId: string }; +}; + +export const CAMPAIGN_DETAILS_TEST_IDS = { + CONTAINER: 'campaign-details-container', + CTA_BUTTON: 'campaign-details-cta-button', +} as const; + +const CampaignDetailsView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const route = + useRoute>(); + const { campaignId } = route.params; + + const [isOptInSheetOpen, setIsOptInSheetOpen] = useState(false); + + const { campaigns, isLoading, hasError, fetchCampaigns } = + useRewardCampaigns(); + + const campaign = useMemo( + () => campaigns.find((c) => c.id === campaignId) ?? null, + [campaigns, campaignId], + ); + + const { status: participantStatus, isLoading: isStatusLoading } = + useGetCampaignParticipantStatus(campaignId); + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'campaign-details-back-button' }} + endButtonIconProps={ + campaign + ? [ + { + iconName: IconName.Question, + onPress: () => + navigation.navigate(Routes.CAMPAIGN_MECHANICS, { + campaignId, + }), + testID: 'campaign-details-mechanics-button', + }, + ] + : undefined + } + includesTopInset + /> + + + {isLoading && !campaign && ( + + + + + )} + + {!isLoading && hasError && !campaign && ( + + + + )} + + {campaign && ( + <> + + + {campaign.details?.howItWorks && ( + <> + + + + + + )} + + )} + + + {campaign && participantStatus?.optedIn !== true && ( + + + + )} + + {isOptInSheetOpen && campaign && ( + setIsOptInSheetOpen(false)} + /> + )} + + + ); +}; + +export default CampaignDetailsView; diff --git a/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx b/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx new file mode 100644 index 00000000000..6d631725025 --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx @@ -0,0 +1,370 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import CampaignMechanicsView, { + CAMPAIGN_MECHANICS_TEST_IDS, +} from './CampaignMechanicsView'; +import { + type CampaignDto, + CampaignType, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; + +const mockGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack }), + useRoute: () => ({ params: { campaignId: 'campaign-1' } }), +})); + +jest.mock('react-native-safe-area-context', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const actual = jest.requireActual('react-native-safe-area-context'); + return { + ...actual, + useSafeAreaInsets: jest.fn(() => ({ + top: 0, + right: 0, + bottom: 0, + left: 0, + })), + SafeAreaView: ({ + children, + testID, + ...props + }: { + children: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, { ...props, testID }, children), + }; +}); + +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 { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +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/ContentfulRichText/ContentfulRichText', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text: RNText } = jest.requireActual('react-native'); + const isDocumentFn = (value: unknown): boolean => + value !== null && + typeof value === 'object' && + 'nodeType' in (value as Record) && + (value as Record).nodeType === 'document' && + 'content' in (value as Record) && + Array.isArray((value as Record).content); + return { + __esModule: true, + isDocument: isDocumentFn, + default: ({ + document: doc, + testID, + }: { + document: unknown; + testID?: string; + }) => + ReactActual.createElement( + View, + { testID }, + ReactActual.createElement(RNText, null, JSON.stringify(doc)), + ), + }; +}); + +jest.mock('../hooks/useRewardCampaigns'); +const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< + typeof useRewardCampaigns +>; + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaign_mechanics.title': 'How it works', + }; + return translations[key] || key; + }, +})); + +const createTestCampaign = ( + overrides: Partial = {}, +): CampaignDto => ({ + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2027-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +const emptyCategorized = { active: [], upcoming: [], previous: [] }; +const hookDefaults = { + campaigns: [], + categorizedCampaigns: emptyCategorized, + isLoading: false, + hasError: false, + fetchCampaigns: jest.fn(), +}; + +describe('CampaignMechanicsView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRewardCampaigns.mockReturnValue(hookDefaults); + }); + + it('renders the container', () => { + const { getByTestId } = render(); + expect(getByTestId(CAMPAIGN_MECHANICS_TEST_IDS.CONTAINER)).toBeDefined(); + }); + + it('renders the header title', () => { + const { getByText } = render(); + expect(getByText('How it works')).toBeDefined(); + }); + + it('navigates back when the back button is pressed', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('header-back-button')); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + describe('howItWorks section', () => { + it('renders the howItWorks section when campaign has howItWorks', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Earn rewards', + phases: [], + }, + }, + }), + ], + }); + const { getByTestId } = render(); + expect( + getByTestId(CAMPAIGN_MECHANICS_TEST_IDS.HOW_IT_WORKS_SECTION), + ).toBeDefined(); + expect(getByTestId('campaign-how-it-works')).toBeDefined(); + }); + + it('does not render howItWorks section when campaign has no details', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign({ details: null })], + }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_MECHANICS_TEST_IDS.HOW_IT_WORKS_SECTION), + ).toBeNull(); + }); + + it('does not render howItWorks section when campaign is not found', () => { + // No campaigns in list → useMemo returns null + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_MECHANICS_TEST_IDS.HOW_IT_WORKS_SECTION), + ).toBeNull(); + }); + }); + + describe('notes section', () => { + const richTextNotes = { + nodeType: 'document', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { nodeType: 'text', value: 'Important notes', marks: [], data: {} }, + ], + }, + ], + }; + + it('renders notes section with ContentfulRichText when notes is present', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Earn rewards', + phases: [], + notes: richTextNotes, + }, + }, + }), + ], + }); + const { getByTestId } = render(); + expect( + getByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_SECTION), + ).toBeDefined(); + }); + + it('does not render notes section when notes is null', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Earn rewards', + phases: [], + notes: null, + }, + }, + }), + ], + }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_SECTION), + ).toBeNull(); + }); + + it('does not render notes section when howItWorks has no notes field', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Earn rewards', + phases: [], + }, + }, + }), + ], + }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_SECTION), + ).toBeNull(); + }); + + it('does not render notes section when notes is a non-document object', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Earn rewards', + phases: [], + notes: { title: 'Only title' }, + }, + }, + }), + ], + }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_SECTION), + ).toBeNull(); + }); + + it('does not render notes section when notes is a string', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Earn rewards', + phases: [], + notes: 'just a string', + }, + }, + }), + ], + }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_SECTION), + ).toBeNull(); + }); + }); +}); diff --git a/app/components/UI/Rewards/Views/CampaignMechanicsView.tsx b/app/components/UI/Rewards/Views/CampaignMechanicsView.tsx new file mode 100644 index 00000000000..3c6e66b75c5 --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignMechanicsView.tsx @@ -0,0 +1,84 @@ +import React, { useMemo } from 'react'; +import { ScrollView } from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { Box } 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 CampaignHowItWorks from '../components/Campaigns/CampaignHowItWorks'; +import ContentfulRichText, { + isDocument, +} from '../components/ContentfulRichText/ContentfulRichText'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { strings } from '../../../../../locales/i18n'; + +// ParamListBase requires an index signature, which interfaces don't support +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type CampaignMechanicsRouteParams = { + CampaignMechanics: { campaignId: string }; +}; + +export const CAMPAIGN_MECHANICS_TEST_IDS = { + CONTAINER: 'campaign-mechanics-container', + HOW_IT_WORKS_SECTION: 'campaign-mechanics-how-it-works', + NOTES_SECTION: 'campaign-mechanics-notes', +} as const; + +const CampaignMechanicsView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const route = + useRoute>(); + const { campaignId } = route.params; + + const { campaigns } = useRewardCampaigns(); + const campaign = useMemo( + () => campaigns.find((c) => c.id === campaignId) ?? null, + [campaigns, campaignId], + ); + + const howItWorks = campaign?.details?.howItWorks ?? null; + const notes = howItWorks?.notes ?? null; + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'campaign-mechanics-back-button' }} + includesTopInset + /> + + {howItWorks && ( + + + + )} + + {isDocument(notes) && ( + + + + )} + + + + ); +}; + +export default CampaignMechanicsView; diff --git a/app/components/UI/Rewards/Views/CampaignsView.test.tsx b/app/components/UI/Rewards/Views/CampaignsView.test.tsx new file mode 100644 index 00000000000..e8058223565 --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignsView.test.tsx @@ -0,0 +1,366 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import CampaignsView from './CampaignsView'; +import { + type CampaignDto, + CampaignType, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; + +const mockGoBack = jest.fn(); +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 }), +})); + +jest.mock('../hooks/useRewardCampaigns'); +const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< + typeof useRewardCampaigns +>; + +jest.mock('../components/Campaigns/CampaignsGroup', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + campaigns, + testID, + }: { + title: string; + campaigns: CampaignDto[]; + testID?: string; + }) => + campaigns.length > 0 + ? ReactActual.createElement( + View, + { testID }, + ReactActual.createElement(Text, null, title), + campaigns.map((c: CampaignDto) => + ReactActual.createElement(Text, { key: c.id }, c.name), + ), + ) + : null, + }; +}); + +jest.mock('../components/RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + description, + onConfirm, + confirmButtonLabel, + }: { + title: string; + description: string; + onConfirm?: () => void; + confirmButtonLabel?: string; + }) => + ReactActual.createElement( + View, + { testID: 'error-banner' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Text, null, description), + confirmButtonLabel && + ReactActual.createElement( + Pressable, + { onPress: onConfirm, testID: 'error-retry-button' }, + ReactActual.createElement(Text, null, confirmButtonLabel), + ), + ), + }; +}); + +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 { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaigns_view.title': 'Campaigns', + 'rewards.campaigns_view.active_title': 'Active', + 'rewards.campaigns_view.upcoming_title': 'Upcoming', + 'rewards.campaigns_view.previous_title': 'Previous', + 'rewards.campaigns_view.empty_state': 'No campaigns available', + 'rewards.campaigns_view.error_title': 'Unable to load campaigns', + 'rewards.campaigns_view.error_description': + "We couldn't load the campaigns. Please try again.", + 'rewards.campaigns_view.retry_button': 'Retry', + 'rewards.campaigns_view.refreshing': 'Refreshing...', + }; + return translations[key] || key; + }, +})); + +const createTestCampaign = ( + overrides: Partial = {}, +): CampaignDto => ({ + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2027-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +const emptyCategorized = { active: [], upcoming: [], previous: [] }; +const mockFetchCampaigns = jest.fn(); + +const hookDefaults = { + campaigns: [], + categorizedCampaigns: emptyCategorized, + isLoading: false, + hasError: false, + fetchCampaigns: mockFetchCampaigns, +}; + +describe('CampaignsView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRewardCampaigns.mockReturnValue(hookDefaults); + }); + + it('renders the header with the correct title', () => { + const { getByText, getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_VIEW), + ).toBeOnTheScreen(); + expect(getByText('Campaigns')).toBeOnTheScreen(); + }); + + it('navigates back when the back button is pressed', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('header-back-button')); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + describe('loading state', () => { + it('renders skeletons when loading with no campaigns', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + isLoading: true, + }); + + const { queryByText } = render(); + + expect(queryByText('No campaigns available')).toBeNull(); + expect(queryByText('Unable to load campaigns')).toBeNull(); + }); + + it('renders the refreshing indicator when loading with existing campaigns', () => { + const activeCampaign = createTestCampaign({ + id: 'a1', + name: 'Active One', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + isLoading: true, + categorizedCampaigns: { ...emptyCategorized, active: [activeCampaign] }, + }); + + const { getByText } = render(); + + expect(getByText('Refreshing...')).toBeOnTheScreen(); + expect(getByText('Active One')).toBeOnTheScreen(); + }); + }); + + describe('error state', () => { + it('renders the error banner when there is an error and no campaigns', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + hasError: true, + }); + + const { getByText } = render(); + + expect(getByText('Unable to load campaigns')).toBeOnTheScreen(); + expect( + getByText("We couldn't load the campaigns. Please try again."), + ).toBeOnTheScreen(); + expect(getByText('Retry')).toBeOnTheScreen(); + }); + + it('calls fetchCampaigns when retry button is pressed', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + hasError: true, + }); + + const { getByTestId } = render(); + fireEvent.press(getByTestId('error-retry-button')); + + expect(mockFetchCampaigns).toHaveBeenCalledTimes(1); + }); + }); + + describe('empty state', () => { + it('renders the empty state message when there are no campaigns and not loading', () => { + const { getByText } = render(); + + expect(getByText('No campaigns available')).toBeOnTheScreen(); + }); + }); + + describe('campaigns display', () => { + it('renders active campaigns group', () => { + const activeCampaign = createTestCampaign({ + id: 'a1', + name: 'Active One', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [activeCampaign] }, + }); + + const { getByText, getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_ACTIVE_SECTION), + ).toBeOnTheScreen(); + expect(getByText('Active')).toBeOnTheScreen(); + expect(getByText('Active One')).toBeOnTheScreen(); + }); + + it('renders upcoming campaigns group', () => { + const upcomingCampaign = createTestCampaign({ + id: 'u1', + name: 'Upcoming One', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + categorizedCampaigns: { + ...emptyCategorized, + upcoming: [upcomingCampaign], + }, + }); + + const { getByText, getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_UPCOMING_SECTION), + ).toBeOnTheScreen(); + expect(getByText('Upcoming')).toBeOnTheScreen(); + expect(getByText('Upcoming One')).toBeOnTheScreen(); + }); + + it('renders previous campaigns group', () => { + const previousCampaign = createTestCampaign({ + id: 'p1', + name: 'Previous One', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + categorizedCampaigns: { + ...emptyCategorized, + previous: [previousCampaign], + }, + }); + + const { getByText, getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIOUS_SECTION), + ).toBeOnTheScreen(); + expect(getByText('Previous')).toBeOnTheScreen(); + expect(getByText('Previous One')).toBeOnTheScreen(); + }); + + it('renders all three groups when all categories have campaigns', () => { + const active = createTestCampaign({ id: 'a1', name: 'Active One' }); + const upcoming = createTestCampaign({ id: 'u1', name: 'Upcoming One' }); + const previous = createTestCampaign({ id: 'p1', name: 'Previous One' }); + + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + categorizedCampaigns: { + active: [active], + upcoming: [upcoming], + previous: [previous], + }, + }); + + const { getByText } = render(); + + expect(getByText('Active')).toBeOnTheScreen(); + expect(getByText('Active One')).toBeOnTheScreen(); + expect(getByText('Upcoming')).toBeOnTheScreen(); + expect(getByText('Upcoming One')).toBeOnTheScreen(); + expect(getByText('Previous')).toBeOnTheScreen(); + expect(getByText('Previous One')).toBeOnTheScreen(); + }); + + it('does not show empty state or error when campaigns exist', () => { + const active = createTestCampaign({ id: 'a1', name: 'Active One' }); + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [active] }, + }); + + const { queryByText, queryByTestId } = render(); + + expect(queryByText('No campaigns available')).toBeNull(); + expect(queryByTestId('error-banner')).toBeNull(); + }); + + it('does not show refreshing indicator when not loading', () => { + const active = createTestCampaign({ id: 'a1', name: 'Active One' }); + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [active] }, + }); + + const { queryByText } = render(); + + expect(queryByText('Refreshing...')).toBeNull(); + }); + }); +}); diff --git a/app/components/UI/Rewards/Views/CampaignsView.tsx b/app/components/UI/Rewards/Views/CampaignsView.tsx new file mode 100644 index 00000000000..9867e1414ce --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignsView.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { ActivityIndicator } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; +import { useNavigation } from '@react-navigation/native'; +import { + Box, + Text, + TextVariant, + BoxFlexDirection, + BoxAlignItems, + 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 RewardsErrorBanner from '../components/RewardsErrorBanner'; +import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; +import CampaignsGroup from '../components/Campaigns/CampaignsGroup'; +import { strings } from '../../../../../locales/i18n'; + +/** + * CampaignsView displays all campaigns organized by status: + * - Active + * - Upcoming + * - Previous (complete) + */ +const CampaignsView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const { categorizedCampaigns, isLoading, hasError, fetchCampaigns } = + useRewardCampaigns(); + + const { active, upcoming, previous } = categorizedCampaigns; + const hasCampaigns = + active.length > 0 || upcoming.length > 0 || previous.length > 0; + + const renderContent = () => { + if (isLoading && !hasCampaigns) { + return ( + + + + + + + + + + + ); + } + + if (hasError && !hasCampaigns) { + return ( + + ); + } + + if (!hasCampaigns) { + return ( + + + {strings('rewards.campaigns_view.empty_state')} + + + ); + } + + return ( + + + + + + + + ); + }; + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'header-back-button' }} + includesTopInset + /> + + {isLoading && hasCampaigns && ( + + + + {strings('rewards.campaigns_view.refreshing')} + + + )} + + {renderContent()} + + + + ); +}; + +export default CampaignsView; diff --git a/app/components/UI/Rewards/Views/PreviousSeasonView.test.tsx b/app/components/UI/Rewards/Views/PreviousSeasonView.test.tsx new file mode 100644 index 00000000000..20ce0ec4e8b --- /dev/null +++ b/app/components/UI/Rewards/Views/PreviousSeasonView.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PreviousSeasonView from './PreviousSeasonView'; + +const mockGoBack = jest.fn(); +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 }), +})); + +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 { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('../components/PreviousSeason/PreviousSeasonSummary', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { + testID: 'previous-season-summary', + }), + }; +}); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.previous_season_view.title': 'Previous Season', + }; + return translations[key] || key; + }, +})); + +describe('PreviousSeasonView', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the safe area container', () => { + const { getByTestId } = render(); + + expect(getByTestId('previous-season-view-safe-area')).toBeOnTheScreen(); + }); + + it('renders the header with the correct title', () => { + const { getByText } = render(); + + expect(getByText('Previous Season')).toBeOnTheScreen(); + }); + + it('navigates back when the back button is pressed', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('header-back-button')); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('renders PreviousSeasonSummary', () => { + const { getByTestId } = render(); + + expect(getByTestId('previous-season-summary')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Rewards/Views/PreviousSeasonView.tsx b/app/components/UI/Rewards/Views/PreviousSeasonView.tsx new file mode 100644 index 00000000000..e40f39c6870 --- /dev/null +++ b/app/components/UI/Rewards/Views/PreviousSeasonView.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { ScrollView } from 'react-native-gesture-handler'; +import { strings } from '../../../../../locales/i18n'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import PreviousSeasonSummary from '../components/PreviousSeason/PreviousSeasonSummary'; + +const PreviousSeasonView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'header-back-button' }} + /> + + + + + + ); +}; + +export default PreviousSeasonView; diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx index 85c3a32f6a5..10620e591a3 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx @@ -3,7 +3,6 @@ import { render, fireEvent, waitFor } from '@testing-library/react-native'; import { useDispatch, useSelector } from 'react-redux'; import { Alert } from 'react-native'; import RewardsDashboard from './RewardsDashboard'; -import { setActiveTab } from '../../../../actions/rewards'; import Routes from '../../../../constants/navigation/Routes'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; @@ -44,7 +43,6 @@ jest.mock('@react-navigation/native', () => { jest.mock('../../../../reducers/rewards/selectors', () => ({ selectActiveTab: jest.fn(), selectSeasonId: jest.fn(), - selectSeasonEndDate: jest.fn(), selectOptinAllowedForGeo: jest.fn(), selectHideCurrentAccountNotOptedInBannerArray: jest.fn(), selectHideUnlinkedAccountsBanner: jest.fn(), @@ -55,7 +53,7 @@ jest.mock('../../../../selectors/rewards', () => ({ })); jest.mock('../../../../selectors/featureFlagController/rewards', () => ({ - selectSnapshotsRewardsEnabledFlag: jest.fn(), + selectCampaignsRewardsEnabledFlag: jest.fn(), })); jest.mock( @@ -68,14 +66,13 @@ jest.mock( import { selectActiveTab, selectSeasonId, - selectSeasonEndDate, selectOptinAllowedForGeo, selectHideUnlinkedAccountsBanner, selectHideCurrentAccountNotOptedInBannerArray, } from '../../../../reducers/rewards/selectors'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { selectSelectedAccountGroup } from '../../../../selectors/multichainAccounts/accountTreeController'; -import { selectSnapshotsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; +import { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; const mockSelectActiveTab = selectActiveTab as jest.MockedFunction< typeof selectActiveTab @@ -87,9 +84,6 @@ const mockSelectRewardsSubscriptionId = const mockSelectSeasonId = selectSeasonId as jest.MockedFunction< typeof selectSeasonId >; -const mockSelectSeasonEndDate = selectSeasonEndDate as jest.MockedFunction< - typeof selectSeasonEndDate ->; const mockSelectOptinAllowedForGeo = selectOptinAllowedForGeo as jest.MockedFunction< typeof selectOptinAllowedForGeo @@ -106,9 +100,9 @@ const mockSelectSelectedAccountGroup = selectSelectedAccountGroup as jest.MockedFunction< typeof selectSelectedAccountGroup >; -const mockSelectSnapshotsRewardsEnabledFlag = - selectSnapshotsRewardsEnabledFlag as jest.MockedFunction< - typeof selectSnapshotsRewardsEnabledFlag +const mockSelectCampaignsRewardsEnabledFlag = + selectCampaignsRewardsEnabledFlag as jest.MockedFunction< + typeof selectCampaignsRewardsEnabledFlag >; // Mock theme @@ -214,15 +208,15 @@ jest.mock('../../../Views/ErrorBoundary', () => ({ })); // Mock child components -jest.mock('../components/SeasonStatus/SeasonStatus', () => ({ +jest.mock('../components/Campaigns/CampaignsPreview', () => ({ __esModule: true, - default: function MockSeasonStatus() { + default: function MockCampaignsPreview() { const ReactActual = jest.requireActual('react'); const { View, Text } = jest.requireActual('react-native'); return ReactActual.createElement( View, - { testID: 'season-status' }, - ReactActual.createElement(Text, null, 'Season Status'), + { testID: 'campaigns-preview' }, + ReactActual.createElement(Text, null, 'Campaigns Preview'), ); }, })); @@ -255,19 +249,17 @@ jest.mock('../components/Tabs/RewardsOverview', () => ({ }, })); -jest.mock('../components/Tabs/RewardsSnapshots', () => ({ - __esModule: true, - default: function MockRewardsSnapshots({ tabLabel }: { tabLabel: string }) { - const ReactActual = jest.requireActual('react'); - const { View, Text } = jest.requireActual('react-native'); - - return ReactActual.createElement( - View, - { testID: 'rewards-snapshots-tab' }, - ReactActual.createElement(Text, null, tabLabel || 'Snapshots'), +jest.mock('../Views/CampaignsView', () => { + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + const MockCampaignsView = ({ tabLabel }: { tabLabel?: string }) => + ReactActual.createElement( + RN.View, + { testID: 'rewards-campaigns-tab' }, + ReactActual.createElement(RN.Text, null, tabLabel || 'Campaigns'), ); - }, -})); + return { __esModule: true, default: MockCampaignsView }; +}); jest.mock('../components/Tabs/RewardsActivity', () => ({ __esModule: true, @@ -564,20 +556,17 @@ describe('RewardsDashboard', () => { }; const currentSeasonId = '7c9fa360-8d4c-425a-8a3e-7e82e1d82179'; - const futureDate = new Date(Date.now() + 86400000).toISOString(); // Tomorrow - const pastDate = new Date(Date.now() - 86400000).toISOString(); // Yesterday const defaultSelectorValues = { - activeTab: 'overview' as const, + activeTab: 'campaigns' as const, subscriptionId: 'test-subscription-id', seasonId: currentSeasonId, - seasonEndDate: new Date(futureDate), // Season is active by default optinAllowedForGeo: false as boolean | null, hideUnlinkedAccountsBanner: false, hideCurrentAccountNotOptedInBannerArray: [], selectedAccount: mockSelectedAccount, selectedAccountGroup: mockSelectedAccountGroup, - isSnapshotsEnabled: true, // Enable snapshots by default in tests + isCampaignsEnabled: true, }; const defaultHookValues = { @@ -657,9 +646,6 @@ describe('RewardsDashboard', () => { defaultSelectorValues.subscriptionId, ); mockSelectSeasonId.mockReturnValue(defaultSelectorValues.seasonId); - mockSelectSeasonEndDate.mockReturnValue( - defaultSelectorValues.seasonEndDate, - ); mockSelectHideUnlinkedAccountsBanner.mockReturnValue( defaultSelectorValues.hideUnlinkedAccountsBanner, ); @@ -670,8 +656,8 @@ describe('RewardsDashboard', () => { mockSelectSelectedAccountGroup.mockReturnValue( defaultSelectorValues.selectedAccountGroup, ); - mockSelectSnapshotsRewardsEnabledFlag.mockReturnValue( - defaultSelectorValues.isSnapshotsEnabled, + mockSelectCampaignsRewardsEnabledFlag.mockReturnValue( + defaultSelectorValues.isCampaignsEnabled, ); mockSelectOptinAllowedForGeo.mockReturnValue( defaultSelectorValues.optinAllowedForGeo, @@ -698,8 +684,6 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectOptinAllowedForGeo) return defaultSelectorValues.optinAllowedForGeo; if (selector === selectHideUnlinkedAccountsBanner) @@ -708,8 +692,8 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); }); @@ -727,11 +711,11 @@ describe('RewardsDashboard', () => { // Act const { getByTestId } = render(); - // Assert - season content with tabs shown by default (active season) - expect(getByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeTruthy(); - expect(getByTestId('season-status')).toBeTruthy(); + // Assert + expect(getByTestId(REWARDS_VIEW_SELECTORS.SAFE_AREA_VIEW)).toBeTruthy(); expect(getByTestId(REWARDS_VIEW_SELECTORS.REFERRAL_BUTTON)).toBeTruthy(); expect(getByTestId(REWARDS_VIEW_SELECTORS.SETTINGS_BUTTON)).toBeTruthy(); + expect(getByTestId('campaigns-preview')).toBeTruthy(); }); it('should call modal hooks when component is rendered', () => { @@ -742,16 +726,16 @@ describe('RewardsDashboard', () => { expect(mockUseRewardDashboardModals).toHaveBeenCalled(); }); - it('should render previous season summary when season has ended and geo not allowed', () => { - // Arrange - Season ended, geo not allowed → just PreviousSeasonSummary - const pastDateObj = new Date(pastDate); + it('should render previous season summary when campaigns disabled and geo not allowed', () => { + // Arrange - campaigns disabled, geo not allowed → just PreviousSeasonSummary + mockSelectSeasonId.mockReturnValue(currentSeasonId); + mockSelectCampaignsRewardsEnabledFlag.mockReturnValue(false); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return pastDateObj; if (selector === selectOptinAllowedForGeo) return false; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; @@ -759,8 +743,7 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) return false; return undefined; }); @@ -775,16 +758,14 @@ describe('RewardsDashboard', () => { expect(queryByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeNull(); }); - it('should render mUSD and previous season tabs when season ended and geo allowed', () => { - // Arrange - Season ended + geo allowed → two-tab layout - const pastDateObj = new Date(pastDate); + it('should not render previous season summary when campaigns enabled', () => { + // isCampaignsEnabled is true (default) so showPreviousSeasonSummary is false mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return pastDateObj; if (selector === selectOptinAllowedForGeo) return true; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; @@ -792,72 +773,40 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); // Act - const { getByTestId } = render(); - - // Assert - TabsList with mUSD calculator and Previous Season Summary - expect(getByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeTruthy(); - expect(getByTestId('musd-calculator-tab')).toBeTruthy(); - }); - - it('should render season status and tabs when season is active', () => { - // Act - defaults have active season (future end date) - const { getByTestId, queryByTestId } = render(); + const { queryByTestId } = render(); - // Assert - SeasonStatus + overview/snapshots/activity tabs - expect(getByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeTruthy(); - expect(getByTestId('season-status')).toBeTruthy(); + // Assert - no previous season summary or tabs when season is active expect( queryByTestId(REWARDS_VIEW_SELECTORS.PREVIOUS_SEASON_SUMMARY), ).toBeNull(); + expect(queryByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeNull(); }); - it('should not render previous season summary when seasonId is null', () => { - // Arrange - mockUseSelector.mockImplementation((selector) => { - if (selector === selectActiveTab) - return defaultSelectorValues.activeTab; - if (selector === selectRewardsSubscriptionId) - return defaultSelectorValues.subscriptionId; - if (selector === selectSeasonId) return null; - if (selector === selectSeasonEndDate) return new Date(pastDate); - if (selector === selectOptinAllowedForGeo) - return defaultSelectorValues.optinAllowedForGeo; - if (selector === selectHideUnlinkedAccountsBanner) - return defaultSelectorValues.hideUnlinkedAccountsBanner; - if (selector === selectHideCurrentAccountNotOptedInBannerArray) - return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; - if (selector === selectSelectedAccountGroup) - return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; - return undefined; - }); - - // Act + it('should render campaigns preview and referral button when campaigns enabled', () => { + // Act - defaults have campaigns enabled const { getByTestId, queryByTestId } = render(); - // Assert - shows season content, not previous season summary - expect(getByTestId('season-status')).toBeTruthy(); + // Assert + expect(getByTestId(REWARDS_VIEW_SELECTORS.REFERRAL_BUTTON)).toBeTruthy(); expect( queryByTestId(REWARDS_VIEW_SELECTORS.PREVIOUS_SEASON_SUMMARY), ).toBeNull(); }); - it('should not render previous season summary when seasonEndDate is null', () => { + it('should not render previous season summary when seasonId is null', () => { // Arrange mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; - if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return null; + if (selector === selectSeasonId) return null; if (selector === selectOptinAllowedForGeo) return defaultSelectorValues.optinAllowedForGeo; if (selector === selectHideUnlinkedAccountsBanner) @@ -866,16 +815,15 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); // Act - const { getByTestId, queryByTestId } = render(); + const { queryByTestId } = render(); - // Assert - shows season content, not previous season summary - expect(getByTestId('season-status')).toBeTruthy(); + // Assert expect( queryByTestId(REWARDS_VIEW_SELECTORS.PREVIOUS_SEASON_SUMMARY), ).toBeNull(); @@ -883,16 +831,14 @@ describe('RewardsDashboard', () => { }); describe('optinAllowedForGeo-based content', () => { - it('shows mUSD calculator tab when previous season and geo allowed', () => { - // Arrange - season ended + geo allowed - const pastDateObj = new Date(pastDate); + it('shows mUSD calculator tab when campaigns disabled and geo allowed', () => { + // Arrange - campaigns disabled + geo allowed mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return pastDateObj; if (selector === selectOptinAllowedForGeo) return true; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; @@ -900,8 +846,7 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) return false; return undefined; }); @@ -913,16 +858,14 @@ describe('RewardsDashboard', () => { expect(getByTestId('musd-calculator-tab')).toBeTruthy(); }); - it('hides mUSD calculator when previous season but geo not allowed', () => { - // Arrange - season ended + geo NOT allowed - const pastDateObj = new Date(pastDate); + it('hides mUSD calculator when campaigns disabled but geo not allowed', () => { + // Arrange - campaigns disabled + geo NOT allowed mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return pastDateObj; if (selector === selectOptinAllowedForGeo) return false; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; @@ -930,8 +873,7 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) return false; return undefined; }); @@ -946,13 +888,13 @@ describe('RewardsDashboard', () => { expect(queryByTestId('musd-calculator-tab')).toBeNull(); }); - it('shows season content when season is active regardless of geo', () => { - // Act - defaults have active season - const { getByTestId, queryByTestId } = render(); + it('shows campaigns preview when season is active regardless of geo', () => { + // Act - defaults have active season with campaigns enabled + const { queryByTestId, getByTestId } = render(); - // Assert - SeasonStatus + overview tabs, no mUSD calculator - expect(getByTestId('season-status')).toBeTruthy(); - expect(getByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeTruthy(); + // Assert - CampaignsPreview shown, no previous season content + expect(getByTestId('campaigns-preview')).toBeTruthy(); + expect(queryByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeNull(); expect(queryByTestId('musd-calculator-tab')).toBeNull(); }); }); @@ -1011,77 +953,47 @@ describe('RewardsDashboard', () => { }); }); - describe('tab functionality', () => { - it('should handle tab change when user selects different tab', () => { - // Act - defaults show overview/snapshots/activity tabs - const { getByTestId } = render(); - const snapshotsTab = getByTestId('tab-1'); - fireEvent.press(snapshotsTab); - - // Assert - dispatches setActiveTab with 'snapshots' - expect(mockDispatch).toHaveBeenCalledWith(setActiveTab('snapshots')); - }); - - it('should render all tab options', () => { - // Act - const { getByTestId, queryByTestId } = render(); - - // Assert - 3 tabs: overview, snapshots, activity - expect(getByTestId('tab-headers')).toBeTruthy(); - expect(getByTestId('tab-0')).toBeTruthy(); - expect(getByTestId('tab-1')).toBeTruthy(); - expect(getByTestId('tab-2')).toBeTruthy(); - expect(queryByTestId('tab-3')).toBeNull(); - }); - - it('should show overview tab content by default', () => { - // Act - const { getByTestId } = render(); - - // Assert - overview tab is default - expect(getByTestId('rewards-overview-tab')).toBeTruthy(); - }); - - it('resets activeTab to overview when current tab becomes unavailable', () => { - // Arrange - set activeTab to a value not in tabOptions + describe('when isCampaignsEnabled is false', () => { + beforeEach(() => { + mockSelectCampaignsRewardsEnabledFlag.mockReturnValue(false); + mockSelectActiveTab.mockReturnValue('overview'); mockUseSelector.mockImplementation((selector) => { - if (selector === selectActiveTab) return 'nonexistent'; + if (selector === selectActiveTab) return 'overview'; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; - if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; - if (selector === selectOptinAllowedForGeo) - return defaultSelectorValues.optinAllowedForGeo; + if (selector === selectSeasonId) return currentSeasonId; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) return false; return undefined; }); + }); + it('does not render CampaignsPreview when campaigns is disabled', () => { // Act - render(); + const { queryByTestId } = render(); // Assert - expect(mockDispatch).toHaveBeenCalledWith(setActiveTab('overview')); + expect(queryByTestId('campaigns-preview')).toBeNull(); }); }); describe('previous season summary', () => { - const setupPastSeasonMocks = (optinAllowed: boolean | null = false) => { - const pastDateObj = new Date(pastDate); + const setupCampaignsDisabledMocks = ( + optinAllowed: boolean | null = false, + ) => { + mockSelectSeasonId.mockReturnValue(currentSeasonId); + mockSelectCampaignsRewardsEnabledFlag.mockReturnValue(false); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return pastDateObj; if (selector === selectOptinAllowedForGeo) return optinAllowed; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; @@ -1089,14 +1001,13 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) return false; return undefined; }); }; - it('should show PreviousSeasonSummary when season ended and geo not allowed', () => { - setupPastSeasonMocks(false); + it('should show PreviousSeasonSummary when campaigns disabled and geo not allowed', () => { + setupCampaignsDisabledMocks(false); const { getByTestId, queryByTestId } = render(); @@ -1106,8 +1017,8 @@ describe('RewardsDashboard', () => { expect(queryByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeNull(); }); - it('should show two-tab layout when season ended and geo allowed', () => { - setupPastSeasonMocks(true); + it('should show two-tab layout when campaigns disabled and geo allowed', () => { + setupCampaignsDisabledMocks(true); const { getByTestId } = render(); @@ -1115,8 +1026,27 @@ describe('RewardsDashboard', () => { expect(getByTestId('musd-calculator-tab')).toBeTruthy(); }); - it('should not show previous season summary when season is active', () => { - // Defaults have active season (future end date) + it('should not render previous season summary when campaigns enabled', () => { + // isCampaignsEnabled is true (default) so showPreviousSeasonSummary is false + mockSelectSeasonId.mockReturnValue(currentSeasonId); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectActiveTab) + return defaultSelectorValues.activeTab; + if (selector === selectRewardsSubscriptionId) + return defaultSelectorValues.subscriptionId; + if (selector === selectSeasonId) return currentSeasonId; + if (selector === selectHideUnlinkedAccountsBanner) + return defaultSelectorValues.hideUnlinkedAccountsBanner; + if (selector === selectHideCurrentAccountNotOptedInBannerArray) + return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; + if (selector === selectSelectedAccountGroup) + return defaultSelectorValues.selectedAccountGroup; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; + return undefined; + }); + + // Act const { queryByTestId } = render(); expect( @@ -1125,7 +1055,24 @@ describe('RewardsDashboard', () => { }); it('should hide referral button when showing previous season summary', () => { - setupPastSeasonMocks(false); + // Arrange + mockSelectSeasonId.mockReturnValue(currentSeasonId); + mockSelectCampaignsRewardsEnabledFlag.mockReturnValue(false); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectActiveTab) + return defaultSelectorValues.activeTab; + if (selector === selectRewardsSubscriptionId) + return defaultSelectorValues.subscriptionId; + if (selector === selectSeasonId) return currentSeasonId; + if (selector === selectHideUnlinkedAccountsBanner) + return defaultSelectorValues.hideUnlinkedAccountsBanner; + if (selector === selectHideCurrentAccountNotOptedInBannerArray) + return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; + if (selector === selectSelectedAccountGroup) + return defaultSelectorValues.selectedAccountGroup; + if (selector === selectCampaignsRewardsEnabledFlag) return false; + return undefined; + }); const { queryByTestId } = render(); @@ -1133,7 +1080,25 @@ describe('RewardsDashboard', () => { }); it('should show settings button when showing previous season summary', () => { - setupPastSeasonMocks(false); + setupCampaignsDisabledMocks(false); + // Arrange + mockSelectSeasonId.mockReturnValue(currentSeasonId); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectActiveTab) + return defaultSelectorValues.activeTab; + if (selector === selectRewardsSubscriptionId) + return defaultSelectorValues.subscriptionId; + if (selector === selectSeasonId) return currentSeasonId; + if (selector === selectHideUnlinkedAccountsBanner) + return defaultSelectorValues.hideUnlinkedAccountsBanner; + if (selector === selectHideCurrentAccountNotOptedInBannerArray) + return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; + if (selector === selectSelectedAccountGroup) + return defaultSelectorValues.selectedAccountGroup; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; + return undefined; + }); const { getByTestId } = render(); @@ -1143,18 +1108,16 @@ describe('RewardsDashboard', () => { describe('button states when not opted in', () => { beforeEach(() => { - const futureDateObj = new Date(futureDate); mockSelectRewardsSubscriptionId.mockReturnValue(null); mockSelectSeasonId.mockReturnValue(currentSeasonId); - mockSelectSeasonEndDate.mockReturnValue(futureDateObj); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return null; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectOptinAllowedForGeo) return defaultSelectorValues.optinAllowedForGeo; + if (selector === selectCampaignsRewardsEnabledFlag) return true; return undefined; }); }); @@ -1215,8 +1178,6 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectOptinAllowedForGeo) return defaultSelectorValues.optinAllowedForGeo; return undefined; @@ -1230,25 +1191,22 @@ describe('RewardsDashboard', () => { describe('modal triggering for current account', () => { it('should show not opted in modal when account group has opted out accounts and modal has not been shown', async () => { // Arrange - Mock account group with opted out accounts - // Use future date so showPreviousSeasonSummary is false (season is active) + // isCampaignsEnabled is true so showPreviousSeasonSummary is false // Note: The modal effect only runs when showPreviousSeasonSummary is false - const futureDateObj = new Date(futureDate); - mockSelectSeasonEndDate.mockReturnValue(futureDateObj); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1292,24 +1250,21 @@ describe('RewardsDashboard', () => { }); it('should show not supported modal when account group is not fully supported and modal has not been shown', async () => { - // Arrange - Use future date so showPreviousSeasonSummary is false (season is active) - const futureDateObj = new Date(futureDate); - mockSelectSeasonEndDate.mockReturnValue(futureDateObj); + // Arrange - isCampaignsEnabled is true so showPreviousSeasonSummary is false mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1337,16 +1292,14 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1438,24 +1391,21 @@ describe('RewardsDashboard', () => { it('should show unlinked accounts modal when there are unlinked accounts and user has subscription', async () => { // Arrange - Mock account group as fully opted in and has unlinked accounts - // Use future date so showPreviousSeasonSummary is false (season is active) - const futureDateObj = new Date(futureDate); - mockSelectSeasonEndDate.mockReturnValue(futureDateObj); + // isCampaignsEnabled is true so showPreviousSeasonSummary is false mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1523,15 +1473,13 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectHideUnlinkedAccountsBanner) return true; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1544,26 +1492,23 @@ describe('RewardsDashboard', () => { it('should not show unlinked accounts modal when modal has already been shown', () => { // Arrange - setup mock to return true for unlinked accounts modal - const futureDateObj = new Date(futureDate); mockHasShownModal.mockImplementation( (modalType) => modalType === 'unlinked-accounts', ); - mockSelectSeasonEndDate.mockReturnValue(futureDateObj); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1578,11 +1523,10 @@ describe('RewardsDashboard', () => { describe('modal prioritization', () => { it('should show unlinked accounts modal when current account banner dismissed and account group is fully opted in', async () => { // Arrange - Mock account group as fully opted in and banner dismissed - // Use future date so showPreviousSeasonSummary is false (season is active) + // isCampaignsEnabled is true so showPreviousSeasonSummary is false mockSelectHideCurrentAccountNotOptedInBannerArray.mockReturnValue([ { accountGroupId: 'keyring:wallet1/1' as const, hide: true }, ]); - mockSelectSeasonEndDate.mockReturnValue(new Date(futureDate)); const mockWalletWithOptedOutAccounts = [ { @@ -1630,22 +1574,20 @@ describe('RewardsDashboard', () => { currentAccountGroupPartiallySupported: true, }); - const futureDateObj = new Date(futureDate); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return [{ accountGroupId: 'keyring:wallet1/1', hide: true }]; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1660,24 +1602,21 @@ describe('RewardsDashboard', () => { }); it('should prioritize not supported modal over other modals', async () => { - // Arrange - Use future date so showPreviousSeasonSummary is false (season is active) - const futureDateObj = new Date(futureDate); - mockSelectSeasonEndDate.mockReturnValue(futureDateObj); + // Arrange - isCampaignsEnabled is true so showPreviousSeasonSummary is false mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1742,23 +1681,20 @@ describe('RewardsDashboard', () => { describe('account group opt-in status logic', () => { it('should not show modal when account group is fully opted in', () => { // Arrange - Mock account group with all accounts opted in - const futureDateObj = new Date(futureDate); - mockSelectSeasonEndDate.mockReturnValue(futureDateObj); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1816,23 +1752,21 @@ describe('RewardsDashboard', () => { it('should show not supported modal when account group contains unsupported accounts', async () => { // Arrange - Mock account group with unsupported accounts - // Use future date so showPreviousSeasonSummary is false (season is active) - mockSelectSeasonEndDate.mockReturnValue(new Date(futureDate)); + // isCampaignsEnabled is true so showPreviousSeasonSummary is false mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return new Date(futureDate); if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1861,8 +1795,6 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) @@ -1896,8 +1828,6 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) @@ -1957,8 +1887,6 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) @@ -1993,16 +1921,14 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return null; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -2047,25 +1973,22 @@ describe('RewardsDashboard', () => { }); it('should return early and not show modals when showPreviousSeasonSummary is true', () => { - // Arrange - Set past date so showPreviousSeasonSummary is true (season has ended) - const pastDateObj = new Date(pastDate); + // Arrange - isCampaignsEnabled must be false for showPreviousSeasonSummary to be true mockSelectSeasonId.mockReturnValue(currentSeasonId); - mockSelectSeasonEndDate.mockReturnValue(pastDateObj); + mockSelectCampaignsRewardsEnabledFlag.mockReturnValue(false); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return pastDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) return false; return undefined; }); @@ -2110,25 +2033,23 @@ describe('RewardsDashboard', () => { }); it('should return early and not show modals when showPreviousSeasonSummary is null', () => { - // Arrange - Set seasonId and seasonEndDate to null so showPreviousSeasonSummary is null + // Arrange - Set seasonId to null so showPreviousSeasonSummary is null // This tests the case where the useFocusEffect hasn't evaluated yet mockSelectSeasonId.mockReturnValue(null); - mockSelectSeasonEndDate.mockReturnValue(null); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return null; - if (selector === selectSeasonEndDate) return null; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -2224,15 +2145,13 @@ describe('RewardsDashboard', () => { mockCreateEventBuilder.mockClear(); mockBuild.mockClear(); - // Act - change active tab - mockSelectActiveTab.mockReturnValue('snapshots'); + // Act - change active tab from campaigns to activity + mockSelectActiveTab.mockReturnValue('activity'); mockUseSelector.mockImplementation((selector) => { - if (selector === selectActiveTab) return 'snapshots'; + if (selector === selectActiveTab) return 'activity'; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectOptinAllowedForGeo) return defaultSelectorValues.optinAllowedForGeo; if (selector === selectHideUnlinkedAccountsBanner) @@ -2241,8 +2160,8 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); rerender(); @@ -2251,7 +2170,7 @@ describe('RewardsDashboard', () => { expect(mockCreateEventBuilder).toHaveBeenCalledWith( 'rewards_dashboard_tab_viewed', ); - expect(mockAddProperties).toHaveBeenCalledWith({ tab: 'snapshots' }); + expect(mockAddProperties).toHaveBeenCalledWith({ tab: 'activity' }); expect(mockBuild).toHaveBeenCalled(); expect(mockTrackEvent).toHaveBeenCalledWith({ event: 'mock-event' }); }); @@ -2264,15 +2183,13 @@ describe('RewardsDashboard', () => { mockBuild.mockClear(); mockAddProperties.mockClear(); - // Act - change to snapshots tab - mockSelectActiveTab.mockReturnValue('snapshots'); + // Act - change to activity tab + mockSelectActiveTab.mockReturnValue('activity'); mockUseSelector.mockImplementation((selector) => { - if (selector === selectActiveTab) return 'snapshots'; + if (selector === selectActiveTab) return 'activity'; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectOptinAllowedForGeo) return defaultSelectorValues.optinAllowedForGeo; if (selector === selectHideUnlinkedAccountsBanner) @@ -2281,24 +2198,22 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); rerender(); - // Assert - snapshots tab - expect(mockAddProperties).toHaveBeenCalledWith({ tab: 'snapshots' }); + // Assert - activity tab + expect(mockAddProperties).toHaveBeenCalledWith({ tab: 'activity' }); - // Act - change to activity tab - mockSelectActiveTab.mockReturnValue('activity'); + // Act - change back to campaigns tab + mockSelectActiveTab.mockReturnValue('campaigns'); mockUseSelector.mockImplementation((selector) => { - if (selector === selectActiveTab) return 'activity'; + if (selector === selectActiveTab) return 'campaigns'; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectOptinAllowedForGeo) return defaultSelectorValues.optinAllowedForGeo; if (selector === selectHideUnlinkedAccountsBanner) @@ -2307,41 +2222,38 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); rerender(); - // Assert - activity tab - expect(mockAddProperties).toHaveBeenCalledWith({ tab: 'activity' }); + // Assert - campaigns tab + expect(mockAddProperties).toHaveBeenCalledWith({ tab: 'campaigns' }); }); }); describe('TabsList ref functionality', () => { it('handles Redux state changes for activeTab without crashing', () => { // Arrange + mockSelectActiveTab.mockReturnValue('campaigns'); const { rerender } = render(); - // Act - change activeTab in Redux to snapshots - mockSelectActiveTab.mockReturnValue('snapshots'); + // Act - change activeTab in Redux to campaigns + mockSelectActiveTab.mockReturnValue('campaigns'); mockUseSelector.mockImplementation((selector) => { - if (selector === selectActiveTab) return 'snapshots'; + if (selector === selectActiveTab) return 'campaigns'; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; - if (selector === selectOptinAllowedForGeo) - return defaultSelectorValues.optinAllowedForGeo; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx index e3763fc333a..4e8d039b34d 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx @@ -9,34 +9,27 @@ import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { Box, IconName } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { strings } from '../../../../../locales/i18n'; import HeaderRoot from '../../../../component-library/components-temp/HeaderRoot'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; -import { setActiveTab } from '../../../../actions/rewards'; import Routes from '../../../../constants/navigation/Routes'; -import { RewardsTab } from '../../../../reducers/rewards/types'; import { selectActiveTab, selectHideUnlinkedAccountsBanner, selectHideCurrentAccountNotOptedInBannerArray, selectSeasonId, - selectSeasonEndDate, selectOptinAllowedForGeo, } from '../../../../reducers/rewards/selectors'; -import SeasonStatus from '../components/SeasonStatus/SeasonStatus'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { selectSnapshotsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; +import { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; import { useRewardOptinSummary } from '../hooks/useRewardOptinSummary'; import { useRewardDashboardModals, RewardsDashboardModalType, } from '../hooks/useRewardDashboardModals'; import { useBulkLinkState } from '../hooks/useBulkLinkState'; -import RewardsOverview from '../components/Tabs/RewardsOverview'; -import RewardsSnapshots from '../components/Tabs/RewardsSnapshots'; -import RewardsActivity from '../components/Tabs/RewardsActivity'; import MusdCalculatorTab from '../components/Tabs/MusdCalculatorTab/MusdCalculatorTab'; import { TabsList } from '../../../../component-library/components-temp/Tabs'; import { @@ -48,6 +41,7 @@ import { ToastRef } from '../../../../component-library/components/Toast/Toast.t import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; import { selectSelectedAccountGroup } from '../../../../selectors/multichainAccounts/accountTreeController'; import PreviousSeasonSummary from '../components/PreviousSeason/PreviousSeasonSummary'; +import CampaignsPreview from '../components/Campaigns/CampaignsPreview'; const RewardsDashboard: React.FC = () => { const tw = useTailwind(); @@ -55,16 +49,14 @@ const RewardsDashboard: React.FC = () => { const toastRef = useRef(null); const subscriptionId = useSelector(selectRewardsSubscriptionId); const activeTab = useSelector(selectActiveTab); - const dispatch = useDispatch(); const { trackEvent, createEventBuilder } = useMetrics(); const hasTrackedDashboardViewed = useRef(false); const hideUnlinkedAccountsBanner = useSelector( selectHideUnlinkedAccountsBanner, ); const seasonId = useSelector(selectSeasonId); - const seasonEndDate = useSelector(selectSeasonEndDate); const optinAllowedForGeo = useSelector(selectOptinAllowedForGeo); - const isSnapshotsEnabled = useSelector(selectSnapshotsRewardsEnabledFlag); + const isCampaignsEnabled = useSelector(selectCampaignsRewardsEnabledFlag); const hideCurrentAccountNotOptedInBannerMap = useSelector( selectHideCurrentAccountNotOptedInBannerArray, ); @@ -83,8 +75,6 @@ const RewardsDashboard: React.FC = () => { const [showPreviousSeasonSummary, setShowPreviousSeasonSummary] = useState< boolean | null >(null); - - // Ref for TabsList to control active tab programmatically const tabsListRef = useRef(null); // Use the reward dashboard modals hook @@ -125,111 +115,6 @@ const RewardsDashboard: React.FC = () => { [optInByWallet], ); - const tabOptions = useMemo(() => { - const options: { - value: 'overview' | 'snapshots' | 'activity'; - label: string; - }[] = [ - { - value: 'overview' as const, - label: strings('rewards.tab_overview_title'), - }, - ]; - - if (isSnapshotsEnabled) { - options.push({ - value: 'snapshots' as const, - label: strings('rewards.tab_snapshots_title'), - }); - } - - options.push({ - value: 'activity' as const, - label: strings('rewards.tab_activity_title'), - }); - - return options; - }, [isSnapshotsEnabled]); - - const getActiveIndex = useCallback( - () => tabOptions.findIndex((tab) => tab.value === activeTab), - [tabOptions, activeTab], - ); - - // Reset activeTab to 'overview' if current tab becomes unavailable (e.g., snapshots disabled) - // This ensures Redux state stays in sync with the visible tab and analytics events are accurate - useEffect(() => { - const isCurrentTabAvailable = tabOptions.some( - (tab) => tab.value === activeTab, - ); - if (!isCurrentTabAvailable) { - dispatch(setActiveTab('overview')); - } - }, [tabOptions, activeTab, dispatch]); - - // Sync TabsList with Redux state changes - useEffect(() => { - const activeIndex = tabOptions.findIndex((tab) => tab.value === activeTab); - if (tabsListRef.current && activeIndex !== -1) { - // Use setTimeout to avoid race conditions with TabsList internal state - if (tabsListRef.current) { - tabsListRef.current.goToTabIndex(activeIndex); - } - } - }, [activeTab, tabOptions]); - - const handleTabChange = useCallback( - ({ i }: { i: number }) => { - const newTab = tabOptions[i]?.value as RewardsTab; - // Only dispatch if the tab is actually different to prevent loops - if (newTab && newTab !== activeTab) { - dispatch(setActiveTab(newTab)); - } - }, - [dispatch, tabOptions, activeTab], - ); - - const tabsListProps = useMemo( - () => ({ - ref: tabsListRef, - initialActiveIndex: getActiveIndex(), - onChangeTab: handleTabChange, - testID: REWARDS_VIEW_SELECTORS.TAB_CONTROL, - tabsBarProps: { - twClassName: 'px-4', - }, - tabsListContentTwClassName: 'px-0', - }), - [getActiveIndex, handleTabChange], - ); - - const tabComponents = useMemo(() => { - const tabs: React.ReactElement[] = [ - , - ]; - - if (isSnapshotsEnabled) { - tabs.push( - , - ); - } - - tabs.push( - , - ); - - return tabs; - }, [isSnapshotsEnabled]); - // Auto-resume interrupted bulk link process when screen comes into focus. // This handles the case where the app was closed during a bulk opt-in process. // The saga is idempotent - it re-fetches opt-in status to skip already-linked accounts. @@ -244,13 +129,9 @@ const RewardsDashboard: React.FC = () => { // Evaluate showPreviousSeasonSummary when screen comes into focus useFocusEffect( useCallback(() => { - const shouldShow = Boolean( - seasonId && - seasonEndDate && - new Date(seasonEndDate).getTime() < Date.now(), - ); + const shouldShow = Boolean(seasonId && !isCampaignsEnabled); setShowPreviousSeasonSummary(shouldShow); - }, [seasonId, seasonEndDate]), + }, [seasonId, isCampaignsEnabled]), ); // Auto-trigger dashboard modals based on account/rewards state (session-aware) @@ -364,39 +245,36 @@ const RewardsDashboard: React.FC = () => { ]} /> - {showPreviousSeasonSummary && optinAllowedForGeo ? ( - - - - - } + {showPreviousSeasonSummary && + (optinAllowedForGeo ? ( + - - - - ) : showPreviousSeasonSummary ? ( - - ) : ( - <> - - - - {tabComponents} - - )} + + + + + + + + ) : ( + + ))} diff --git a/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx b/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx index b82bd22f49a..8ddf1119249 100644 --- a/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx @@ -1,76 +1,76 @@ import React from 'react'; -import { render, waitFor } from '@testing-library/react-native'; -import { useSelector } from 'react-redux'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; import RewardsReferralView from './RewardsReferralView'; -// Mock react-redux -jest.mock('react-redux', () => ({ - useSelector: jest.fn(), +const mockGoBack = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack }), })); -const mockUseSelector = useSelector as jest.MockedFunction; - -// Mock selectors -jest.mock('../../../../selectors/rewards', () => ({ - selectRewardsSubscriptionId: jest.fn(), +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), })); -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); -// Mock navigation -const mockNavigate = jest.fn(); -const mockSetOptions = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn().mockReturnValue({ + build: jest.fn().mockReturnValue({ event: 'REWARDS_REFERRALS_VIEWED' }), +}); -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ - navigate: mockNavigate, - setOptions: mockSetOptions, +jest.mock('../../../hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, }), + MetaMetricsEvents: { + REWARDS_REFERRALS_VIEWED: 'REWARDS_REFERRALS_VIEWED', + }, })); -// Mock theme -jest.mock('../../../../util/theme', () => { - const { mockTheme } = jest.requireActual('../../../../util/theme'); - return { - useTheme: () => mockTheme, - }; -}); - -// Mock i18n jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => { + strings: (key: string) => { const translations: Record = { - 'rewards.referral_title': 'Referral Program', + 'rewards.referral_title': 'Referrals', }; return translations[key] || key; - }), -})); - -// Mock getNavigationOptionsTitle -jest.mock('../../Navbar', () => ({ - getNavigationOptionsTitle: jest.fn(() => ({ title: 'Referral Program' })), + }, })); -// Import the mock -import { getNavigationOptionsTitle } from '../../Navbar'; -const mockGetNavigationOptionsTitle = - getNavigationOptionsTitle as jest.MockedFunction< - typeof getNavigationOptionsTitle - >; - -// Import hook mocks - useSeasonStatus removed from component +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', + }), + ), + }; + }, +); -// Mock ErrorBoundary jest.mock('../../../Views/ErrorBoundary', () => ({ __esModule: true, - default: function MockErrorBoundary({ + default: ({ children, view, }: { children: React.ReactNode; navigation: unknown; view: string; - }) { + }) => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return ReactActual.createElement( @@ -81,188 +81,113 @@ jest.mock('../../../Views/ErrorBoundary', () => ({ }, })); -// Mock hooks - useSeasonStatus hook removed from component - -// Mock ReferralDetails component -jest.mock('../components/ReferralDetails/ReferralDetails', () => ({ - __esModule: true, - default: function MockReferralDetails() { - const ReactActual = jest.requireActual('react'); - const { View, Text } = jest.requireActual('react-native'); - return ReactActual.createElement( - View, - { testID: 'referral-details' }, - ReactActual.createElement(Text, null, 'Referral Details Component'), - ); - }, -})); +jest.mock('../components/ReferralDetails/ReferralDetails', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement( + View, + { testID: 'referral-details' }, + ReactActual.createElement(Text, null, 'Referral Details Component'), + ), + }; +}); -// Mock SafeAreaView -jest.mock('react-native-safe-area-context', () => ({ - SafeAreaView: ({ children, ...props }: { children: React.ReactNode }) => { - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return ReactActual.createElement( - View, - { ...props, testID: 'safe-area-view' }, - 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 }) => + ReactActual.createElement( + View, + { ...props, testID: 'safe-area-view' }, + children, + ), + }; +}); describe('RewardsReferralView', () => { beforeEach(() => { jest.clearAllMocks(); - - // Setup default useSelector mock return values - mockUseSelector.mockImplementation((selector) => { - if (selector === selectRewardsSubscriptionId) { - return 'test-subscription-id'; - } - return undefined; - }); - - // Setup default hook mock return values - useSeasonStatus removed }); describe('rendering', () => { - it('should render without crashing', () => { - // Act & Assert + it('renders without crashing', () => { expect(() => render()).not.toThrow(); }); - it('should render ReferralDetails component', () => { - // Act + it('renders the header with the referral title', () => { + const { getByText } = render(); + + expect(getByText('Referrals')).toBeOnTheScreen(); + }); + + it('renders the ReferralDetails component', () => { const { getByTestId, getByText } = render(); - // Assert - expect(getByTestId('referral-details')).toBeTruthy(); - expect(getByText('Referral Details Component')).toBeTruthy(); + expect(getByTestId('referral-details')).toBeOnTheScreen(); + expect(getByText('Referral Details Component')).toBeOnTheScreen(); }); - it('should wrap content in ErrorBoundary', () => { - // Act + it('wraps content in ErrorBoundary with correct view name', () => { const { getByTestId } = render(); - // Assert - expect(getByTestId('error-boundary-referralrewardsview')).toBeTruthy(); + expect( + getByTestId('error-boundary-referralrewardsview'), + ).toBeOnTheScreen(); }); }); describe('navigation', () => { - it('should set navigation options on mount', async () => { - // Act - render(); - - // Assert - await waitFor(() => { - expect(mockSetOptions).toHaveBeenCalledTimes(1); - }); - }); + it('navigates back when the back button is pressed', () => { + const { getByTestId } = render(); - it('should call getNavigationOptionsTitle with correct parameters', async () => { - // Act - render(); + fireEvent.press(getByTestId('header-back-button')); - // Assert - await waitFor(() => { - expect(mockGetNavigationOptionsTitle).toHaveBeenCalledWith( - 'Referral Program', - expect.anything(), // navigation object - false, // back button parameter - expect.anything(), // colors object - ); - }); + expect(mockGoBack).toHaveBeenCalledTimes(1); }); + }); - it('should set headerTitleAlign to center in navigation options', async () => { - // Act + describe('analytics', () => { + it('tracks REWARDS_REFERRALS_VIEWED event on mount', async () => { render(); - // Assert await waitFor(() => { - expect(mockSetOptions).toHaveBeenCalledWith( - expect.objectContaining({ - headerTitleAlign: 'center', - }), + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + 'REWARDS_REFERRALS_VIEWED', ); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); }); }); - it('should update navigation options when colors change', async () => { - // Act + it('tracks the event only once across re-renders', async () => { const { rerender } = render(); - // Clear previous calls - mockSetOptions.mockClear(); - mockGetNavigationOptionsTitle.mockClear(); - - // Trigger re-render to simulate color change + rerender(); rerender(); - // Assert await waitFor(() => { - expect(mockSetOptions).toHaveBeenCalled(); - expect(mockGetNavigationOptionsTitle).toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); }); }); }); - describe('error boundary integration', () => { - it('should pass correct view prop to ErrorBoundary', () => { - // Act - const { getByTestId } = render(); - - // Assert - expect(getByTestId('error-boundary-referralrewardsview')).toBeTruthy(); - }); - - it('should pass navigation prop to ErrorBoundary', () => { - // The navigation prop should be passed to ErrorBoundary - // This is verified through the mock implementation that receives the navigation prop - - // Act & Assert - expect(() => render()).not.toThrow(); - }); - }); - describe('component lifecycle', () => { - it('should cleanup properly when unmounted', () => { - // Act + it('cleans up properly when unmounted', () => { const { unmount } = render(); - // Assert expect(() => unmount()).not.toThrow(); }); - it('should handle multiple re-renders gracefully', () => { - // Act + it('handles multiple re-renders gracefully', () => { const { rerender } = render(); - // Assert - Multiple re-renders should not cause issues expect(() => { rerender(); rerender(); - rerender(); }).not.toThrow(); }); }); - - describe('integration with child components', () => { - it('should render ReferralDetails without any props', () => { - // Given that ReferralDetails manages its own state through hooks - // and Redux selectors, it should not receive any props from the parent - - // Act - const { getByTestId } = render(); - - // Assert - expect(getByTestId('referral-details')).toBeTruthy(); - }); - }); - - describe('hook integration', () => { - // useSeasonStatus hook was removed from the component - // No hook integration tests needed for this component - }); }); diff --git a/app/components/UI/Rewards/Views/RewardsReferralView.tsx b/app/components/UI/Rewards/Views/RewardsReferralView.tsx index 7421d16a794..79fdce9c9ba 100644 --- a/app/components/UI/Rewards/Views/RewardsReferralView.tsx +++ b/app/components/UI/Rewards/Views/RewardsReferralView.tsx @@ -1,18 +1,17 @@ import React, { useEffect, useRef } from 'react'; import { useNavigation } from '@react-navigation/native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { getNavigationOptionsTitle } from '../../Navbar'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { ScrollView } from 'react-native'; import { strings } from '../../../../../locales/i18n'; import ErrorBoundary from '../../../Views/ErrorBoundary'; -import { useTheme } from '../../../../util/theme'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import ReferralDetails from '../components/ReferralDetails/ReferralDetails'; -import { ScrollView } from 'react-native'; import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; const ReferralRewardsView: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); - const { colors } = useTheme(); const hasTrackedReferralsViewed = useRef(false); const { trackEvent, createEventBuilder } = useMetrics(); @@ -25,28 +24,25 @@ const ReferralRewardsView: React.FC = () => { } }, [trackEvent, createEventBuilder]); - // Set navigation title with back button - useEffect(() => { - navigation.setOptions({ - ...getNavigationOptionsTitle( - strings('rewards.referral_title'), - navigation, - false, - colors, - ), - headerTitleAlign: 'center', - }); - }, [colors, navigation]); - return ( - - - + navigation.goBack()} + backButtonProps={{ testID: 'header-back-button' }} + includesTopInset + /> + + + + ); }; diff --git a/app/components/UI/Rewards/Views/RewardsSettingsView.tsx b/app/components/UI/Rewards/Views/RewardsSettingsView.tsx index 1444aa4e10d..63fe2011bc0 100644 --- a/app/components/UI/Rewards/Views/RewardsSettingsView.tsx +++ b/app/components/UI/Rewards/Views/RewardsSettingsView.tsx @@ -44,7 +44,7 @@ const RewardsSettingsView: React.FC = () => { return ( @@ -52,7 +52,6 @@ const RewardsSettingsView: React.FC = () => { title={strings('rewards.settings.title')} onBack={() => navigation.goBack()} backButtonProps={{ testID: 'header-back-button' }} - includesTopInset /> {offDeviceAccounts.length > 0 && ( diff --git a/app/components/UI/Rewards/Views/RewardsView.constants.ts b/app/components/UI/Rewards/Views/RewardsView.constants.ts index f02fa232e30..898b92460c2 100644 --- a/app/components/UI/Rewards/Views/RewardsView.constants.ts +++ b/app/components/UI/Rewards/Views/RewardsView.constants.ts @@ -62,10 +62,13 @@ export const REWARDS_VIEW_SELECTORS = { ACTIVITY_EVENT_ROW_DETAILS: 'activity-event-row-details', ACTIVITY_EVENT_ROW_DATE: 'activity-event-row-date', ACTIVITY_EVENT_ROW_BONUS: 'activity-event-row-bonus', - // Snapshots - TAB_CONTENT_SNAPSHOTS: 'rewards-view-tab-content-snapshots', - SNAPSHOTS_SECTION: 'rewards-view-snapshots-section', - SNAPSHOTS_ACTIVE_SECTION: 'rewards-view-snapshots-active-section', - SNAPSHOTS_UPCOMING_SECTION: 'rewards-view-snapshots-upcoming-section', - SNAPSHOTS_PREVIOUS_SECTION: 'rewards-view-snapshots-previous-section', + // Campaigns + CAMPAIGNS_PREVIEW: 'rewards-view-campaigns-preview', + CAMPAIGNS_PREVIEW_ACTIVE_TILE: 'rewards-view-campaigns-preview-active-tile', + CAMPAIGNS_PREVIEW_UPCOMING_BANNER: + 'rewards-view-campaigns-preview-upcoming-banner', + CAMPAIGNS_VIEW: 'rewards-view-campaigns-view', + CAMPAIGNS_ACTIVE_SECTION: 'rewards-view-campaigns-active-section', + CAMPAIGNS_UPCOMING_SECTION: 'rewards-view-campaigns-upcoming-section', + CAMPAIGNS_PREVIOUS_SECTION: 'rewards-view-campaigns-previous-section', } as const; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.test.tsx new file mode 100644 index 00000000000..7a28eb08707 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.test.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import CampaignHowItWorks, { + CAMPAIGN_HOW_IT_WORKS_TEST_IDS, +} from './CampaignHowItWorks'; +import type { OndoCampaignHowItWorks } 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 { View } = jest.requireActual('react-native'); + return { + ...actual, + // Icon maps its name prop to an SVG component via enum lookup; mock it to + // avoid "type is invalid" errors when getIconName returns a plain string. + Icon: ({ testID }: { testID?: string }) => + ReactActual.createElement(View, { testID }), + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../utils/formatUtils', () => ({ + getIconName: (name: string) => name, +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaign_details.how_it_works': 'How it works', + }; + return translations[key] || key; + }, +})); + +const createHowItWorks = ( + overrides: Partial = {}, +): OndoCampaignHowItWorks => ({ + title: 'How it works', + description: 'Hold tokens to earn rewards', + phases: [ + { + name: 'Phase 1', + daysLabel: 'Days 1-30', + sortOrder: 1, + steps: [ + { + iconName: 'star', + title: 'Step 1', + description: 'Do step 1', + }, + ], + }, + ], + ...overrides, +}); + +describe('CampaignHowItWorks', () => { + it('renders the container', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId(CAMPAIGN_HOW_IT_WORKS_TEST_IDS.CONTAINER)).toBeDefined(); + }); + + it('renders the title', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId(CAMPAIGN_HOW_IT_WORKS_TEST_IDS.TITLE)).toHaveTextContent( + 'How it works', + ); + }); + + it('renders a phase chip with daysLabel', () => { + const { getByTestId } = render( + , + ); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE_CHIP}-0`), + ).toHaveTextContent('Days 1-30'); + }); + + it('renders a step title and description', () => { + const { getByTestId } = render( + , + ); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_TITLE}-0-0`), + ).toHaveTextContent('Step 1'); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_DESCRIPTION}-0-0`), + ).toHaveTextContent('Do step 1'); + }); + + it('sorts phases by sortOrder ascending', () => { + const howItWorks = createHowItWorks({ + phases: [ + { name: 'Phase B', daysLabel: 'Days 31-60', sortOrder: 2, steps: [] }, + { name: 'Phase A', daysLabel: 'Days 1-30', sortOrder: 1, steps: [] }, + ], + }); + const { getByTestId } = render( + , + ); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE_CHIP}-0`), + ).toHaveTextContent('Days 1-30'); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE_CHIP}-1`), + ).toHaveTextContent('Days 31-60'); + }); + + it('renders multiple phases', () => { + const howItWorks = createHowItWorks({ + phases: [ + { name: 'Phase 1', daysLabel: 'Days 1-30', sortOrder: 1, steps: [] }, + { name: 'Phase 2', daysLabel: 'Days 31-60', sortOrder: 2, steps: [] }, + ], + }); + const { getByTestId } = render( + , + ); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE}-0`), + ).toBeDefined(); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE}-1`), + ).toBeDefined(); + }); + + it('renders multiple steps in a phase', () => { + const howItWorks = createHowItWorks({ + phases: [ + { + name: 'Phase 1', + daysLabel: 'Days 1-30', + sortOrder: 1, + steps: [ + { iconName: 'star', title: 'Step A', description: 'Desc A' }, + { iconName: 'circle', title: 'Step B', description: 'Desc B' }, + ], + }, + ], + }); + const { getByTestId } = render( + , + ); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_TITLE}-0-0`), + ).toHaveTextContent('Step A'); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_TITLE}-0-1`), + ).toHaveTextContent('Step B'); + }); + + it('renders gracefully with no phases', () => { + const howItWorks = createHowItWorks({ phases: [] }); + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(CAMPAIGN_HOW_IT_WORKS_TEST_IDS.CONTAINER)).toBeDefined(); + expect( + queryByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE}-0`), + ).toBeNull(); + }); + + it('renders step icon for each step', () => { + const { getByTestId } = render( + , + ); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_ICON}-0-0`), + ).toBeDefined(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.tsx new file mode 100644 index 00000000000..bc68a1957fa --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.tsx @@ -0,0 +1,109 @@ +import React, { useMemo } from 'react'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + Text, + TextVariant, + Icon, + IconColor, + IconSize, + FontWeight, +} from '@metamask/design-system-react-native'; +import type { + OndoCampaignHowItWorks, + OndoCampaignPhase, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../../locales/i18n'; +import { getIconName } from '../../utils/formatUtils'; + +export const CAMPAIGN_HOW_IT_WORKS_TEST_IDS = { + CONTAINER: 'campaign-how-it-works-container', + TITLE: 'campaign-how-it-works-title', + PHASE: 'campaign-how-it-works-phase', + PHASE_CHIP: 'campaign-how-it-works-phase-chip', + STEP: 'campaign-how-it-works-step', + STEP_ICON: 'campaign-how-it-works-step-icon', + STEP_TITLE: 'campaign-how-it-works-step-title', + STEP_DESCRIPTION: 'campaign-how-it-works-step-description', +} as const; + +interface CampaignHowItWorksProps { + howItWorks: OndoCampaignHowItWorks; +} + +const CampaignHowItWorks: React.FC = ({ + howItWorks, +}) => { + const sortedPhases = useMemo( + () => [...howItWorks.phases].sort((a, b) => a.sortOrder - b.sortOrder), + [howItWorks.phases], + ); + + return ( + + + {strings('rewards.campaign_details.how_it_works')} + + + {sortedPhases.map((phase: OndoCampaignPhase, phaseIndex: number) => ( + + + + {phase.daysLabel} + + + + {phase.steps.map((step, stepIndex) => ( + + + + + + + {step.title} + + + {step.description} + + + + ))} + + ))} + + ); +}; + +export default CampaignHowItWorks; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.test.tsx new file mode 100644 index 00000000000..d2ecb939e59 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.test.tsx @@ -0,0 +1,350 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import type { Json } from '@metamask/utils'; +import CampaignOptInSheet from './CampaignOptInSheet'; +import { + type CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { useOptInToCampaign } from '../../hooks/useOptInToCampaign'; +import Routes from '../../../../../constants/navigation/Routes'; + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +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('../../hooks/useOptInToCampaign'); +const mockUseOptInToCampaign = useOptInToCampaign as jest.MockedFunction< + typeof useOptInToCampaign +>; + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + children, + testID, + }: { + children?: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, { testID }, children), + }; + }, +); + +jest.mock('../RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + description, + testID, + }: { + title: string; + description: string; + testID?: string; + }) => + ReactActual.createElement( + View, + { testID: testID ?? 'error-banner' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Text, null, description), + ), + }; +}); + +jest.mock('../ContentfulRichText/ContentfulRichText', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text: RNText } = jest.requireActual('react-native'); + const isDocumentFn = (value: unknown): boolean => + value !== null && + typeof value === 'object' && + 'nodeType' in (value as Record) && + (value as Record).nodeType === 'document' && + 'content' in (value as Record) && + Array.isArray((value as Record).content); + return { + __esModule: true, + isDocument: isDocumentFn, + default: ({ + document: doc, + testID, + }: { + document: unknown; + testID?: string; + }) => + ReactActual.createElement( + View, + { testID }, + ReactActual.createElement(RNText, null, JSON.stringify(doc)), + ), + }; +}); + +jest.mock('../Onboarding/constants', () => ({ + REWARDS_ONBOARD_TERMS_URL: 'https://go.metamask.io/rewards-terms', +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaign.opt_in_sheet_title': 'Join Campaign', + 'rewards.campaign.opt_in_sheet_description_pre_link': + 'By joining you agree to the', + 'rewards.campaign.opt_in_sheet_link_text': 'Terms', + 'rewards.campaign.opt_in_sheet_description_post_link': + 'You can opt out at any time.', + 'rewards.campaign_details.opt_in_error': 'Failed to join campaign', + 'rewards.campaign.opt_in_cta': 'Join', + }; + return translations[key] || key; + }, +})); + +const createTestCampaign = ( + overrides: Partial = {}, +): CampaignDto => ({ + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2027-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +const mockOptInToCampaign = jest.fn(); + +describe('CampaignOptInSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseOptInToCampaign.mockReturnValue({ + optInToCampaign: mockOptInToCampaign, + isOptingIn: false, + optInError: undefined, + clearOptInError: jest.fn(), + }); + }); + + it('renders the sheet title', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-title')).toHaveTextContent( + 'Join Campaign', + ); + }); + + it('renders the description container', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-description')).toBeDefined(); + }); + + it('renders the terms link with correct text', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-terms-link')).toHaveTextContent( + 'Terms', + ); + }); + + it('opens the terms URL in in-app browser when terms link is pressed', () => { + const { getByTestId } = render( + , + ); + fireEvent.press(getByTestId('campaign-opt-in-sheet-terms-link')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: expect.objectContaining({ + newTabUrl: 'https://go.metamask.io/rewards-terms', + }), + }); + }); + + it('renders the CTA button', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-cta')).toBeDefined(); + }); + + it('calls optInToCampaign with the campaign id when CTA is pressed', () => { + mockOptInToCampaign.mockResolvedValue({ optedIn: true }); + const { getByTestId } = render( + , + ); + fireEvent.press(getByTestId('campaign-opt-in-cta')); + expect(mockOptInToCampaign).toHaveBeenCalledWith('campaign-1'); + }); + + it('calls onClose after successful opt-in', async () => { + const onClose = jest.fn(); + mockOptInToCampaign.mockResolvedValue({ optedIn: true }); + const { getByTestId } = render( + , + ); + fireEvent.press(getByTestId('campaign-opt-in-cta')); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose when opt-in throws', async () => { + const onClose = jest.fn(); + mockOptInToCampaign.mockRejectedValue(new Error('API error')); + const { getByTestId } = render( + , + ); + fireEvent.press(getByTestId('campaign-opt-in-cta')); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('renders the close button', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-close')).toBeDefined(); + }); + + it('calls onClose when close button is pressed', () => { + const onClose = jest.fn(); + const { getByTestId } = render( + , + ); + fireEvent.press(getByTestId('campaign-opt-in-sheet-close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('shows error banner when optInError is set', () => { + mockUseOptInToCampaign.mockReturnValue({ + optInToCampaign: mockOptInToCampaign, + isOptingIn: false, + optInError: 'Something went wrong', + clearOptInError: jest.fn(), + }); + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-error-banner')).toBeDefined(); + }); + + it('does not show error banner when there is no error', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId('campaign-opt-in-error-banner')).toBeNull(); + }); + + it('shows loading state on the CTA while opting in', () => { + mockUseOptInToCampaign.mockReturnValue({ + optInToCampaign: mockOptInToCampaign, + isOptingIn: true, + optInError: undefined, + clearOptInError: jest.fn(), + }); + const { getByTestId } = render( + , + ); + // Button still renders while loading + expect(getByTestId('campaign-opt-in-cta')).toBeDefined(); + }); + + describe('termsAndConditions rich text', () => { + const richTextDoc: Json = { + nodeType: 'document', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'By joining you agree to the ', + marks: [], + data: {}, + }, + { + nodeType: 'hyperlink', + data: { uri: 'https://example.com/terms' }, + content: [ + { nodeType: 'text', value: 'Terms', marks: [], data: {} }, + ], + }, + ], + }, + ], + }; + + it('renders ContentfulRichText when termsAndConditions is present', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-description')).toBeDefined(); + }); + + it('does not render the static terms link when termsAndConditions is present', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId('campaign-opt-in-sheet-terms-link')).toBeNull(); + }); + + it('renders the static fallback when termsAndConditions is null', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-terms-link')).toBeOnTheScreen(); + }); + + it('renders the static fallback when termsAndConditions is malformed', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-terms-link')).toBeOnTheScreen(); + }); + + it('renders the static fallback when termsAndConditions is a non-document object', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-terms-link')).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.tsx new file mode 100644 index 00000000000..45c1727f22d --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.tsx @@ -0,0 +1,152 @@ +import React, { useCallback } from 'react'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Button, + ButtonIcon, + ButtonSize, + ButtonVariant, + IconColor, + IconName, + Text, + TextVariant, + FontWeight, +} from '@metamask/design-system-react-native'; +import BottomSheet from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { useOptInToCampaign } from '../../hooks/useOptInToCampaign'; +import { strings } from '../../../../../../locales/i18n'; +import { REWARDS_ONBOARD_TERMS_URL } from '../Onboarding/constants'; +import RewardsErrorBanner from '../RewardsErrorBanner'; +import ContentfulRichText, { + isDocument, +} from '../ContentfulRichText/ContentfulRichText'; +import { useNavigation } from '@react-navigation/native'; +import Routes from '../../../../../constants/navigation/Routes'; + +interface CampaignOptInSheetProps { + campaign: CampaignDto; + onClose?: () => void; +} + +/** + * Bottom sheet shown when a user taps a campaign tile they haven't opted into yet. + * Shows the campaign title, a legal disclaimer with a tappable terms link, and an opt-in CTA. + */ +const CampaignOptInSheet: React.FC = ({ + campaign, + onClose, +}) => { + const navigation = useNavigation(); + const { optInToCampaign, isOptingIn, optInError } = useOptInToCampaign(); + + const handleOptIn = useCallback(async () => { + try { + await optInToCampaign(campaign.id); + onClose?.(); + } catch { + // Error is handled by the hook; sheet stays open so user can retry + } + }, [optInToCampaign, campaign.id, onClose]); + + const handleTermsPress = useCallback(() => { + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: REWARDS_ONBOARD_TERMS_URL, + timestamp: Date.now(), + }, + }); + }, [navigation]); + + return ( + + + {/* Header: centered title + close button */} + + {/* Left spacer to balance the close button */} + + + + {strings('rewards.campaign.opt_in_sheet_title')} + + + + + + {/* Legal disclaimer – rich text from Contentful or static fallback */} + + {isDocument(campaign.termsAndConditions) ? ( + + ) : ( + + {strings('rewards.campaign.opt_in_sheet_description_pre_link')}{' '} + + {strings('rewards.campaign.opt_in_sheet_link_text')} + + {'. '} + {strings('rewards.campaign.opt_in_sheet_description_post_link')} + + )} + + + {optInError && ( + + + + )} + + {/* Opt-in CTA */} + + + + ); +}; + +export default CampaignOptInSheet; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignStatus.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignStatus.test.tsx new file mode 100644 index 00000000000..898759d8971 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignStatus.test.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import CampaignStatus, { CAMPAIGN_STATUS_TEST_IDS } from './CampaignStatus'; +import { + type CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { getCampaignStatusInfo } from './CampaignTile.utils'; + +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('./CampaignTile.utils', () => ({ + getCampaignStatusInfo: jest.fn().mockReturnValue({ + status: 'active', + statusLabel: 'Live', + dateLabel: 'Ends March 15', + dateLabelIcon: 'Clock', + }), +})); + +const createTestCampaign = (overrides = {}): CampaignDto => ({ + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2027-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +describe('CampaignStatus', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'active', + statusLabel: 'Live', + dateLabel: 'Ends March 15', + dateLabelIcon: 'Clock', + }); + }); + + it('renders the container', () => { + const campaign = createTestCampaign(); + const { getByTestId } = render(); + expect(getByTestId(CAMPAIGN_STATUS_TEST_IDS.CONTAINER)).toBeDefined(); + }); + + it('renders campaign image', () => { + const campaign = createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { title: '', description: '', phases: [] }, + }, + }); + const { getByTestId } = render(); + expect(getByTestId(CAMPAIGN_STATUS_TEST_IDS.IMAGE)).toBeDefined(); + }); + + it('renders status label', () => { + const campaign = createTestCampaign(); + const { getByTestId } = render(); + expect( + getByTestId(CAMPAIGN_STATUS_TEST_IDS.STATUS_LABEL), + ).toHaveTextContent('Live'); + }); + + it('renders date label', () => { + const campaign = createTestCampaign(); + const { getByTestId } = render(); + expect(getByTestId(CAMPAIGN_STATUS_TEST_IDS.DATE_LABEL)).toHaveTextContent( + /Ends March 15/, + ); + }); + + it('renders howItWorks title when available', () => { + const campaign = createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Description', + phases: [], + }, + }, + }); + const { getByTestId } = render(); + expect( + getByTestId(CAMPAIGN_STATUS_TEST_IDS.HOW_IT_WORKS_TITLE), + ).toHaveTextContent('How it works'); + }); + + it('does not render howItWorks title when details is null', () => { + const campaign = createTestCampaign({ details: null }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_STATUS_TEST_IDS.HOW_IT_WORKS_TITLE), + ).toBeNull(); + }); + + it('does not render howItWorks title when title is empty', () => { + const campaign = createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { title: '', description: '', phases: [] }, + }, + }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_STATUS_TEST_IDS.HOW_IT_WORKS_TITLE), + ).toBeNull(); + }); + + it('renders howItWorks description when available', () => { + const campaign = createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Hold ONDO tokens to earn rewards', + phases: [], + }, + }, + }); + const { getByTestId } = render(); + expect( + getByTestId(CAMPAIGN_STATUS_TEST_IDS.HOW_IT_WORKS_DESCRIPTION), + ).toHaveTextContent('Hold ONDO tokens to earn rewards'); + }); + + it('does not render howItWorks description when details is null', () => { + const campaign = createTestCampaign({ details: null }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_STATUS_TEST_IDS.HOW_IT_WORKS_DESCRIPTION), + ).toBeNull(); + }); + + it('does not render howItWorks description when description is empty', () => { + const campaign = createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { title: 'Title', description: '', phases: [] }, + }, + }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_STATUS_TEST_IDS.HOW_IT_WORKS_DESCRIPTION), + ).toBeNull(); + }); + + it('calls getCampaignStatusInfo with campaign', () => { + const campaign = createTestCampaign(); + render(); + expect(getCampaignStatusInfo).toHaveBeenCalledWith(campaign); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignStatus.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignStatus.tsx new file mode 100644 index 00000000000..2ca2e5ac7c9 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignStatus.tsx @@ -0,0 +1,112 @@ +import React, { useMemo } from 'react'; +import { ImageBackground, useColorScheme } from 'react-native'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + Text, + TextVariant, + FontWeight, + TextColor, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { getCampaignStatusInfo } from './CampaignTile.utils'; + +export const CAMPAIGN_STATUS_TEST_IDS = { + CONTAINER: 'campaign-status-container', + IMAGE: 'campaign-status-image', + STATUS_LABEL: 'campaign-status-label', + DATE_LABEL: 'campaign-status-date-label', + HOW_IT_WORKS_TITLE: 'campaign-status-how-it-works-title', + HOW_IT_WORKS_DESCRIPTION: 'campaign-status-how-it-works-description', +} as const; + +interface CampaignStatusProps { + campaign: CampaignDto; +} + +const CampaignStatus: React.FC = ({ campaign }) => { + const tw = useTailwind(); + const colorScheme = useColorScheme(); + + const { statusLabel, dateLabel } = useMemo( + () => getCampaignStatusInfo(campaign), + [campaign], + ); + + const backgroundImageUrl = + colorScheme === 'dark' + ? campaign.details?.image?.darkModeUrl + : campaign.details?.image?.lightModeUrl; + + const howItWorksTitle = campaign.details?.howItWorks?.title; + const howItWorksDescription = campaign.details?.howItWorks?.description; + + return ( + + + + + + + + + + {statusLabel} + + + + + + • + + + {dateLabel} + + + + + {howItWorksTitle ? ( + + {howItWorksTitle} + + ) : null} + + {howItWorksDescription ? ( + + {howItWorksDescription} + + ) : null} + + + ); +}; + +export default CampaignStatus; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx new file mode 100644 index 00000000000..b9bc6588829 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx @@ -0,0 +1,339 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import CampaignTile from './CampaignTile'; +import { + type CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { getCampaignStatusInfo } from './CampaignTile.utils'; +import { selectCampaignParticipantCount } from '../../../../../reducers/rewards/selectors'; +import useGetCampaignParticipantStatus from '../../hooks/useGetCampaignParticipantStatus'; +import Routes from '../../../../../constants/navigation/Routes'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn().mockReturnValue({ + navigate: (...args: unknown[]) => mockNavigate(...args), + }), +})); + +jest.mock('../../hooks/useGetCampaignParticipantStatus', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockUseGetCampaignParticipantStatus = + useGetCampaignParticipantStatus as jest.MockedFunction< + typeof useGetCampaignParticipantStatus + >; + +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('../../hooks/useGetCampaignParticipantStatus', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('./CampaignTile.utils', () => ({ + getCampaignStatusInfo: jest.fn().mockReturnValue({ + status: 'active', + statusLabel: 'Active', + dateLabel: 'Ends Mar 15, 2:30 PM', + dateLabelIcon: 'Clock', + }), +})); + +jest.mock('../../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantCount: jest.fn(), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string, params?: Record) => { + const translations: Record = { + 'rewards.campaign.enter_now': 'Enter now', + 'rewards.campaign.entered': 'Entered', + 'rewards.campaign.participant_count': `#${params?.count ?? ''}`, + }; + return translations[key] || key; + }, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockSelectCampaignParticipantCount = + selectCampaignParticipantCount as jest.MockedFunction< + typeof selectCampaignParticipantCount + >; + +const createTestCampaign = (overrides = {}): CampaignDto => ({ + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2027-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +function setupParticipantCount(count: number | null) { + const mockSelector = jest.fn().mockReturnValue(count); + mockSelectCampaignParticipantCount.mockReturnValue(mockSelector); + mockUseSelector.mockImplementation((selector) => { + if (selector === mockSelector) return count; + return undefined; + }); +} + +function setupParticipantStatus(optedIn: boolean) { + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: { optedIn, participantCount: 0 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); +} + +describe('CampaignTile', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'active', + statusLabel: 'Active', + dateLabel: 'Ends Mar 15, 2:30 PM', + dateLabelIcon: 'Clock', + }); + setupParticipantCount(null); + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: null, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + }); + + it('renders campaign name via campaign-tile-name testID', () => { + const campaign = createTestCampaign({ name: 'My Campaign' }); + + const { getByTestId } = render(); + + expect(getByTestId('campaign-tile-name')).toHaveTextContent('My Campaign'); + }); + + it('renders date label via campaign-tile-date-label testID', () => { + const campaign = createTestCampaign(); + + const { getByTestId } = render(); + + expect(getByTestId('campaign-tile-date-label')).toHaveTextContent( + 'Ends Mar 15, 2:30 PM', + ); + }); + + it('renders status label via campaign-tile-status-label testID', () => { + const campaign = createTestCampaign(); + + const { getByTestId } = render(); + + expect(getByTestId('campaign-tile-status-label')).toHaveTextContent( + /Active/, + ); + }); + + it('renders background image via campaign-tile-background testID', () => { + const campaign = createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: '', + description: '', + phases: [], + }, + }, + }); + + const { getByTestId } = render(); + + expect(getByTestId('campaign-tile-background')).toBeDefined(); + }); + + it('calls getCampaignStatusInfo with campaign', () => { + const campaign = createTestCampaign(); + + render(); + + expect(getCampaignStatusInfo).toHaveBeenCalledWith(campaign); + }); + + describe('enter now label', () => { + it('renders enter-now when status is active and participantCount is null', () => { + setupParticipantCount(null); + const campaign = createTestCampaign(); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('campaign-tile-enter-now')).toHaveTextContent( + '•Enter now', + ); + expect(queryByTestId('campaign-tile-participant-count')).toBeNull(); + }); + + it('does not render enter-now when status is upcoming', () => { + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'upcoming', + statusLabel: 'Up next', + dateLabel: 'Starts June 1', + dateLabelIcon: 'Speed', + }); + setupParticipantCount(null); + const campaign = createTestCampaign(); + + const { queryByTestId } = render(); + + expect(queryByTestId('campaign-tile-enter-now')).toBeNull(); + expect(queryByTestId('campaign-tile-participant-count')).toBeNull(); + }); + + it('does not render enter-now when status is complete', () => { + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'complete', + statusLabel: 'Complete', + dateLabel: 'December 31', + dateLabelIcon: 'Confirmation', + }); + setupParticipantCount(null); + const campaign = createTestCampaign(); + + const { queryByTestId } = render(); + + expect(queryByTestId('campaign-tile-enter-now')).toBeNull(); + expect(queryByTestId('campaign-tile-participant-count')).toBeNull(); + }); + }); + + describe('participant count', () => { + it('renders participant count when count is available', () => { + setupParticipantCount(1234); + const campaign = createTestCampaign(); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('campaign-tile-participant-count')).toHaveTextContent( + '#1,234', + ); + expect(queryByTestId('campaign-tile-enter-now')).toBeNull(); + }); + + it('renders participant count of zero', () => { + setupParticipantCount(0); + const campaign = createTestCampaign(); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('campaign-tile-participant-count')).toHaveTextContent( + '#0', + ); + expect(queryByTestId('campaign-tile-enter-now')).toBeNull(); + }); + + it('renders participant count even when status is not active', () => { + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'complete', + statusLabel: 'Complete', + dateLabel: 'December 31', + dateLabelIcon: 'Confirmation', + }); + setupParticipantCount(5000); + const campaign = createTestCampaign(); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('campaign-tile-participant-count')).toHaveTextContent( + '#5,000', + ); + expect(queryByTestId('campaign-tile-enter-now')).toBeNull(); + }); + }); + + describe('entered label', () => { + it('shows "Entered" label when participant is opted in', () => { + setupParticipantStatus(true); + const campaign = createTestCampaign(); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('campaign-tile-entered-label')).toHaveTextContent( + 'Entered', + ); + expect(queryByTestId('campaign-tile-enter-now')).toBeNull(); + }); + + it('shows status label when participant is not opted in', () => { + setupParticipantStatus(false); + const campaign = createTestCampaign(); + + const { queryByTestId, getByTestId } = render( + , + ); + + expect(queryByTestId('campaign-tile-entered-label')).toBeNull(); + expect(getByTestId('campaign-tile-status-label')).toHaveTextContent( + /Active/, + ); + }); + + it('shows "Entered" label alongside participant count when opted in', () => { + setupParticipantStatus(true); + setupParticipantCount(42); + const campaign = createTestCampaign(); + + const { getByTestId } = render(); + + expect(getByTestId('campaign-tile-entered-label')).toHaveTextContent( + 'Entered', + ); + expect(getByTestId('campaign-tile-participant-count')).toHaveTextContent( + '#42', + ); + }); + }); + + describe('navigation', () => { + it('navigates to campaign details when the tile is pressed', () => { + const campaign = createTestCampaign({ id: 'camp-42' }); + + const { getByTestId } = render(); + fireEvent.press(getByTestId('campaign-tile-camp-42')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CAMPAIGN_DETAILS, { + campaignId: 'camp-42', + }); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx new file mode 100644 index 00000000000..4a1a2f8680d --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx @@ -0,0 +1,202 @@ +import React, { useMemo } from 'react'; +import { ImageBackground, Pressable, useColorScheme } from 'react-native'; +import { useSelector } from 'react-redux'; +import { useNavigation } from '@react-navigation/native'; +import Routes from '../../../../../constants/navigation/Routes'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + Text, + TextColor, + TextVariant, + Icon, + IconColor, + IconName, + IconSize, + FontWeight, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { getCampaignStatusInfo } from './CampaignTile.utils'; +import { selectCampaignParticipantCount } from '../../../../../reducers/rewards/selectors'; +import { strings } from '../../../../../../locales/i18n'; +import useGetCampaignParticipantStatus from '../../hooks/useGetCampaignParticipantStatus'; + +interface CampaignTileProps { + campaign: CampaignDto; +} + +/** + * CampaignTile displays campaign information with status. + * Tapping navigates to the campaign details screen. + */ +const CampaignTile: React.FC = ({ campaign }) => { + const tw = useTailwind(); + const colorScheme = useColorScheme(); + const navigation = useNavigation(); + + const { status: participantStatus } = useGetCampaignParticipantStatus( + campaign.id, + ); + + const participantCount = useSelector( + selectCampaignParticipantCount(campaign.id), + ); + + const { + status: campaignStatus, + statusLabel, + dateLabel, + dateLabelIcon, + } = useMemo(() => getCampaignStatusInfo(campaign), [campaign]); + + const backgroundImageUrl = + colorScheme === 'dark' + ? campaign.details?.image?.darkModeUrl + : campaign.details?.image?.lightModeUrl; + + const handlePress = () => { + navigation.navigate(Routes.CAMPAIGN_DETAILS, { campaignId: campaign.id }); + }; + + return ( + + tw.style( + 'rounded-xl overflow-hidden h-50 bg-muted', + pressed && 'opacity-70', + ) + } + testID={`campaign-tile-${campaign.id}`} + > + + + {/* Date label */} + + + + {dateLabel} + + + + + + {participantStatus?.optedIn === true ? ( + + {strings('rewards.campaign.entered')} + + ) : ( + + {statusLabel} + + )} + {participantCount != null ? ( + + + + {strings('rewards.campaign.participant_count', { + count: participantCount.toLocaleString(), + })} + + + ) : campaignStatus === 'active' && + participantStatus?.optedIn !== true ? ( + + + • + + + {strings('rewards.campaign.enter_now')} + + + ) : ( + <> + )} + + + + + {campaign.name} + + + + + + ); +}; + +export default CampaignTile; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts new file mode 100644 index 00000000000..92f83565905 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts @@ -0,0 +1,283 @@ +/** + * Unit tests for CampaignTile utility functions + */ + +import { + getCampaignStatus, + formatCampaignStatusLabel, + getCampaignPillLabel, + getCampaignStatusInfo, +} from './CampaignTile.utils'; +import { + type CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('@metamask/design-system-react-native', () => ({ + IconName: { + Clock: 'Clock', + Confirmation: 'Confirmation', + Speed: 'Speed', + }, +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, + ), +})); + +import { strings } from '../../../../../../locales/i18n'; + +/** + * Helper to build test CampaignDto objects. + */ +function buildCampaignDto(overrides: Partial = {}): CampaignDto { + return { + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, + }; +} + +describe('CampaignTile.utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('getCampaignStatus', () => { + it('returns upcoming when now is before startDate', () => { + const fixedNow = new Date('2025-01-15T12:00:00.000Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatus(campaign); + + expect(result).toBe('upcoming'); + }); + + it('returns active when now is within startDate and endDate', () => { + const fixedNow = new Date('2025-08-15T12:00:00.000Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatus(campaign); + + expect(result).toBe('active'); + }); + + it('returns active when now equals startDate', () => { + const fixedNow = new Date('2025-06-01T00:00:00.000Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatus(campaign); + + expect(result).toBe('active'); + }); + + it('returns complete when now is after endDate', () => { + const fixedNow = new Date('2026-01-15T12:00:00.000Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatus(campaign); + + expect(result).toBe('complete'); + }); + + it('returns complete when now equals endDate', () => { + const fixedNow = new Date('2025-12-31T23:59:59.999Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatus(campaign); + + expect(result).toBe('complete'); + }); + }); + + describe('formatCampaignStatusLabel', () => { + it('returns localized starts_date for upcoming status with formatted startDate', () => { + const campaign = buildCampaignDto({ + startDate: '2025-03-15T14:30:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = formatCampaignStatusLabel('upcoming', campaign); + + expect(strings).toHaveBeenCalledWith('rewards.campaign.starts_date', { + date: 'March 15', + }); + expect(result).toContain('rewards.campaign.starts_date:'); + expect(result).toContain('"date"'); + }); + + it('returns localized ends_date for active status with formatted endDate', () => { + const campaign = buildCampaignDto({ + startDate: '2025-01-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:00.000Z', + }); + + const result = formatCampaignStatusLabel('active', campaign); + + expect(strings).toHaveBeenCalledWith('rewards.campaign.ends_date', { + date: 'December 31', + }); + expect(result).toContain('rewards.campaign.ends_date:'); + expect(result).toContain('"date"'); + }); + + it('returns formatted endDate for complete status without localization', () => { + const campaign = buildCampaignDto({ + startDate: '2025-01-01T00:00:00.000Z', + endDate: '2025-07-04T18:00:00.000Z', + }); + + const result = formatCampaignStatusLabel('complete', campaign); + + expect(strings).not.toHaveBeenCalled(); + expect(result).toBe('July 4'); + }); + + it('returns empty string for unknown status', () => { + const campaign = buildCampaignDto(); + + const result = formatCampaignStatusLabel( + 'unknown' as 'upcoming' | 'active' | 'complete', + campaign, + ); + + expect(result).toBe(''); + }); + }); + + describe('getCampaignPillLabel', () => { + it('returns pill_up_next for upcoming status', () => { + const result = getCampaignPillLabel('upcoming'); + + expect(strings).toHaveBeenCalledWith('rewards.campaign.pill_up_next'); + expect(result).toBe('rewards.campaign.pill_up_next'); + }); + + it('returns pill_active for active status', () => { + const result = getCampaignPillLabel('active'); + + expect(strings).toHaveBeenCalledWith('rewards.campaign.pill_active'); + expect(result).toBe('rewards.campaign.pill_active'); + }); + + it('returns pill_complete for complete status', () => { + const result = getCampaignPillLabel('complete'); + + expect(strings).toHaveBeenCalledWith('rewards.campaign.pill_complete'); + expect(result).toBe('rewards.campaign.pill_complete'); + }); + + it('returns empty string for unknown status', () => { + const result = getCampaignPillLabel( + 'unknown' as 'upcoming' | 'active' | 'complete', + ); + + expect(result).toBe(''); + }); + }); + + describe('getCampaignStatusInfo', () => { + it('combines status, pill label, description, and icon for upcoming campaign', () => { + const fixedNow = new Date('2025-01-15T12:00:00.000Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatusInfo(campaign); + + expect(result).toEqual({ + status: 'upcoming', + statusLabel: 'rewards.campaign.pill_up_next', + dateLabel: expect.stringContaining('rewards.campaign.starts_date'), + dateLabelIcon: 'Speed', + }); + }); + + it('combines status, pill label, description, and icon for active campaign', () => { + const fixedNow = new Date('2025-08-15T12:00:00.000Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatusInfo(campaign); + + expect(result).toEqual({ + status: 'active', + statusLabel: 'rewards.campaign.pill_active', + dateLabel: expect.stringContaining('rewards.campaign.ends_date'), + dateLabelIcon: 'Clock', + }); + }); + + it('combines status, pill label, description, and icon for complete campaign', () => { + const fixedNow = new Date('2026-01-15T12:00:00.000Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatusInfo(campaign); + + expect(result).toEqual({ + status: 'complete', + statusLabel: 'rewards.campaign.pill_complete', + dateLabel: 'December 31', + dateLabelIcon: 'Confirmation', + }); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts new file mode 100644 index 00000000000..e57f8d4ce2f --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts @@ -0,0 +1,156 @@ +import { IconName } from '@metamask/design-system-react-native'; +import type { + CampaignDto, + CampaignStatus, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../../locales/i18n'; + +/** + * Derives the status of a campaign based on its date fields. + * + * Status logic: + * - upcoming: now < startDate + * - active: startDate <= now < endDate + * - complete: now >= endDate + * + * @param campaign - The campaign data + * @returns The derived status + */ +export function getCampaignStatus(campaign: CampaignDto): CampaignStatus { + const now = new Date(); + const startDate = new Date(campaign.startDate); + const endDate = new Date(campaign.endDate); + + if (now < startDate) { + return 'upcoming'; + } + + if (now >= startDate && now < endDate) { + return 'active'; + } + + return 'complete'; +} + +const MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +/** + * Formats a date for display in campaign tiles. + * + * @param date - The date to format + * @returns Formatted date string (e.g., "March 15") + */ +function formatCampaignDate(date: Date): string { + const month = MONTHS[date.getMonth()]; + const day = date.getDate(); + + return `${month} ${day}`; +} + +/** + * Formats the status label for display in the campaign tile. + * + * @param status - The campaign status + * @param campaign - The campaign data (used for date formatting) + * @returns The formatted status label + */ +export function formatCampaignStatusLabel( + status: CampaignStatus, + campaign: CampaignDto, +): string { + switch (status) { + case 'upcoming': { + const startDate = new Date(campaign.startDate); + return strings('rewards.campaign.starts_date', { + date: formatCampaignDate(startDate), + }); + } + case 'active': { + const endDate = new Date(campaign.endDate); + return strings('rewards.campaign.ends_date', { + date: formatCampaignDate(endDate), + }); + } + case 'complete': { + const endDate = new Date(campaign.endDate); + return formatCampaignDate(endDate); + } + default: + return ''; + } +} + +/** + * Gets the pill label text based on the campaign status. + * + * @param status - The campaign status + * @returns The pill label text + */ +export function getCampaignPillLabel(status: CampaignStatus): string { + switch (status) { + case 'upcoming': + return strings('rewards.campaign.pill_up_next'); + case 'active': + return strings('rewards.campaign.pill_active'); + case 'complete': + return strings('rewards.campaign.pill_complete'); + default: + return ''; + } +} + +/** + * Gets the appropriate icon for the campaign status. + * + * @param status - The campaign status + * @returns The icon name for the status + */ +function getStatusIcon(status: CampaignStatus): IconName { + switch (status) { + case 'active': + return IconName.Clock; + case 'complete': + return IconName.Confirmation; + case 'upcoming': + default: + return IconName.Speed; + } +} + +export interface CampaignStatusInfo { + status: CampaignStatus; + statusLabel: string; + dateLabel: string; + dateLabelIcon: IconName; +} + +/** + * Gets all status-related information for a campaign. + * + * @param campaign - The campaign data + * @returns Object containing status, statusLabel, statusDescription, and statusDescriptionIcon + */ +export function getCampaignStatusInfo( + campaign: CampaignDto, +): CampaignStatusInfo { + const status = getCampaignStatus(campaign); + return { + status, + statusLabel: getCampaignPillLabel(status), + dateLabel: formatCampaignStatusLabel(status, campaign), + dateLabelIcon: getStatusIcon(status), + }; +} diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.test.tsx new file mode 100644 index 00000000000..70155caee21 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.test.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import CampaignsGroup from './CampaignsGroup'; +import type { + CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; + +const createTestCampaign = ( + overrides: Partial = {}, +): CampaignDto => ({ + id: 'campaign-1', + type: 'ONDO_HOLDING' as CampaignType, + name: 'Test Campaign', + startDate: '2025-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +jest.mock('./CampaignTile', () => { + const ReactActual = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ campaign }: { campaign: { name: string } }) => + ReactActual.createElement(Text, null, campaign.name), + }; +}); + +jest.mock('../PreviousSeason/PreviousSeasonTile', () => { + const ReactActual = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => ReactActual.createElement(Text, null, 'PreviousSeasonTile'), + }; +}); + +const mockUseSelector = jest.fn(); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: (selector: unknown) => mockUseSelector(selector), +})); + +describe('CampaignsGroup', () => { + beforeEach(() => { + mockUseSelector.mockReturnValue(null); + }); + + it('renders title and campaign tiles', () => { + const campaigns = [ + createTestCampaign({ id: '1', name: 'Campaign One' }), + createTestCampaign({ id: '2', name: 'Campaign Two' }), + ]; + + const { getByText } = render( + , + ); + + expect(getByText('Active Campaigns')).toBeOnTheScreen(); + expect(getByText('Campaign One')).toBeOnTheScreen(); + expect(getByText('Campaign Two')).toBeOnTheScreen(); + }); + + it('returns null when campaigns array is empty', () => { + const { queryByText } = render( + , + ); + + expect(queryByText('Active Campaigns')).toBeNull(); + expect(queryByText('Test Campaign')).toBeNull(); + }); + + it('renders PreviousSeasonTile when displayPreviousSeason is true and seasonName exists', () => { + mockUseSelector.mockReturnValue('Season 1'); + + const { getByText } = render( + , + ); + + expect(getByText('Previous')).toBeOnTheScreen(); + expect(getByText('PreviousSeasonTile')).toBeOnTheScreen(); + }); + + it('returns null when displayPreviousSeason is true but seasonName is empty', () => { + mockUseSelector.mockReturnValue(null); + + const { queryByText } = render( + , + ); + + expect(queryByText('Previous')).toBeNull(); + expect(queryByText('PreviousSeasonTile')).toBeNull(); + }); + + it('does not render PreviousSeasonTile when displayPreviousSeason is false', () => { + mockUseSelector.mockReturnValue('Season 1'); + + const campaigns = [createTestCampaign({ id: '1', name: 'Campaign One' })]; + + const { queryByText } = render( + , + ); + + expect(queryByText('PreviousSeasonTile')).toBeNull(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.tsx new file mode 100644 index 00000000000..bd1fa64d9ec --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; +import CampaignTile from './CampaignTile'; +import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import PreviousSeasonTile from '../PreviousSeason/PreviousSeasonTile'; +import { selectSeasonName } from '../../../../../reducers/rewards/selectors'; +import { useSelector } from 'react-redux'; + +interface CampaignsGroupProps { + title: string; + campaigns: CampaignDto[]; + testID?: string; + displayPreviousSeason?: boolean; +} + +/** + * Section component for displaying a group of campaigns with a title. + */ +const CampaignsGroup: React.FC = ({ + title, + campaigns, + testID, + displayPreviousSeason = false, +}) => { + const seasonName = useSelector(selectSeasonName); + const showPreviousSeason = displayPreviousSeason && !!seasonName; + + if (campaigns.length === 0 && !showPreviousSeason) { + return null; + } + + return ( + + + {title} + + {campaigns.map((campaign) => ( + + ))} + {showPreviousSeason && } + + ); +}; + +export default CampaignsGroup; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx new file mode 100644 index 00000000000..033c2428b5f --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx @@ -0,0 +1,251 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import CampaignsPreview from './CampaignsPreview'; +import { + type CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import Routes from '../../../../../constants/navigation/Routes'; +import { useRewardCampaigns } from '../../hooks/useRewardCampaigns'; +import { REWARDS_VIEW_SELECTORS } from '../../Views/RewardsView.constants'; + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +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('../../hooks/useRewardCampaigns'); +const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< + typeof useRewardCampaigns +>; + +jest.mock('./CampaignTile', () => { + const ReactActual = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ campaign }: { campaign: CampaignDto }) => + ReactActual.createElement( + Text, + { testID: `campaign-tile-${campaign.id}` }, + campaign.name, + ), + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaigns_preview.title': 'Campaigns', + 'rewards.campaigns_preview.coming_soon': 'Coming soon', + 'rewards.campaigns_preview.notify_me': 'Notify me', + }; + return translations[key] || key; + }, +})); + +const createTestCampaign = ( + overrides: Partial = {}, +): CampaignDto => ({ + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2027-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +const emptyCategorized = { active: [], upcoming: [], previous: [] }; + +const mockHookDefaults = { + campaigns: [], + categorizedCampaigns: emptyCategorized, + isLoading: false, + hasError: false, + fetchCampaigns: jest.fn(), +}; + +describe('CampaignsPreview', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRewardCampaigns.mockReturnValue(mockHookDefaults); + }); + + it('returns null when there are no active or upcoming campaigns', () => { + const { queryByTestId } = render(); + + expect(queryByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW)).toBeNull(); + }); + + it('renders the section title when an active campaign exists', () => { + const activeCampaign = createTestCampaign({ + id: 'active-1', + name: 'Active Campaign', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [activeCampaign] }, + }); + + const { getByText, getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW), + ).toBeOnTheScreen(); + expect(getByText('Campaigns')).toBeOnTheScreen(); + }); + + it('renders a CampaignTile for the first active campaign', () => { + const activeCampaign = createTestCampaign({ + id: 'active-1', + name: 'Active Campaign', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [activeCampaign] }, + }); + + const { getByText } = render(); + + expect(getByText('Active Campaign')).toBeOnTheScreen(); + }); + + it('renders the upcoming banner when an upcoming campaign exists', () => { + const upcomingCampaign = createTestCampaign({ + id: 'up-1', + name: 'Upcoming Campaign', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { + ...emptyCategorized, + upcoming: [upcomingCampaign], + }, + }); + + const { getByText, getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW_UPCOMING_BANNER), + ).toBeOnTheScreen(); + expect(getByText('Coming soon')).toBeOnTheScreen(); + expect(getByText('Upcoming Campaign')).toBeOnTheScreen(); + expect(getByText('Notify me')).toBeOnTheScreen(); + }); + + it('renders both active tile and upcoming banner when both exist', () => { + const activeCampaign = createTestCampaign({ + id: 'active-1', + name: 'Active Campaign', + }); + const upcomingCampaign = createTestCampaign({ + id: 'up-1', + name: 'Upcoming Campaign', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { + ...emptyCategorized, + active: [activeCampaign], + upcoming: [upcomingCampaign], + }, + }); + + const { getByText } = render(); + + expect(getByText('Active Campaign')).toBeOnTheScreen(); + expect(getByText('Upcoming Campaign')).toBeOnTheScreen(); + }); + + it('does not render a CampaignTile when there are no active campaigns', () => { + const upcomingCampaign = createTestCampaign({ + id: 'up-1', + name: 'Upcoming Campaign', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { + ...emptyCategorized, + upcoming: [upcomingCampaign], + }, + }); + + const { queryByTestId } = render(); + + expect(queryByTestId('campaign-tile-up-1')).toBeNull(); + }); + + it('does not render the upcoming banner when there are no upcoming campaigns', () => { + const activeCampaign = createTestCampaign({ + id: 'active-1', + name: 'Active Campaign', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [activeCampaign] }, + }); + + const { queryByTestId } = render(); + + expect( + queryByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW_UPCOMING_BANNER), + ).toBeNull(); + }); + + it('navigates to campaigns view when the title header is pressed', () => { + const activeCampaign = createTestCampaign({ + id: 'active-1', + name: 'Active Campaign', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [activeCampaign] }, + }); + + const { getByText } = render(); + fireEvent.press(getByText('Campaigns')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CAMPAIGNS_VIEW); + }); + + it('only shows the first active campaign even when multiple exist', () => { + const first = createTestCampaign({ id: 'a1', name: 'First Active' }); + const second = createTestCampaign({ id: 'a2', name: 'Second Active' }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [first, second] }, + }); + + const { getByText, queryByText } = render(); + + expect(getByText('First Active')).toBeOnTheScreen(); + expect(queryByText('Second Active')).toBeNull(); + }); + + it('only shows the first upcoming campaign even when multiple exist', () => { + const first = createTestCampaign({ id: 'u1', name: 'First Upcoming' }); + const second = createTestCampaign({ id: 'u2', name: 'Second Upcoming' }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { ...emptyCategorized, upcoming: [first, second] }, + }); + + const { getByText, queryByText } = render(); + + expect(getByText('First Upcoming')).toBeOnTheScreen(); + expect(queryByText('Second Upcoming')).toBeNull(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.tsx new file mode 100644 index 00000000000..10cb287787f --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.tsx @@ -0,0 +1,137 @@ +import React, { useCallback, useMemo } from 'react'; +import { Pressable, ActivityIndicator } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useTheme } from '../../../../../util/theme'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + Text, + TextVariant, + Icon, + IconName, + IconSize, + FontWeight, + Button, + ButtonVariant, + ButtonSize, + Skeleton, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import Routes from '../../../../../constants/navigation/Routes'; +import { REWARDS_VIEW_SELECTORS } from '../../Views/RewardsView.constants'; +import { strings } from '../../../../../../locales/i18n'; +import { useRewardCampaigns } from '../../hooks/useRewardCampaigns'; +import CampaignTile from './CampaignTile'; +import RewardsErrorBanner from '../RewardsErrorBanner'; + +/** + * CampaignsPreview shows a snapshot of campaigns on the dashboard: + * the first active campaign as a tile and the first upcoming campaign + * as a compact banner with "Coming soon". + */ +const CampaignsPreview: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const { colors } = useTheme(); + const { categorizedCampaigns, isLoading, hasError, fetchCampaigns } = + useRewardCampaigns(); + + const activeCampaign = useMemo( + () => categorizedCampaigns.active[0] ?? null, + [categorizedCampaigns.active], + ); + + const upcomingCampaign = useMemo( + () => categorizedCampaigns.upcoming[0] ?? null, + [categorizedCampaigns.upcoming], + ); + + const handleNavigateToCampaigns = useCallback(() => { + navigation.navigate(Routes.CAMPAIGNS_VIEW); + }, [navigation]); + + if (!isLoading && !hasError && !activeCampaign && !upcomingCampaign) { + return null; + } + + return ( + + + + {isLoading && !activeCampaign && !upcomingCampaign && ( + + )} + + {strings('rewards.campaigns_preview.title')} + + + + + + {isLoading && !activeCampaign && !upcomingCampaign && ( + + )} + + {!isLoading && hasError && !activeCampaign && !upcomingCampaign && ( + + )} + + {activeCampaign && } + + {upcomingCampaign && ( + + tw.style('rounded-xl bg-muted px-4 py-3', pressed && 'opacity-70') + } + testID={REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW_UPCOMING_BANNER} + > + + + + {strings('rewards.campaigns_preview.coming_soon')} + + + {upcomingCampaign.name} + + + + + + )} + + ); +}; + +export default CampaignsPreview; diff --git a/app/components/UI/Rewards/components/ContentfulRichText/ContentfulRichText.test.tsx b/app/components/UI/Rewards/components/ContentfulRichText/ContentfulRichText.test.tsx new file mode 100644 index 00000000000..657d7f787c9 --- /dev/null +++ b/app/components/UI/Rewards/components/ContentfulRichText/ContentfulRichText.test.tsx @@ -0,0 +1,197 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import type { Json } from '@metamask/utils'; +import ContentfulRichText from './ContentfulRichText'; +import Routes from '../../../../../constants/navigation/Routes'; + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +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 }), +})); + +type RichTextNode = Record; + +const makeDoc = (...content: RichTextNode[]): Json => ({ + nodeType: 'document', + data: {}, + content, +}); + +const paragraph = (...children: RichTextNode[]): RichTextNode => ({ + nodeType: 'paragraph', + data: {}, + content: children, +}); + +const text = (value: string, marks: { type: string }[] = []): RichTextNode => ({ + nodeType: 'text', + value, + marks: marks as Json[], + data: {}, +}); + +const hyperlink = (uri: string, linkText: string): RichTextNode => ({ + nodeType: 'hyperlink', + data: { uri }, + content: [text(linkText)], +}); + +describe('ContentfulRichText', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns null for invalid document', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('returns null for a non-document object', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('renders a simple paragraph with plain text', () => { + const doc = makeDoc(paragraph(text('Hello world'))); + const { getByText } = render( + , + ); + expect(getByText('Hello world')).toBeOnTheScreen(); + }); + + it('renders bold text', () => { + const doc = makeDoc(paragraph(text('bold text', [{ type: 'bold' }]))); + const { getByText } = render( + , + ); + expect(getByText('bold text')).toBeOnTheScreen(); + }); + + it('renders a hyperlink and opens in-app browser on press', () => { + const doc = makeDoc( + paragraph( + text('See '), + hyperlink('https://example.com', 'our terms'), + text(' for details.'), + ), + ); + const { getByText } = render( + , + ); + fireEvent.press(getByText('our terms')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: expect.objectContaining({ + newTabUrl: 'https://example.com', + }), + }); + }); + + it('renders multiple paragraphs', () => { + const doc = makeDoc( + paragraph(text('First paragraph')), + paragraph(text('Second paragraph')), + ); + const { getByText } = render( + , + ); + expect(getByText('First paragraph')).toBeOnTheScreen(); + expect(getByText('Second paragraph')).toBeOnTheScreen(); + }); + + it('renders an unordered list with bullets', () => { + const doc = makeDoc({ + nodeType: 'unordered-list', + data: {}, + content: [ + { + nodeType: 'list-item', + data: {}, + content: [paragraph(text('Item one'))], + }, + { + nodeType: 'list-item', + data: {}, + content: [paragraph(text('Item two'))], + }, + ], + }); + const { getByText, getAllByText } = render( + , + ); + expect(getByText('Item one')).toBeOnTheScreen(); + expect(getByText('Item two')).toBeOnTheScreen(); + expect(getAllByText('• ').length).toBe(2); + }); + + it('renders an ordered list with numbers', () => { + const doc = makeDoc({ + nodeType: 'ordered-list', + data: {}, + content: [ + { + nodeType: 'list-item', + data: {}, + content: [paragraph(text('First'))], + }, + { + nodeType: 'list-item', + data: {}, + content: [paragraph(text('Second'))], + }, + ], + }); + const { getByText } = render( + , + ); + expect(getByText('1. ')).toBeOnTheScreen(); + expect(getByText('2. ')).toBeOnTheScreen(); + }); + + it('renders a heading', () => { + const doc = makeDoc({ + nodeType: 'heading-2', + data: {}, + content: [text('Title')], + }); + const { getByText } = render( + , + ); + expect(getByText('Title')).toBeOnTheScreen(); + }); + + it('sets the testID on the container', () => { + const doc = makeDoc(paragraph(text('test'))); + const { getByTestId } = render( + , + ); + expect(getByTestId('my-rich-text')).toBeOnTheScreen(); + }); + + it('renders a text node that has no marks property', () => { + const doc = makeDoc( + paragraph({ + nodeType: 'text', + value: 'no marks', + data: {}, + }), + ); + const { getByText } = render( + , + ); + expect(getByText('no marks')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Rewards/components/ContentfulRichText/ContentfulRichText.tsx b/app/components/UI/Rewards/components/ContentfulRichText/ContentfulRichText.tsx new file mode 100644 index 00000000000..99c2bc8609a --- /dev/null +++ b/app/components/UI/Rewards/components/ContentfulRichText/ContentfulRichText.tsx @@ -0,0 +1,255 @@ +import React, { Fragment, useCallback } from 'react'; +import { + useNavigation, + type NavigationProp, + type ParamListBase, +} from '@react-navigation/native'; +import { + Box, + Text, + TextVariant, + FontWeight, +} from '@metamask/design-system-react-native'; +import type { Json } from '@metamask/utils'; +import Routes from '../../../../../constants/navigation/Routes'; + +// Contentful rich text node-type constants (from @contentful/rich-text-types) +const BLOCK_TYPES = { + DOCUMENT: 'document', + PARAGRAPH: 'paragraph', + HEADING_1: 'heading-1', + HEADING_2: 'heading-2', + HEADING_3: 'heading-3', + HEADING_4: 'heading-4', + HEADING_5: 'heading-5', + HEADING_6: 'heading-6', + OL_LIST: 'ordered-list', + UL_LIST: 'unordered-list', + LIST_ITEM: 'list-item', + HR: 'hr', +} as const; + +const INLINE_TYPES = { + HYPERLINK: 'hyperlink', +} as const; + +const MARK_TYPES = { + BOLD: 'bold', + ITALIC: 'italic', + UNDERLINE: 'underline', +} as const; + +interface RichTextMark { + type: string; +} + +interface RichTextNode { + nodeType: string; + data: Record; + content?: RichTextNode[]; + value?: string; + marks?: RichTextMark[]; +} + +interface ContentfulRichTextProps { + document: Json; + textVariant?: TextVariant; + headingClassName?: string; + bodyClassName?: string; + testID?: string; +} + +function isDocument(value: unknown): value is { + nodeType: 'document'; + data: Record; + content: RichTextNode[]; +} { + return ( + value !== null && + typeof value === 'object' && + 'nodeType' in value && + (value as { nodeType: unknown }).nodeType === BLOCK_TYPES.DOCUMENT && + 'content' in value && + Array.isArray((value as { content: unknown }).content) + ); +} + +function isTextNode( + node: RichTextNode, +): node is RichTextNode & { value: string; marks: RichTextMark[] } { + return ( + node.nodeType === 'text' && + typeof node.value === 'string' && + Array.isArray(node.marks) + ); +} + +/** + * Renders a Contentful rich text Document as React Native components + * using the MetaMask design system primitives. + * + * Supports paragraphs, headings, lists, hyperlinks, and text marks + * (bold, italic, underline). + */ +const ContentfulRichText: React.FC = ({ + document: doc, + textVariant = TextVariant.BodyMd, + headingClassName = 'text-default', + bodyClassName = 'text-alternative', + testID, +}) => { + const navigation = useNavigation(); + + const handleLinkPress = useCallback( + (url: string) => { + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: url, + timestamp: Date.now(), + }, + }); + }, + [navigation], + ); + + if (!isDocument(doc)) { + return null; + } + + const renderMarkedText = ( + text: string, + marks: RichTextMark[], + key: string, + ): React.ReactElement => { + const isBold = marks.some((m) => m.type === MARK_TYPES.BOLD); + const isItalic = marks.some((m) => m.type === MARK_TYPES.ITALIC); + const isUnderline = marks.some((m) => m.type === MARK_TYPES.UNDERLINE); + + return ( + + {text} + + ); + }; + + const renderInlineChildren = ( + nodes: RichTextNode[], + keyPrefix: string, + ): React.ReactNode[] => + nodes.map((child, i) => { + const childKey = `${keyPrefix}-${i}`; + + if (isTextNode(child)) { + if (child.marks.length === 0) { + return {child.value}; + } + return renderMarkedText(child.value, child.marks, childKey); + } + + if (child.nodeType === 'text' && typeof child.value === 'string') { + return {child.value}; + } + + if (child.nodeType === INLINE_TYPES.HYPERLINK) { + const uri = (child.data as { uri?: string }).uri ?? ''; + return ( + handleLinkPress(uri)} + > + {renderInlineChildren(child.content ?? [], childKey)} + + ); + } + + return null; + }); + + const renderBlock = ( + node: RichTextNode, + key: string, + ): React.ReactElement | null => { + switch (node.nodeType) { + case BLOCK_TYPES.PARAGRAPH: + return ( + + {renderInlineChildren(node.content ?? [], key)} + + ); + + case BLOCK_TYPES.HEADING_1: + case BLOCK_TYPES.HEADING_2: + case BLOCK_TYPES.HEADING_3: + case BLOCK_TYPES.HEADING_4: + case BLOCK_TYPES.HEADING_5: + case BLOCK_TYPES.HEADING_6: { + const headingVariantMap: Record = { + [BLOCK_TYPES.HEADING_1]: TextVariant.HeadingLg, + [BLOCK_TYPES.HEADING_2]: TextVariant.HeadingLg, + [BLOCK_TYPES.HEADING_3]: TextVariant.HeadingMd, + [BLOCK_TYPES.HEADING_4]: TextVariant.HeadingMd, + [BLOCK_TYPES.HEADING_5]: TextVariant.HeadingSm, + [BLOCK_TYPES.HEADING_6]: TextVariant.HeadingSm, + }; + return ( + + {renderInlineChildren(node.content ?? [], key)} + + ); + } + + case BLOCK_TYPES.UL_LIST: + case BLOCK_TYPES.OL_LIST: + return ( + + {(node.content ?? []).map((item, i) => { + const bullet = + node.nodeType === BLOCK_TYPES.OL_LIST ? `${i + 1}. ` : '• '; + return ( + + + {bullet} + + + {(item.content ?? []).map((block, j) => + renderBlock(block, `${key}-li-${i}-${j}`), + )} + + + ); + })} + + ); + + case BLOCK_TYPES.HR: + return ( + + ); + + default: + return null; + } + }; + + return ( + + {doc.content.map((block, i) => renderBlock(block, `rt-${i}`))} + + ); +}; + +export { isDocument }; +export default ContentfulRichText; diff --git a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx index 8b0dcf956c2..08fc286fb5e 100644 --- a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx +++ b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx @@ -283,28 +283,33 @@ jest.mock( }, ); -// Mock BottomSheetHeader +// Mock HeaderCompactStandard jest.mock( - '../../../../../component-library/components/BottomSheets/BottomSheetHeader', + '../../../../../component-library/components-temp/HeaderCompactStandard', () => { const ReactActual = jest.requireActual('react'); const { View, Text, TouchableOpacity } = jest.requireActual('react-native'); return { __esModule: true, default: ({ - children, + title, onClose, + closeButtonProps, }: { - children?: React.ReactNode; + title?: React.ReactNode; onClose?: () => void; + closeButtonProps?: { testID?: string }; }) => ReactActual.createElement( View, { testID: 'bottom-sheet-header' }, - ReactActual.createElement(Text, {}, children), + ReactActual.createElement(Text, {}, title), ReactActual.createElement( TouchableOpacity, - { onPress: onClose, testID: 'close-button' }, + { + onPress: onClose, + testID: closeButtonProps?.testID ?? 'close-button', + }, ReactActual.createElement(Text, {}, 'Close'), ), ), @@ -459,19 +464,16 @@ describe('EndOfSeasonClaimBottomSheet', () => { ); expect(getByTestId(REWARDS_VIEW_SELECTORS.CLAIM_MODAL)).toBeOnTheScreen(); - expect(getByText('Reward Details')).toBeOnTheScreen(); + expect(getByText('Test Reward')).toBeOnTheScreen(); }); it('renders title for non-LINEA_TOKENS reward', () => { - const { getByTestId, getByText } = render( + const { getByText } = render( , ); - expect( - getByTestId(REWARDS_VIEW_SELECTORS.CLAIM_MODAL_TITLE), - ).toBeOnTheScreen(); expect(getByText('My Reward Title')).toBeOnTheScreen(); }); diff --git a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.tsx b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.tsx index 4dcbae1799d..2bff626153d 100644 --- a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.tsx +++ b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.tsx @@ -33,7 +33,7 @@ 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 BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import { ClaimRewardDto, SeasonRewardType, @@ -403,17 +403,7 @@ const EndOfSeasonClaimBottomSheet = ({ ); } - // default return title - return ( - - - {title} - - - ); + return null; }; const renderDescription = () => ( @@ -523,9 +513,11 @@ const EndOfSeasonClaimBottomSheet = ({ testID={REWARDS_VIEW_SELECTORS.CLAIM_MODAL} keyboardAvoidingViewEnabled={!needsKeyboardAvoiding} > - - {strings('rewards.end_of_season_rewards.reward_details')} - + {needsKeyboardAvoiding ? ( { +jest.mock('../../../../../component-library/components-temp/Skeleton', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return { diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonSummaryTile.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonSummaryTile.tsx index 3ec336612e2..1e2a3cabb93 100644 --- a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonSummaryTile.tsx +++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonSummaryTile.tsx @@ -1,6 +1,6 @@ import { Box } from '@metamask/design-system-react-native'; import React, { PropsWithChildren } from 'react'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; interface PreviousSeasonSummaryTileProps extends PropsWithChildren { twClassName?: string; diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonTile.test.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonTile.test.tsx new file mode 100644 index 00000000000..3c0e565cc1a --- /dev/null +++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonTile.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import PreviousSeasonTile from './PreviousSeasonTile'; +import Routes from '../../../../../constants/navigation/Routes'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +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.pill_complete': 'Complete', + }; + return translations[key] || key; + }, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +describe('PreviousSeasonTile', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns null when seasonName is not available', () => { + mockUseSelector.mockReturnValue(null); + + const { toJSON } = render(); + + expect(toJSON()).toBeNull(); + }); + + it('renders season name when seasonName is available', () => { + mockUseSelector.mockReturnValue('Season 1'); + + const { getByTestId } = render(); + + expect(getByTestId('previous-season-tile-name')).toHaveTextContent( + 'Season 1', + ); + }); + + it('renders the complete label', () => { + mockUseSelector.mockReturnValue('Season 1'); + + const { getByText } = render(); + + expect(getByText('Complete')).toBeOnTheScreen(); + }); + + it('renders background image', () => { + mockUseSelector.mockReturnValue('Season 1'); + + const { getByTestId } = render(); + + expect(getByTestId('previous-season-tile-background')).toBeOnTheScreen(); + }); + + it('renders foreground image', () => { + mockUseSelector.mockReturnValue('Season 1'); + + const { getByTestId } = render(); + + expect(getByTestId('previous-season-tile-image')).toBeOnTheScreen(); + }); + + it('navigates to PreviousSeasonView on press', () => { + mockUseSelector.mockReturnValue('Season 1'); + + const { getByTestId } = render(); + + fireEvent.press(getByTestId('previous-season-tile')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREVIOUS_SEASON_VIEW); + }); +}); diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonTile.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonTile.tsx new file mode 100644 index 00000000000..acbc940f752 --- /dev/null +++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonTile.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { Image, ImageBackground, Pressable } from 'react-native'; +import { useSelector } from 'react-redux'; +import { useNavigation } from '@react-navigation/native'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { selectSeasonName } from '../../../../../reducers/rewards/selectors'; +import Routes from '../../../../../constants/navigation/Routes'; +import introBg from '../../../../../images/rewards/rewards-onboarding-intro-bg.png'; +import intro from '../../../../../images/rewards/rewards-onboarding-intro.png'; +import { strings } from '../../../../../../locales/i18n'; + +const PreviousSeasonTile: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const seasonName = useSelector(selectSeasonName); + + if (!seasonName) { + return null; + } + + return ( + navigation.navigate(Routes.PREVIOUS_SEASON_VIEW)} + style={({ pressed }) => + tw.style( + 'rounded-xl overflow-hidden h-50 bg-muted', + pressed && 'opacity-70', + ) + } + testID="previous-season-tile" + > + + + + + {strings('rewards.campaign.pill_complete')} + + + {seasonName} + + + + + + + ); +}; + +export default PreviousSeasonTile; diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx index dd0027cdd40..41c83df18b7 100644 --- a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx +++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx @@ -182,7 +182,7 @@ jest.mock('@metamask/design-system-react-native', () => { }); // Mock Skeleton -jest.mock('../../../../../component-library/components/Skeleton', () => { +jest.mock('../../../../../component-library/components-temp/Skeleton', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return { diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.tsx index be27b673012..423cf523173 100644 --- a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.tsx +++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.tsx @@ -27,7 +27,7 @@ import { import { useUnlockedRewards } from '../../hooks/useUnlockedRewards'; import RewardsSeasonEndedNoUnlockedRewardsImage from '../../../../../images/rewards/rewards-season-ended-no-unlocked-rewards.svg'; import RewardsErrorBanner from '../RewardsErrorBanner'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import RewardItem from '../RewardItem/RewardItem'; import { useTheme } from '../../../../../util/theme'; diff --git a/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx b/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx index 82c4a648012..6d85a9d4e62 100644 --- a/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx +++ b/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx @@ -12,7 +12,7 @@ import { IconColor, IconName, } from '../../../../../component-library/components/Icons/Icon/Icon.types'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; interface CopyableFieldProps { label: string; diff --git a/app/components/UI/Rewards/components/ReferralDetails/ReferralStatsSection.test.tsx b/app/components/UI/Rewards/components/ReferralDetails/ReferralStatsSection.test.tsx index 67e40625038..554593e8ecd 100644 --- a/app/components/UI/Rewards/components/ReferralDetails/ReferralStatsSection.test.tsx +++ b/app/components/UI/Rewards/components/ReferralDetails/ReferralStatsSection.test.tsx @@ -9,7 +9,7 @@ jest.mock( ); // Mock the Skeleton component -jest.mock('../../../../../component-library/components/Skeleton', () => { +jest.mock('../../../../../component-library/components-temp/Skeleton', () => { const mockReact = jest.requireActual('react'); const RN = jest.requireActual('react-native'); diff --git a/app/components/UI/Rewards/components/ReferralDetails/ReferralStatsSection.tsx b/app/components/UI/Rewards/components/ReferralDetails/ReferralStatsSection.tsx index 7411ea4bab2..2215e3b6544 100644 --- a/app/components/UI/Rewards/components/ReferralDetails/ReferralStatsSection.tsx +++ b/app/components/UI/Rewards/components/ReferralDetails/ReferralStatsSection.tsx @@ -8,7 +8,7 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import MetamaskRewardsPointsImage from '../../../../../images/rewards/metamask-rewards-points.svg'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { SeasonWayToEarnSpecificReferralDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; import { strings } from '../../../../../../locales/i18n'; diff --git a/app/components/UI/Rewards/components/RewardPointsAnimation/index.tsx b/app/components/UI/Rewards/components/RewardPointsAnimation/index.tsx index 1049fd95815..c65cd27afd5 100644 --- a/app/components/UI/Rewards/components/RewardPointsAnimation/index.tsx +++ b/app/components/UI/Rewards/components/RewardPointsAnimation/index.tsx @@ -18,7 +18,7 @@ import { } from '../../hooks/useRewardsAnimation'; import styleSheet from './index.styles'; -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const RewardsIconAnimation = require('../../../../../animations/rewards_icon_animations.riv'); /** diff --git a/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.test.tsx b/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.test.tsx deleted file mode 100644 index 6648b94c8d6..00000000000 --- a/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.test.tsx +++ /dev/null @@ -1,756 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; -import { useSelector } from 'react-redux'; -import SeasonStatusSummary from './SeasonStatus'; -import { - selectSeasonStatusLoading, - selectBalanceTotal, - selectSeasonEndDate, - selectSeasonName, - selectSeasonStatusError, - selectSeasonStartDate, -} from '../../../../../reducers/rewards/selectors'; -import { formatNumber, formatTimeRemaining } from '../../utils/formatUtils'; - -// Mock react-redux -jest.mock('react-redux', () => ({ - useSelector: jest.fn(), -})); - -const mockUseSelector = useSelector as jest.MockedFunction; - -// Mock selectors -jest.mock('../../../../../reducers/rewards/selectors', () => ({ - selectSeasonStatusLoading: jest.fn(), - selectBalanceTotal: jest.fn(), - selectSeasonEndDate: jest.fn(), - selectSeasonName: jest.fn(), - selectSeasonStatusError: jest.fn(), - selectSeasonStartDate: jest.fn(), -})); - -// Mock formatUtils -jest.mock('../../utils/formatUtils', () => ({ - formatNumber: jest.fn((value: number | null) => - value === null || value === undefined ? '0' : value.toLocaleString(), - ), - formatTimeRemaining: jest.fn((endDate: Date) => { - const now = new Date('2024-06-15T12:00:00.000Z'); - const diff = endDate.getTime() - now.getTime(); - if (diff <= 0) return null; - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - return `${days}d ${hours}h ${minutes}m`; - }), -})); - -// Mock i18n -jest.mock('../../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => { - const translations: Record = { - 'rewards.season_status.points_earned': 'Points earned', - 'rewards.season_status_error.error_fetching_title': - "Season balance couldn't be loaded", - 'rewards.season_status_error.error_fetching_description': - 'Check your connection and try again.', - 'rewards.season_status_error.retry_button': 'Retry', - }; - return translations[key] || key; - }), -})); - -// Mock useSeasonStatus hook -const mockFetchSeasonStatus = jest.fn(); -jest.mock('../../hooks/useSeasonStatus', () => ({ - useSeasonStatus: () => ({ - fetchSeasonStatus: mockFetchSeasonStatus, - }), -})); - -// Mock useTheme -jest.mock('../../../../../util/theme', () => { - const { mockTheme } = jest.requireActual('../../../../../util/theme'); - return { - useTheme: jest.fn(() => mockTheme), - }; -}); - -// Mock Tailwind -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => { - const mockTw = jest.fn(() => ({})); - Object.assign(mockTw, { - style: jest.fn((styles) => { - if (typeof styles === 'object') { - return styles; - } - if (Array.isArray(styles)) { - return styles.reduce( - (acc, style) => ({ ...acc, ...style }), - {} as Record, - ); - } - return {}; - }), - }); - return mockTw; - }, -})); - -// Mock design system components -jest.mock('@metamask/design-system-react-native', () => { - const ReactActual = jest.requireActual('react'); - const { View, Text: RNText } = jest.requireActual('react-native'); - - const Box = ({ - children, - testID, - style, - ...props - }: { - children?: React.ReactNode; - testID?: string; - style?: Record; - [key: string]: unknown; - }) => ReactActual.createElement(View, { testID, style, ...props }, children); - - const TextComponent = ({ - children, - testID, - ...props - }: { - children?: React.ReactNode; - testID?: string; - [key: string]: unknown; - }) => ReactActual.createElement(RNText, { testID, ...props }, children); - - return { - Box, - Text: TextComponent, - TextVariant: { - HeadingMd: 'HeadingMd', - BodyMd: 'BodyMd', - BodySm: 'BodySm', - }, - FontWeight: { - Bold: 'bold', - Medium: 'medium', - }, - BoxFlexDirection: { - Row: 'row', - Column: 'column', - }, - BoxAlignItems: { - Center: 'center', - FlexEnd: 'flex-end', - }, - BoxJustifyContent: { - SpaceBetween: 'space-between', - }, - }; -}); - -// Mock SVG image -jest.mock('../../../../../images/rewards/metamask-rewards-points.svg', () => { - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return function MockSvg(props: Record) { - return ReactActual.createElement(View, { - testID: 'metamask-rewards-points-image', - ...props, - }); - }; -}); - -// Mock Skeleton component -jest.mock('../../../../../component-library/components/Skeleton', () => { - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return { - Skeleton: ({ height, width }: { height: number; width: string }) => - ReactActual.createElement(View, { - testID: 'skeleton-loader', - style: { height, width }, - }), - }; -}); - -// Mock RewardsErrorBanner -jest.mock('../RewardsErrorBanner', () => { - const ReactActual = jest.requireActual('react'); - const { View, Text: RNText, Pressable } = jest.requireActual('react-native'); - const RewardsErrorBanner = ({ - title, - description, - onConfirm, - confirmButtonLabel, - }: { - title: string; - description: string; - onConfirm?: () => void; - confirmButtonLabel?: string; - }) => - ReactActual.createElement( - View, - { testID: 'rewards-error-banner' }, - ReactActual.createElement(RNText, { testID: 'error-title' }, title), - ReactActual.createElement( - RNText, - { testID: 'error-description' }, - description, - ), - onConfirm && - ReactActual.createElement( - Pressable, - { onPress: onConfirm, testID: 'error-retry-button' }, - ReactActual.createElement( - RNText, - null, - confirmButtonLabel || 'Confirm', - ), - ), - ); - return RewardsErrorBanner; -}); - -describe('SeasonStatusSummary', () => { - const mockFormatNumber = formatNumber as jest.MockedFunction< - typeof formatNumber - >; - const mockFormatTimeRemaining = formatTimeRemaining as jest.MockedFunction< - typeof formatTimeRemaining - >; - - beforeEach(() => { - jest.clearAllMocks(); - mockFetchSeasonStatus.mockClear(); - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-06-15T12:00:00.000Z')); - - // Default mock implementation - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - // Reset format mocks - mockFormatNumber.mockImplementation((value: number | null) => - value === null || value === undefined ? '0' : value.toLocaleString(), - ); - mockFormatTimeRemaining.mockImplementation(() => '10d 5h 30m'); - }); - - afterEach(() => { - jest.useRealTimers(); - jest.resetAllMocks(); - }); - - describe('useSelector calls', () => { - it('calls selectSeasonStatusLoading selector', () => { - render(); - - expect(mockUseSelector).toHaveBeenCalledWith(selectSeasonStatusLoading); - }); - - it('calls selectSeasonStatusError selector', () => { - render(); - - expect(mockUseSelector).toHaveBeenCalledWith(selectSeasonStatusError); - }); - - it('calls selectSeasonStartDate selector', () => { - render(); - - expect(mockUseSelector).toHaveBeenCalledWith(selectSeasonStartDate); - }); - - it('calls selectSeasonEndDate selector', () => { - render(); - - expect(mockUseSelector).toHaveBeenCalledWith(selectSeasonEndDate); - }); - - it('calls selectSeasonName selector', () => { - render(); - - expect(mockUseSelector).toHaveBeenCalledWith(selectSeasonName); - }); - - it('calls selectBalanceTotal selector', () => { - render(); - - expect(mockUseSelector).toHaveBeenCalledWith(selectBalanceTotal); - }); - }); - - describe('loading state', () => { - it('renders skeleton loader when loading', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return true; - return undefined; - }); - - const { getByTestId } = render(); - - expect(getByTestId('skeleton-loader')).toBeOnTheScreen(); - }); - - it('does not render points image when loading', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return true; - return undefined; - }); - - const { queryByTestId } = render(); - - expect(queryByTestId('metamask-rewards-points-image')).toBeNull(); - }); - - it('does not render season name when loading', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return true; - if (selector === selectSeasonName) return 'Season 1'; - return undefined; - }); - - const { queryByText } = render(); - - expect(queryByText('Season 1')).toBeNull(); - }); - }); - - describe('error state', () => { - it('renders error banner when error exists and no season start date', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return true; - if (selector === selectSeasonStartDate) return null; - return undefined; - }); - - const { getByTestId } = render(); - - expect(getByTestId('rewards-error-banner')).toBeOnTheScreen(); - }); - - it('renders error title in error banner', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return true; - if (selector === selectSeasonStartDate) return null; - return undefined; - }); - - const { getByTestId } = render(); - - expect(getByTestId('error-title')).toBeOnTheScreen(); - }); - - it('renders error description in error banner', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return true; - if (selector === selectSeasonStartDate) return null; - return undefined; - }); - - const { getByTestId } = render(); - - expect(getByTestId('error-description')).toBeOnTheScreen(); - }); - - it('renders retry button in error banner', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return true; - if (selector === selectSeasonStartDate) return null; - return undefined; - }); - - const { getByTestId } = render(); - - expect(getByTestId('error-retry-button')).toBeOnTheScreen(); - }); - - it('calls fetchSeasonStatus when retry button is pressed', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return true; - if (selector === selectSeasonStartDate) return null; - return undefined; - }); - - const { getByTestId } = render(); - - const retryButton = getByTestId('error-retry-button'); - fireEvent.press(retryButton); - - expect(mockFetchSeasonStatus).toHaveBeenCalledTimes(1); - }); - - it('does not render error banner when error exists but season start date is present', () => { - // When there's an error but we have cached data (seasonStartDate exists), - // component should render the normal state - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return true; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { getByTestId, queryByTestId } = render(); - - // Normal state renders the points image instead of error banner - expect(getByTestId('metamask-rewards-points-image')).toBeOnTheScreen(); - expect(queryByTestId('rewards-error-banner')).toBeNull(); - }); - - it('renders normal content when there is no error', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { getByTestId } = render(); - - expect(getByTestId('metamask-rewards-points-image')).toBeOnTheScreen(); - }); - - it('does not render points image when error state shows', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return true; - if (selector === selectSeasonStartDate) return null; - return undefined; - }); - - const { queryByTestId } = render(); - - expect(queryByTestId('metamask-rewards-points-image')).toBeNull(); - }); - }); - - describe('normal state - points display', () => { - it('renders points image', () => { - const { getByTestId } = render(); - - expect(getByTestId('metamask-rewards-points-image')).toBeOnTheScreen(); - }); - - it('displays formatted balance total', () => { - mockFormatNumber.mockReturnValue('1,500'); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectBalanceTotal) return 1500; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - return undefined; - }); - - const { getByText } = render(); - - expect(getByText('1,500')).toBeOnTheScreen(); - }); - - it('calls formatNumber with balance total', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectBalanceTotal) return 2500; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - return undefined; - }); - - render(); - - expect(mockFormatNumber).toHaveBeenCalledWith(2500); - }); - - it('renders points section with balance total and season info', () => { - // The points section contains balance total and points label - // We verify the structure renders correctly - const { getByText } = render(); - - // Balance total is visible - expect(getByText('1,500')).toBeOnTheScreen(); - - // Season info is visible - expect(getByText('Season 1')).toBeOnTheScreen(); - - // Time remaining is shown - expect(getByText('10d 5h 30m')).toBeOnTheScreen(); - }); - }); - - describe('normal state - season info display', () => { - it('displays season name when provided', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonName) return 'Summer Season'; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { getByText } = render(); - - expect(getByText('Summer Season')).toBeOnTheScreen(); - }); - - it('does not display season name when null', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonName) return null; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { queryByText } = render(); - - expect(queryByText('Summer Season')).toBeNull(); - }); - - it('does not display season name text when value is empty string', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonName) return ''; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { queryByText } = render(); - - // Season 1 should not appear since it's set to empty string - expect(queryByText('Season 1')).toBeNull(); - }); - - it('displays time remaining when season end date is provided', () => { - mockFormatTimeRemaining.mockReturnValue('15d 8h 22m'); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonEndDate) return '2024-07-01'; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { getByText } = render(); - - expect(getByText('15d 8h 22m')).toBeOnTheScreen(); - }); - - it('does not display time remaining when season end date is null', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonEndDate) return null; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { queryByText } = render(); - - expect(queryByText(/d.*h.*m/)).toBeNull(); - }); - - it('does not display time remaining when formatTimeRemaining returns null', () => { - mockFormatTimeRemaining.mockReturnValue(null); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonEndDate) return '2024-01-01'; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { queryByText } = render(); - - expect(queryByText(/d.*h.*m/)).toBeNull(); - }); - }); - - describe('edge cases', () => { - it('renders with zero balance', () => { - mockFormatNumber.mockReturnValue('0'); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectBalanceTotal) return 0; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - return undefined; - }); - - const { getByText } = render(); - - expect(getByText('0')).toBeOnTheScreen(); - }); - - it('renders with null balance', () => { - mockFormatNumber.mockReturnValue('0'); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectBalanceTotal) return null; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - return undefined; - }); - - const { getByText } = render(); - - expect(mockFormatNumber).toHaveBeenCalledWith(null); - expect(getByText('0')).toBeOnTheScreen(); - }); - - it('renders with large balance', () => { - mockFormatNumber.mockReturnValue('1,000,000'); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectBalanceTotal) return 1000000; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - return undefined; - }); - - const { getByText } = render(); - - expect(getByText('1,000,000')).toBeOnTheScreen(); - }); - - it('renders correctly when only loading state changes', () => { - // First render with loading - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return true; - return undefined; - }); - - const { rerender, getByTestId, queryByTestId } = render( - , - ); - - expect(getByTestId('skeleton-loader')).toBeOnTheScreen(); - - // Then update to not loading - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - mockFormatNumber.mockReturnValue('1,500'); - mockFormatTimeRemaining.mockReturnValue('10d 5h 30m'); - - rerender(); - - expect(queryByTestId('skeleton-loader')).toBeNull(); - expect(getByTestId('metamask-rewards-points-image')).toBeOnTheScreen(); - }); - }); - - describe('timeRemaining calculation', () => { - it('calls formatTimeRemaining with Date object from season end date string', () => { - const endDateString = '2024-12-31T23:59:59.000Z'; - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonEndDate) return endDateString; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - render(); - - expect(mockFormatTimeRemaining).toHaveBeenCalledWith( - new Date(endDateString), - ); - }); - - it('does not call formatTimeRemaining when season end date is null', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonEndDate) return null; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - render(); - - expect(mockFormatTimeRemaining).not.toHaveBeenCalled(); - }); - }); - - describe('component rendering without crashing', () => { - it('renders without crashing with minimal props', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return null; - if (selector === selectSeasonName) return null; - if (selector === selectBalanceTotal) return null; - return undefined; - }); - - expect(() => render()).not.toThrow(); - }); - - it('renders without crashing with all values present', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 5000; - return undefined; - }); - - expect(() => render()).not.toThrow(); - }); - }); -}); diff --git a/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.tsx b/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.tsx deleted file mode 100644 index f7602ff34d8..00000000000 --- a/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React from 'react'; -import { - Box, - BoxFlexDirection, - BoxAlignItems, - BoxJustifyContent, - TextVariant, - Text, - FontWeight, -} from '@metamask/design-system-react-native'; -import { strings } from '../../../../../../locales/i18n'; -import { useTheme } from '../../../../../util/theme'; -import MetamaskRewardsPointsImage from '../../../../../images/rewards/metamask-rewards-points.svg'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; -import { useSelector } from 'react-redux'; -import { - selectSeasonStatusLoading, - selectBalanceTotal, - selectSeasonEndDate, - selectSeasonName, - selectSeasonStatusError, - selectSeasonStartDate, -} from '../../../../../reducers/rewards/selectors'; -import { formatNumber, formatTimeRemaining } from '../../utils/formatUtils'; -import RewardsErrorBanner from '../RewardsErrorBanner'; -import { useSeasonStatus } from '../../hooks/useSeasonStatus'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; - -const SeasonStatus: React.FC = () => { - const theme = useTheme(); - const { fetchSeasonStatus } = useSeasonStatus({ - onlyForExplicitFetch: true, - }); - const tw = useTailwind(); - const balanceTotal = useSelector(selectBalanceTotal); - const seasonStatusLoading = useSelector(selectSeasonStatusLoading); - const seasonStatusError = useSelector(selectSeasonStatusError); - const seasonStartDate = useSelector(selectSeasonStartDate); - const seasonEndDate = useSelector(selectSeasonEndDate); - const seasonName = useSelector(selectSeasonName); - - const timeRemaining = React.useMemo(() => { - if (!seasonEndDate) { - return null; - } - return formatTimeRemaining(new Date(seasonEndDate)); - }, [seasonEndDate]); - - if (seasonStatusLoading) { - return ; - } - - if (seasonStatusError && !seasonStartDate) { - return ( - { - fetchSeasonStatus(); - }} - confirmButtonLabel={strings('rewards.season_status_error.retry_button')} - /> - ); - } - - return ( - - {/* Left side - Points */} - - - - - {formatNumber(balanceTotal)} - - - {strings('rewards.season_status.points_earned')} - - - - - {/* Right side - Season info */} - - {!!seasonName && ( - - {seasonName} - - )} - {!!timeRemaining && ( - - {timeRemaining} - - )} - - - ); -}; - -export default SeasonStatus; diff --git a/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx b/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx index f51bd4df51a..4b5b5d366cb 100644 --- a/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx +++ b/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx @@ -152,7 +152,7 @@ jest.mock('../../../../../component-library/components/Buttons/Button', () => { }; }); -jest.mock('../../../../../component-library/components/Skeleton', () => { +jest.mock('../../../../../component-library/components-temp/Skeleton', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); diff --git a/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.tsx b/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.tsx index b931c3e680c..4598a22787c 100644 --- a/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.tsx +++ b/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.tsx @@ -23,7 +23,7 @@ import { REFERRAL_CODE_LENGTH, } from '../../hooks/useValidateReferralCode'; import { useApplyReferralCode } from '../../hooks/useApplyReferralCode'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { useTheme } from '../../../../../util/theme'; import RewardsErrorBanner from '../RewardsErrorBanner'; diff --git a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx index 9cd932415b8..99f5247950a 100644 --- a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx +++ b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx @@ -211,6 +211,10 @@ jest.mock('@metamask/design-system-react-native', () => { FontWeight: { Medium: 'medium', }, + ButtonVariant: { + Primary: 'primary', + Secondary: 'secondary', + }, ButtonVariants: { Primary: 'primary', Secondary: 'secondary', @@ -266,7 +270,7 @@ jest.mock('@metamask/design-system-react-native', () => { }); // Mock Skeleton component -jest.mock('../../../../../component-library/components/Skeleton', () => { +jest.mock('../../../../../component-library/components-temp/Skeleton', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); diff --git a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.tsx b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.tsx index c8487cfd368..2e2b767eb83 100644 --- a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.tsx +++ b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.tsx @@ -10,6 +10,8 @@ import { BoxFlexDirection, BoxAlignItems, BoxJustifyContent, + Button, + ButtonVariant, ButtonBase, Icon, IconName, @@ -17,13 +19,10 @@ import { IconColor, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { useRewardOptinSummary } from '../../hooks/useRewardOptinSummary'; import { selectAvatarAccountType } from '../../../../../selectors/settings'; import { selectInternalAccountsByGroupId } from '../../../../../selectors/multichainAccounts/accounts'; -import Button, { - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; import RewardSettingsAccountGroup from './RewardSettingsAccountGroup'; import ReferredByCodeSection from './ReferredByCodeSection'; import { RewardSettingsAccountGroupListFlatListItem } from './types'; @@ -137,12 +136,12 @@ const AccountProgressSection: React.FC = memo( {showAddAllButton && ( )} ); diff --git a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.test.tsx b/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.test.tsx deleted file mode 100644 index e91ea372f07..00000000000 --- a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.test.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import SnapshotTile from './SnapshotTile'; -import type { SnapshotDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { getSnapshotStatusInfo } from './SnapshotTile.utils'; - -// Mock design system -jest.mock('@metamask/design-system-react-native', () => ({ - Box: 'Box', - BoxFlexDirection: { Column: 'Column', Row: 'Row' }, - BoxAlignItems: { Center: 'Center' }, - BoxJustifyContent: { Between: 'Between' }, - Text: 'Text', - TextVariant: { BodySm: 'BodySm', BodyMd: 'BodyMd', HeadingLg: 'HeadingLg' }, - Icon: 'Icon', - IconSize: { Sm: 'Sm' }, - IconName: { Clock: 'Clock', Speed: 'Speed' }, -})); - -// Mock Tailwind -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => ({ style: jest.fn(() => ({})) }), -})); - -// Mock the utils to control status output -jest.mock('./SnapshotTile.utils', () => ({ - getSnapshotStatusInfo: jest.fn(), -})); - -// Mock RewardsThemeImageComponent -jest.mock('../ThemeImageComponent', () => ({ - __esModule: true, - default: 'RewardsThemeImageComponent', -})); - -const mockGetSnapshotStatusInfo = getSnapshotStatusInfo as jest.MockedFunction< - typeof getSnapshotStatusInfo ->; - -/** - * Creates a test snapshot with default values that can be overridden - */ -function createTestSnapshot(overrides: Partial = {}): SnapshotDto { - return { - id: 'snapshot-1', - seasonId: 'season-1', - name: 'Test Snapshot Prize', - description: 'Test description', - tokenSymbol: 'TEST', - tokenAmount: '1000', - tokenChainId: '1', - receivingBlockchain: 'ethereum', - opensAt: '2024-01-01T00:00:00Z', - closesAt: '2024-01-31T23:59:59Z', - calculatedAt: undefined, - distributedAt: undefined, - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - ...overrides, - }; -} - -describe('SnapshotTile', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'live', - statusLabel: 'Live Now', - statusDescription: 'Ends Mar 15, 2:30 PM', - statusDescriptionIcon: 'Clock' as never, - }); - }); - - it('renders snapshot name as prize display', () => { - const snapshot = createTestSnapshot({ name: 'Monad Airdrop' }); - - const { getByText } = render(); - - expect(getByText('Monad Airdrop')).toBeOnTheScreen(); - }); - - it('renders status description text', () => { - const snapshot = createTestSnapshot(); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'live', - statusLabel: 'Live Now', - statusDescription: 'Ends Mar 15, 2:30 PM', - statusDescriptionIcon: 'Clock' as never, - }); - - const { getByText } = render(); - - expect(getByText('Ends Mar 15, 2:30 PM')).toBeOnTheScreen(); - }); - - it('renders status label text', () => { - const snapshot = createTestSnapshot(); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'live', - statusLabel: 'Live Now', - statusDescription: 'Ends Mar 15, 2:30 PM', - statusDescriptionIcon: 'Clock' as never, - }); - - const { getByText } = render(); - - expect(getByText('Live Now')).toBeOnTheScreen(); - }); - - it('displays ActivityIndicator when status is calculating', () => { - const snapshot = createTestSnapshot(); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'calculating', - statusLabel: 'Calculating', - statusDescription: 'Results coming soon', - statusDescriptionIcon: 'Loading' as never, - }); - - const { UNSAFE_getByType } = render(); - const { ActivityIndicator } = jest.requireActual('react-native'); - - expect(UNSAFE_getByType(ActivityIndicator)).toBeDefined(); - }); - - it('displays Icon when status is not calculating', () => { - const snapshot = createTestSnapshot(); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'live', - statusLabel: 'Live Now', - statusDescription: 'Ends Mar 15, 2:30 PM', - statusDescriptionIcon: 'Clock' as never, - }); - - const { UNSAFE_queryByType, UNSAFE_getByType } = render( - , - ); - const { ActivityIndicator } = jest.requireActual('react-native'); - - expect(UNSAFE_queryByType(ActivityIndicator)).toBeNull(); - expect(UNSAFE_getByType('Icon' as never)).toBeDefined(); - }); - - it('renders background image component', () => { - const snapshot = createTestSnapshot({ - backgroundImage: { - lightModeUrl: 'https://example.com/custom-light.png', - darkModeUrl: 'https://example.com/custom-dark.png', - }, - }); - - const { UNSAFE_getByType } = render(); - - expect( - UNSAFE_getByType('RewardsThemeImageComponent' as never), - ).toBeDefined(); - }); -}); diff --git a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.tsx b/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.tsx deleted file mode 100644 index 074683205dd..00000000000 --- a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useMemo } from 'react'; -import { View, ActivityIndicator } from 'react-native'; -import { - Box, - BoxFlexDirection, - BoxAlignItems, - BoxJustifyContent, - Text, - TextVariant, - Icon, - IconSize, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import type { SnapshotDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import RewardsThemeImageComponent from '../ThemeImageComponent'; -import { getSnapshotStatusInfo } from './SnapshotTile.utils'; - -interface SnapshotTileProps { - /** - * The snapshot data to display - */ - snapshot: SnapshotDto; -} - -/** - * SnapshotTile component displays snapshot/airdrop information with status. - * - * Shows: - * - Background image - * - Status pill (Live now, Up next, Calculating, Results Ready, Complete) - * - Status label with date - * - Prize information - */ -const SnapshotTile: React.FC = ({ snapshot }) => { - const tw = useTailwind(); - - const { status, statusLabel, statusDescription, statusDescriptionIcon } = - useMemo(() => getSnapshotStatusInfo(snapshot), [snapshot]); - - // Format prize display (e.g., "$50,000 Monad") - const prizeDisplay = useMemo( - () => - // For now, just show the token symbol and name - // The actual formatting would depend on how we want to display the amount - `${snapshot.name}`, - [snapshot], - ); - - return ( - - {/* Background Image */} - - - - - {/* Content */} - - {/* Status Description Icon and Text */} - - {status === 'calculating' ? ( - - ) : ( - - )} - - {statusDescription} - - - - {/* Bottom Content */} - - - {statusLabel} - - - {/* Prize Info */} - - {prizeDisplay} - - - - - ); -}; - -export default SnapshotTile; diff --git a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.utils.test.ts b/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.utils.test.ts deleted file mode 100644 index 5aa5c7dde93..00000000000 --- a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.utils.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { IconName } from '@metamask/design-system-react-native'; -import type { - SnapshotDto, - SnapshotStatus, -} from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { - getSnapshotStatus, - formatSnapshotStatusLabel, - getSnapshotPillLabel, - getSnapshotStatusInfo, -} from './SnapshotTile.utils'; - -// Mock the strings function - must return the key-based string for assertions -jest.mock('../../../../../../locales/i18n', () => ({ - strings: (key: string, params?: { date?: string }) => { - const keyPart = key.split('.').pop() || key; - if (params?.date) { - return `${keyPart}: ${params.date}`; - } - return keyPart; - }, -})); - -/** - * Creates a test snapshot with sensible defaults. - * @param overrides - Partial SnapshotDto to override defaults - * @returns Complete SnapshotDto for testing - */ -const createTestSnapshot = ( - overrides: Partial = {}, -): SnapshotDto => ({ - id: 'test-snapshot-id', - seasonId: 'test-season-id', - name: 'Test Snapshot', - description: 'Test description', - tokenSymbol: 'TEST', - tokenAmount: '1000000000000000000', - tokenChainId: '1', - tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - ...overrides, -}); - -/** - * Helper to format a date for comparison in the America/Toronto timezone - * The production code uses new Date().getHours() which returns local time - */ -const getExpectedFormattedDate = (isoString: string): string => { - const date = new Date(isoString); - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - const month = months[date.getMonth()]; - const day = date.getDate(); - const hours = date.getHours(); - const minutes = date.getMinutes(); - const hour12 = hours % 12 || 12; - const ampm = hours >= 12 ? 'PM' : 'AM'; - const paddedMinutes = minutes.toString().padStart(2, '0'); - - return `${month} ${day}, ${hour12}:${paddedMinutes} ${ampm}`; -}; - -describe('SnapshotTile.utils', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - }); - - afterEach(() => { - jest.useRealTimers(); - jest.resetAllMocks(); - }); - - describe('getSnapshotStatus', () => { - it('returns "complete" when distributedAt is set', () => { - jest.setSystemTime(new Date('2025-03-20T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: '2025-03-16T00:00:00.000Z', - distributedAt: '2025-03-20T00:00:00.000Z', - }); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('complete'); - }); - - it('returns "distributing" when calculatedAt is set but distributedAt is not', () => { - jest.setSystemTime(new Date('2025-03-18T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: '2025-03-16T00:00:00.000Z', - distributedAt: undefined, - }); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('distributing'); - }); - - it('returns "upcoming" when current time is before opensAt', () => { - jest.setSystemTime(new Date('2025-02-28T00:00:00.000Z')); - const snapshot = createTestSnapshot(); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('upcoming'); - }); - - it('returns "live" when current time is between opensAt and closesAt', () => { - jest.setSystemTime(new Date('2025-03-10T00:00:00.000Z')); - const snapshot = createTestSnapshot(); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('live'); - }); - - it('returns "live" when current time equals opensAt exactly', () => { - jest.setSystemTime(new Date('2025-03-01T00:00:00.000Z')); - const snapshot = createTestSnapshot(); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('live'); - }); - - it('returns "calculating" when current time is past closesAt and calculatedAt is not set', () => { - jest.setSystemTime(new Date('2025-03-16T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: undefined, - distributedAt: undefined, - }); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('calculating'); - }); - - it('returns "calculating" when current time equals closesAt exactly', () => { - jest.setSystemTime(new Date('2025-03-15T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: undefined, - distributedAt: undefined, - }); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('calculating'); - }); - - it('prioritizes "complete" over other statuses when distributedAt is set', () => { - jest.setSystemTime(new Date('2025-02-01T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: '2025-03-16T00:00:00.000Z', - distributedAt: '2025-03-20T00:00:00.000Z', - }); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('complete'); - }); - - it('prioritizes "distributing" over date-based statuses when calculatedAt is set', () => { - jest.setSystemTime(new Date('2025-02-01T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: '2025-03-16T00:00:00.000Z', - distributedAt: undefined, - }); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('distributing'); - }); - }); - - describe('formatSnapshotStatusLabel', () => { - it('returns starts_date label with formatted opensAt for upcoming status', () => { - const opensAtDate = '2025-03-01T14:30:00.000Z'; - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = formatSnapshotStatusLabel('upcoming', snapshot); - - expect(result).toBe(`starts_date: ${expectedDate}`); - }); - - it('returns ends_date label with formatted closesAt for live status', () => { - const closesAtDate = '2025-03-15T09:00:00.000Z'; - const snapshot = createTestSnapshot({ - closesAt: closesAtDate, - }); - const expectedDate = getExpectedFormattedDate(closesAtDate); - - const result = formatSnapshotStatusLabel('live', snapshot); - - expect(result).toBe(`ends_date: ${expectedDate}`); - }); - - it('returns results_coming_soon label for calculating status', () => { - const snapshot = createTestSnapshot(); - - const result = formatSnapshotStatusLabel('calculating', snapshot); - - expect(result).toBe('results_coming_soon'); - }); - - it('returns tokens_on_the_way label for distributing status', () => { - const snapshot = createTestSnapshot(); - - const result = formatSnapshotStatusLabel('distributing', snapshot); - - expect(result).toBe('tokens_on_the_way'); - }); - - it('returns formatted distributedAt date for complete status', () => { - const distributedAtDate = '2025-03-20T16:45:00.000Z'; - const snapshot = createTestSnapshot({ - distributedAt: distributedAtDate, - }); - const expectedDate = getExpectedFormattedDate(distributedAtDate); - - const result = formatSnapshotStatusLabel('complete', snapshot); - - expect(result).toBe(expectedDate); - }); - - it('returns formatted current date for complete status when distributedAt is undefined', () => { - const currentDate = '2025-04-01T10:00:00.000Z'; - jest.setSystemTime(new Date(currentDate)); - const snapshot = createTestSnapshot({ - distributedAt: undefined, - }); - const expectedDate = getExpectedFormattedDate(currentDate); - - const result = formatSnapshotStatusLabel('complete', snapshot); - - expect(result).toBe(expectedDate); - }); - - it('returns empty string for unknown status', () => { - const snapshot = createTestSnapshot(); - - const result = formatSnapshotStatusLabel( - 'unknown' as SnapshotStatus, - snapshot, - ); - - expect(result).toBe(''); - }); - - it('formats midnight correctly (12:00 AM)', () => { - const opensAtDate = '2025-03-01T05:00:00.000Z'; // Midnight in America/Toronto (UTC-5) - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = formatSnapshotStatusLabel('upcoming', snapshot); - - expect(result).toBe(`starts_date: ${expectedDate}`); - }); - - it('formats noon correctly (12:00 PM)', () => { - const opensAtDate = '2025-03-01T17:00:00.000Z'; // Noon in America/Toronto (UTC-5) - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = formatSnapshotStatusLabel('upcoming', snapshot); - - expect(result).toBe(`starts_date: ${expectedDate}`); - }); - - it('pads single-digit minutes with leading zero', () => { - const opensAtDate = '2025-03-01T19:05:00.000Z'; - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = formatSnapshotStatusLabel('upcoming', snapshot); - - expect(result).toBe(`starts_date: ${expectedDate}`); - expect(result).toContain(':05'); - }); - }); - - describe('getSnapshotPillLabel', () => { - it.each([ - ['upcoming', 'pill_up_next'], - ['live', 'pill_live_now'], - ['calculating', 'pill_calculating'], - ['distributing', 'pill_results_ready'], - ['complete', 'pill_complete'], - ] as const)('returns %s for %s status', (status, expectedLabel) => { - const result = getSnapshotPillLabel(status); - - expect(result).toBe(expectedLabel); - }); - - it('returns empty string for unknown status', () => { - const result = getSnapshotPillLabel('unknown' as SnapshotStatus); - - expect(result).toBe(''); - }); - }); - - describe('getSnapshotStatusInfo', () => { - it('returns complete status info object for upcoming snapshot', () => { - jest.setSystemTime(new Date('2025-02-28T00:00:00.000Z')); - const opensAtDate = '2025-03-01T14:30:00.000Z'; - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = getSnapshotStatusInfo(snapshot); - - expect(result).toEqual({ - status: 'upcoming', - statusLabel: 'pill_up_next', - statusDescription: `starts_date: ${expectedDate}`, - statusDescriptionIcon: IconName.Speed, - }); - }); - - it('returns complete status info object for live snapshot', () => { - jest.setSystemTime(new Date('2025-03-10T00:00:00.000Z')); - const closesAtDate = '2025-03-15T09:00:00.000Z'; - const snapshot = createTestSnapshot({ - closesAt: closesAtDate, - }); - const expectedDate = getExpectedFormattedDate(closesAtDate); - - const result = getSnapshotStatusInfo(snapshot); - - expect(result).toEqual({ - status: 'live', - statusLabel: 'pill_live_now', - statusDescription: `ends_date: ${expectedDate}`, - statusDescriptionIcon: IconName.Clock, - }); - }); - - it('returns complete status info object for calculating snapshot', () => { - jest.setSystemTime(new Date('2025-03-16T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: undefined, - distributedAt: undefined, - }); - - const result = getSnapshotStatusInfo(snapshot); - - expect(result).toEqual({ - status: 'calculating', - statusLabel: 'pill_calculating', - statusDescription: 'results_coming_soon', - statusDescriptionIcon: IconName.Loading, - }); - }); - - it('returns complete status info object for distributing snapshot', () => { - jest.setSystemTime(new Date('2025-03-18T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: '2025-03-16T00:00:00.000Z', - distributedAt: undefined, - }); - - const result = getSnapshotStatusInfo(snapshot); - - expect(result).toEqual({ - status: 'distributing', - statusLabel: 'pill_results_ready', - statusDescription: 'tokens_on_the_way', - statusDescriptionIcon: IconName.Send, - }); - }); - - it('returns complete status info object for complete snapshot', () => { - jest.setSystemTime(new Date('2025-03-25T00:00:00.000Z')); - const distributedAtDate = '2025-03-20T16:45:00.000Z'; - const snapshot = createTestSnapshot({ - calculatedAt: '2025-03-16T00:00:00.000Z', - distributedAt: distributedAtDate, - }); - const expectedDate = getExpectedFormattedDate(distributedAtDate); - - const result = getSnapshotStatusInfo(snapshot); - - expect(result).toEqual({ - status: 'complete', - statusLabel: 'pill_complete', - statusDescription: expectedDate, - statusDescriptionIcon: IconName.Confirmation, - }); - }); - - it('integrates all utility functions correctly', () => { - jest.setSystemTime(new Date('2025-03-10T12:00:00.000Z')); - const closesAtDate = '2025-03-15T18:30:00.000Z'; - const snapshot = createTestSnapshot({ - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: closesAtDate, - }); - const expectedDate = getExpectedFormattedDate(closesAtDate); - - const result = getSnapshotStatusInfo(snapshot); - - expect(result.status).toBe('live'); - expect(result.statusLabel).toBe('pill_live_now'); - expect(result.statusDescription).toBe(`ends_date: ${expectedDate}`); - expect(result.statusDescriptionIcon).toBe(IconName.Clock); - }); - }); - - describe('date formatting edge cases', () => { - it('formats all months correctly', () => { - const months = [ - { date: '2025-01-15T12:00:00.000Z', expected: 'Jan' }, - { date: '2025-02-15T12:00:00.000Z', expected: 'Feb' }, - { date: '2025-03-15T12:00:00.000Z', expected: 'Mar' }, - { date: '2025-04-15T12:00:00.000Z', expected: 'Apr' }, - { date: '2025-05-15T12:00:00.000Z', expected: 'May' }, - { date: '2025-06-15T12:00:00.000Z', expected: 'Jun' }, - { date: '2025-07-15T12:00:00.000Z', expected: 'Jul' }, - { date: '2025-08-15T12:00:00.000Z', expected: 'Aug' }, - { date: '2025-09-15T12:00:00.000Z', expected: 'Sep' }, - { date: '2025-10-15T12:00:00.000Z', expected: 'Oct' }, - { date: '2025-11-15T12:00:00.000Z', expected: 'Nov' }, - { date: '2025-12-15T12:00:00.000Z', expected: 'Dec' }, - ]; - - months.forEach(({ date, expected }) => { - const snapshot = createTestSnapshot({ - distributedAt: date, - }); - - const result = formatSnapshotStatusLabel('complete', snapshot); - - expect(result).toContain(expected); - }); - }); - - it('handles AM hours correctly (before noon)', () => { - const opensAtDate = '2025-03-01T15:59:00.000Z'; // 10:59 AM in America/Toronto (UTC-5) - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = formatSnapshotStatusLabel('upcoming', snapshot); - - expect(result).toBe(`starts_date: ${expectedDate}`); - expect(result).toContain('AM'); - }); - - it('handles PM hours correctly (after noon)', () => { - const opensAtDate = '2025-03-01T18:01:00.000Z'; // 1:01 PM in America/Toronto (UTC-5) - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = formatSnapshotStatusLabel('upcoming', snapshot); - - expect(result).toBe(`starts_date: ${expectedDate}`); - expect(result).toContain('PM'); - }); - - it('handles late night hours correctly', () => { - const opensAtDate = '2025-03-02T04:59:00.000Z'; // 11:59 PM in America/Toronto (UTC-5) - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = formatSnapshotStatusLabel('upcoming', snapshot); - - expect(result).toBe(`starts_date: ${expectedDate}`); - }); - }); -}); diff --git a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.utils.ts b/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.utils.ts deleted file mode 100644 index abe56857fd9..00000000000 --- a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.utils.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { IconName } from '@metamask/design-system-react-native'; -import type { - SnapshotDto, - SnapshotStatus, -} from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { strings } from '../../../../../../locales/i18n'; - -/** - * Derives the status of a snapshot based on its date fields. - * - * Status logic: - * - upcoming: now < opensAt - * - live: opensAt <= now < closesAt - * - calculating: closesAt <= now && !calculatedAt - * - distributing: calculatedAt && !distributedAt - * - complete: distributedAt is set - * - * @param snapshot - The snapshot data - * @returns The derived status - */ -export function getSnapshotStatus(snapshot: SnapshotDto): SnapshotStatus { - const now = new Date(); - const opensAt = new Date(snapshot.opensAt); - const closesAt = new Date(snapshot.closesAt); - - // Check if distribution is complete - if (snapshot.distributedAt) { - return 'complete'; - } - - // Check if results are calculated but not distributed yet - if (snapshot.calculatedAt) { - return 'distributing'; - } - - // Check if snapshot is still upcoming - if (now < opensAt) { - return 'upcoming'; - } - - // Check if snapshot is currently live - if (now >= opensAt && now < closesAt) { - return 'live'; - } - - // Snapshot has closed but results not calculated yet - return 'calculating'; -} - -const MONTHS = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', -]; - -/** - * Formats the date for display in the snapshot tile. - * - * @param date - The date to format - * @returns Formatted date string (e.g., "Mar 15, 2:30 PM") - */ -function formatSnapshotDate(date: Date): string { - const month = MONTHS[date.getMonth()]; - const day = date.getDate(); - const hours = date.getHours(); - const minutes = date.getMinutes(); - - const hour12 = hours % 12 || 12; - const ampm = hours >= 12 ? 'PM' : 'AM'; - const paddedMinutes = minutes.toString().padStart(2, '0'); - - return `${month} ${day}, ${hour12}:${paddedMinutes} ${ampm}`; -} - -/** - * Formats the status label for display in the snapshot tile. - * - * @param status - The snapshot status - * @param snapshot - The snapshot data (used for date formatting) - * @returns The formatted status label - */ -export function formatSnapshotStatusLabel( - status: SnapshotStatus, - snapshot: SnapshotDto, -): string { - switch (status) { - case 'upcoming': { - const opensAt = new Date(snapshot.opensAt); - return strings('rewards.snapshot.starts_date', { - date: formatSnapshotDate(opensAt), - }); - } - case 'live': { - const closesAt = new Date(snapshot.closesAt); - return strings('rewards.snapshot.ends_date', { - date: formatSnapshotDate(closesAt), - }); - } - case 'calculating': - return strings('rewards.snapshot.results_coming_soon'); - case 'distributing': - return strings('rewards.snapshot.tokens_on_the_way'); - case 'complete': { - const distributedAt = snapshot.distributedAt - ? new Date(snapshot.distributedAt) - : new Date(); - return formatSnapshotDate(distributedAt); - } - default: - return ''; - } -} - -/** - * Gets the pill label text based on the snapshot status. - * - * @param status - The snapshot status - * @returns The pill label text - */ -export function getSnapshotPillLabel(status: SnapshotStatus): string { - switch (status) { - case 'upcoming': - return strings('rewards.snapshot.pill_up_next'); - case 'live': - return strings('rewards.snapshot.pill_live_now'); - case 'calculating': - return strings('rewards.snapshot.pill_calculating'); - case 'distributing': - return strings('rewards.snapshot.pill_results_ready'); - case 'complete': - return strings('rewards.snapshot.pill_complete'); - default: - return ''; - } -} - -/** - * Gets the appropriate icon for the status - * - * @param status - The snapshot status - * @returns The icon name for the status - */ -function getStatusIcon(status: SnapshotStatus): IconName { - switch (status) { - case 'live': - return IconName.Clock; - case 'complete': - return IconName.Confirmation; - case 'calculating': - return IconName.Loading; - case 'distributing': - return IconName.Send; - case 'upcoming': - default: - return IconName.Speed; - } -} - -export interface SnapshotStatusInfo { - status: SnapshotStatus; - statusLabel: string; - statusDescription: string; - statusDescriptionIcon: IconName; -} - -/** - * Gets all status-related information for a snapshot. - * - * @param snapshot - The snapshot data - * @returns Object containing status, statusLabel, statusDescription, and statusDescriptionIcon - */ -export function getSnapshotStatusInfo( - snapshot: SnapshotDto, -): SnapshotStatusInfo { - const status = getSnapshotStatus(snapshot); - return { - status, - statusLabel: getSnapshotPillLabel(status), - statusDescription: formatSnapshotStatusLabel(status, snapshot), - statusDescriptionIcon: getStatusIcon(status), - }; -} diff --git a/app/components/UI/Rewards/components/SnapshotTile/UpcomingSnapshotTileCondensed.test.tsx b/app/components/UI/Rewards/components/SnapshotTile/UpcomingSnapshotTileCondensed.test.tsx deleted file mode 100644 index d443d88cd92..00000000000 --- a/app/components/UI/Rewards/components/SnapshotTile/UpcomingSnapshotTileCondensed.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import UpcomingSnapshotTileCondensed from './UpcomingSnapshotTileCondensed'; -import type { SnapshotDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { getSnapshotStatusInfo } from './SnapshotTile.utils'; - -// Mock design system -jest.mock('@metamask/design-system-react-native', () => ({ - Box: 'Box', - BoxFlexDirection: { Column: 'Column', Row: 'Row' }, - BoxAlignItems: { Center: 'Center' }, - BoxJustifyContent: { Between: 'Between' }, - Text: 'Text', - TextVariant: { BodySm: 'BodySm', BodyMd: 'BodyMd', HeadingLg: 'HeadingLg' }, - Icon: 'Icon', - IconSize: { Sm: 'Sm' }, - IconName: { Clock: 'Clock', Speed: 'Speed' }, -})); - -// Mock Tailwind -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => ({ style: jest.fn(() => ({})) }), -})); - -// Mock the utils to control status output -jest.mock('./SnapshotTile.utils', () => ({ - getSnapshotStatusInfo: jest.fn(), -})); - -const mockGetSnapshotStatusInfo = getSnapshotStatusInfo as jest.MockedFunction< - typeof getSnapshotStatusInfo ->; - -/** - * Creates a test snapshot with default values that can be overridden - */ -function createTestSnapshot(overrides: Partial = {}): SnapshotDto { - return { - id: 'snapshot-1', - seasonId: 'season-1', - name: 'Test Snapshot Prize', - description: 'Test description', - tokenSymbol: 'TEST', - tokenAmount: '1000', - tokenChainId: '1', - receivingBlockchain: 'ethereum', - opensAt: '2024-01-01T00:00:00Z', - closesAt: '2024-01-31T23:59:59Z', - calculatedAt: undefined, - distributedAt: undefined, - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - ...overrides, - }; -} - -describe('UpcomingSnapshotTileCondensed', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders snapshot name when status is upcoming', () => { - const snapshot = createTestSnapshot({ name: 'Upcoming Airdrop' }); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'upcoming', - statusLabel: 'Up Next', - statusDescription: 'Starts Mar 1, 12:00 PM', - statusDescriptionIcon: 'Speed' as never, - }); - - const { getByText } = render( - , - ); - - expect(getByText('Upcoming Airdrop')).toBeOnTheScreen(); - }); - - it('renders status description when status is upcoming', () => { - const snapshot = createTestSnapshot(); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'upcoming', - statusLabel: 'Up Next', - statusDescription: 'Starts Mar 1, 12:00 PM', - statusDescriptionIcon: 'Speed' as never, - }); - - const { getByText } = render( - , - ); - - expect(getByText('Starts Mar 1, 12:00 PM')).toBeOnTheScreen(); - }); - - it('returns null when status is not upcoming', () => { - const snapshot = createTestSnapshot(); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'live', - statusLabel: 'Live Now', - statusDescription: 'Ends Mar 15, 2:30 PM', - statusDescriptionIcon: 'Clock' as never, - }); - - const { toJSON } = render( - , - ); - - expect(toJSON()).toBeNull(); - }); -}); diff --git a/app/components/UI/Rewards/components/SnapshotTile/UpcomingSnapshotTileCondensed.tsx b/app/components/UI/Rewards/components/SnapshotTile/UpcomingSnapshotTileCondensed.tsx deleted file mode 100644 index 79eb0375a1a..00000000000 --- a/app/components/UI/Rewards/components/SnapshotTile/UpcomingSnapshotTileCondensed.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React, { useMemo } from 'react'; -import { - Box, - BoxFlexDirection, - Text, - TextVariant, -} from '@metamask/design-system-react-native'; -import type { SnapshotDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { getSnapshotStatusInfo } from './SnapshotTile.utils'; - -interface UpcomingSnapshotTileCondensedProps { - /** - * The snapshot data to display - */ - snapshot: SnapshotDto; -} - -/** - * UpcomingSnapshotTileCondensed component displays a condensed view of upcoming snapshots. - * - * Returns null if the snapshot is not in "upcoming" status. - * - * Shows: - * - Status pill with icon - * - Prize name - * - Status label with date - */ -const UpcomingSnapshotTileCondensed: React.FC< - UpcomingSnapshotTileCondensedProps -> = ({ snapshot }) => { - const { status, statusDescription } = useMemo( - () => getSnapshotStatusInfo(snapshot), - [snapshot], - ); - - // Return null if not upcoming - if (status !== 'upcoming') { - return null; - } - - // Format prize display - const prizeDisplay = snapshot.name; - - return ( - - {/* Content */} - - - {prizeDisplay} - - - - {statusDescription} - - - - ); -}; - -export default UpcomingSnapshotTileCondensed; diff --git a/app/components/UI/Rewards/components/SnapshotTile/index.ts b/app/components/UI/Rewards/components/SnapshotTile/index.ts deleted file mode 100644 index 9f420893576..00000000000 --- a/app/components/UI/Rewards/components/SnapshotTile/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './SnapshotTile'; -export { default as SnapshotTile } from './SnapshotTile'; -export { default as UpcomingSnapshotTileCondensed } from './UpcomingSnapshotTileCondensed'; -export { - getSnapshotStatus, - formatSnapshotStatusLabel, - getSnapshotPillLabel, - getSnapshotStatusInfo, -} from './SnapshotTile.utils'; -export type { SnapshotStatusInfo } from './SnapshotTile.utils'; diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx index c3b227f3afa..d0f9d023238 100644 --- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx +++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx @@ -1,13 +1,7 @@ import React, { useMemo, useState, useCallback } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; import { FlatList, ListRenderItem, ActivityIndicator } from 'react-native'; -import { - Box, - Text, - TextVariant, - Button, - ButtonVariant, -} from '@metamask/design-system-react-native'; +import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; import { usePointsEvents } from '../../../hooks/usePointsEvents'; import { PointsEventDto, @@ -23,10 +17,9 @@ import { selectSeasonStatusLoading, selectSeasonActivityTypes, } from '../../../../../../reducers/rewards/selectors'; -import { Skeleton } from '../../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../../component-library/components-temp/Skeleton'; import MetamaskRewardsActivityEmptyImage from '../../../../../../images/rewards/metamask-rewards-activity-empty.svg'; import RewardsErrorBanner from '../../RewardsErrorBanner'; -import { setActiveTab } from '../../../../../../actions/rewards'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useAccountNames } from '../../../../../hooks/DisplayName/useAccountNames'; import { NameType } from '../../../../Name/Name.types'; @@ -98,13 +91,8 @@ const LoadingFooter: React.FC = () => ( const ItemSeparator: React.FC = () => ; const EmptyState: React.FC = () => { - const dispatch = useDispatch(); const tw = useTailwind(); - const handleSeeWaysToEarn = () => { - dispatch(setActiveTab('overview')); - }; - return ( { > {strings('rewards.activity_empty_description')} - - ); diff --git a/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.test.tsx b/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.test.tsx index a2867b3f9c7..ffd6304ba4b 100644 --- a/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.test.tsx @@ -17,7 +17,7 @@ import { selectCurrentTier, } from '../../../../../../reducers/rewards/selectors'; import { useUnlockedRewards } from '../../../hooks/useUnlockedRewards'; -import { SkeletonProps } from '../../../../../../component-library/components/Skeleton'; +import { SkeletonProps } from '../../../../../../component-library/components-temp/Skeleton'; // Mock dependencies jest.mock('react-redux', () => ({ @@ -84,19 +84,22 @@ jest.mock('../../../../../../../locales/i18n', () => ({ jest.mock('../../RewardItem/RewardItem', () => jest.fn(() => null)); // Mock Skeleton -jest.mock('../../../../../../component-library/components/Skeleton', () => { - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return { - __esModule: true, - Skeleton: ({ style, ...props }: SkeletonProps) => - ReactActual.createElement(View, { - testID: 'skeleton', - style, - ...props, - }), - }; -}); +jest.mock( + '../../../../../../component-library/components-temp/Skeleton', + () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + Skeleton: ({ style, ...props }: SkeletonProps) => + ReactActual.createElement(View, { + testID: 'skeleton', + style, + ...props, + }), + }; + }, +); // Mock the useUnlockedRewards hook jest.mock('../../../hooks/useUnlockedRewards', () => ({ diff --git a/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.tsx b/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.tsx index 2d4541dbf91..4828168dc88 100644 --- a/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.tsx +++ b/app/components/UI/Rewards/components/Tabs/LevelsTab/UnlockedRewards.tsx @@ -15,7 +15,7 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { REWARDS_VIEW_SELECTORS } from '../../../Views/RewardsView.constants'; import RewardItem from '../../RewardItem/RewardItem'; import { useUnlockedRewards } from '../../../hooks/useUnlockedRewards'; -import { Skeleton } from '../../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../../component-library/components-temp/Skeleton'; import RewardsErrorBanner from '../../RewardsErrorBanner'; import { ActivityIndicator } from 'react-native'; interface UnlockedRewardItemProps { diff --git a/app/components/UI/Rewards/components/Tabs/LevelsTab/UpcomingRewards.tsx b/app/components/UI/Rewards/components/Tabs/LevelsTab/UpcomingRewards.tsx index 320525b336f..097f5638044 100644 --- a/app/components/UI/Rewards/components/Tabs/LevelsTab/UpcomingRewards.tsx +++ b/app/components/UI/Rewards/components/Tabs/LevelsTab/UpcomingRewards.tsx @@ -31,7 +31,7 @@ import { REWARDS_VIEW_SELECTORS } from '../../../Views/RewardsView.constants'; import RewardItem from '../../RewardItem/RewardItem'; import RewardsThemeImageComponent from '../../ThemeImageComponent'; import RewardsErrorBanner from '../../RewardsErrorBanner'; -import { Skeleton } from '../../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../../component-library/components-temp/Skeleton'; import RewardsImageModal from '../../RewardsImageModal'; import fallbackTierImage from '../../../../../../images/rewards/tiers/rewards-s1-tier-1.png'; diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.test.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.test.tsx index dd9aedcbd29..59b69090b85 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.test.tsx @@ -5,7 +5,7 @@ import { configureStore } from '@reduxjs/toolkit'; import ActiveBoosts from './ActiveBoosts'; import { REWARDS_VIEW_SELECTORS } from '../../../Views/RewardsView.constants'; import { PointsBoostDto } from '../../../../../../core/Engine/controllers/rewards-controller/types'; -import { SkeletonProps } from '../../../../../../component-library/components/Skeleton'; +import { SkeletonProps } from '../../../../../../component-library/components-temp/Skeleton'; // Mock dependencies const mockUseTheme = jest.fn(() => ({ @@ -81,12 +81,15 @@ jest.mock('../../../utils/formatUtils', () => ({ }), })); -jest.mock('../../../../../../component-library/components/Skeleton', () => ({ - Skeleton: ({ testID, ...props }: SkeletonProps) => { - const { View } = jest.requireActual('react-native'); - return ; - }, -})); +jest.mock( + '../../../../../../component-library/components-temp/Skeleton', + () => ({ + Skeleton: ({ testID, ...props }: SkeletonProps) => { + const { View } = jest.requireActual('react-native'); + return ; + }, + }), +); // Mock RewardsThemeImageComponent jest.mock('../../ThemeImageComponent', () => { diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.tsx index 9c19f763795..641f3dd4664 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.tsx @@ -34,7 +34,7 @@ import { } from '../../../../Bridge/hooks/useSwapBridgeNavigation'; import { REWARDS_VIEW_SELECTORS } from '../../../Views/RewardsView.constants'; import { formatTimeRemaining } from '../../../utils/formatUtils'; -import { Skeleton } from '../../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../../component-library/components-temp/Skeleton'; import RewardsThemeImageComponent from '../../ThemeImageComponent'; import RewardsErrorBanner from '../../RewardsErrorBanner'; import { MetaMetricsEvents, useMetrics } from '../../../../../hooks/useMetrics'; @@ -183,20 +183,18 @@ const SectionHeader: React.FC<{ count: number | null; isLoading: boolean }> = ({ count, isLoading, }) => ( - - - - {strings('rewards.active_boosts_title')} - - {isLoading && } - {count !== null && !isLoading && ( - - - {count} - - - )} - + + + {strings('rewards.active_boosts_title')} + + {isLoading && } + {count !== null && !isLoading && ( + + + {count} + + + )} ); @@ -278,7 +276,7 @@ const ActiveBoosts: React.FC<{ } return ( - + {/* Always show section header */} ({ - useSelector: jest.fn(), -})); - -jest.mock( - '../../../../../../../selectors/featureFlagController/rewards', - () => ({ - selectSnapshotsRewardsEnabledFlag: jest.fn(), - }), -); - -jest.mock('../../../../hooks/useSnapshots', () => ({ - useSnapshots: jest.fn(), -})); - -jest.mock('../../../../components/SnapshotTile', () => { - const ReactNative = jest.requireActual('react-native'); - const ReactActual = jest.requireActual('react'); - return { - SnapshotTile: jest.fn(({ snapshot }) => - ReactActual.createElement( - ReactNative.View, - { testID: `snapshot-tile-${snapshot.id}` }, - ReactActual.createElement(ReactNative.Text, null, snapshot.name), - ), - ), - UpcomingSnapshotTileCondensed: jest.fn(({ snapshot }) => - ReactActual.createElement( - ReactNative.View, - { testID: `upcoming-tile-${snapshot.id}` }, - ReactActual.createElement(ReactNative.Text, null, snapshot.name), - ), - ), - }; -}); - -jest.mock('../../../../../../../component-library/components/Skeleton', () => { - const ReactNative = jest.requireActual('react-native'); - const ReactActual = jest.requireActual('react'); - return { - Skeleton: jest.fn(() => - ReactActual.createElement(ReactNative.View, { - testID: 'skeleton-loader', - }), - ), - }; -}); - -jest.mock('../../../../components/RewardsErrorBanner', () => { - const ReactNative = jest.requireActual('react-native'); - const ReactActual = jest.requireActual('react'); - return jest.fn(({ title, description }) => - ReactActual.createElement( - ReactNative.View, - { testID: 'error-banner' }, - ReactActual.createElement( - ReactNative.Text, - { testID: 'error-title' }, - title, - ), - ReactActual.createElement( - ReactNative.Text, - { testID: 'error-description' }, - description, - ), - ), - ); -}); - -jest.mock('../../../../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => key), -})); - -jest.mock('@metamask/design-system-react-native', () => { - const ReactNative = jest.requireActual('react-native'); - const ReactActual = jest.requireActual('react'); - return { - Box: jest.fn(({ children, testID }) => - ReactActual.createElement(ReactNative.View, { testID }, children), - ), - Text: jest.fn(({ children }) => - ReactActual.createElement(ReactNative.Text, null, children), - ), - TextVariant: { - HeadingMd: 'HeadingMd', - }, - BoxFlexDirection: { - Row: 'row', - }, - BoxAlignItems: { - Center: 'center', - }, - BoxJustifyContent: { - Between: 'space-between', - }, - }; -}); - -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: jest.fn(() => ({ - style: jest.fn((className) => ({ className })), - })), -})); - -jest.mock('../../../../Views/RewardsView.constants', () => ({ - REWARDS_VIEW_SELECTORS: { - SNAPSHOTS_SECTION: 'rewards-view-snapshots-section', - }, -})); - -/** - * Creates a test snapshot with customizable overrides - */ -const createTestSnapshot = ( - overrides: Partial = {}, -): SnapshotDto => ({ - id: `snapshot-${Math.random().toString(36).substr(2, 9)}`, - seasonId: 'season-1', - name: 'Test Snapshot', - description: 'Test description', - tokenSymbol: 'TEST', - tokenAmount: '1000000000000000000', - tokenChainId: '1', - tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - ...overrides, -}); - -describe('SnapshotsSection', () => { - const mockUseSnapshots = useSnapshots as jest.MockedFunction< - typeof useSnapshots - >; - const mockUseSelector = useSelector as jest.MockedFunction< - typeof useSelector - >; - const mockFetchSnapshots = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - mockFetchSnapshots.mockClear(); - - // Enable snapshots feature flag by default - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSnapshotsRewardsEnabledFlag) return true; - return undefined; - }); - }); - - const setupMock = ( - options: { - active?: SnapshotDto[]; - upcoming?: SnapshotDto[]; - previous?: SnapshotDto[]; - isLoading?: boolean; - hasError?: boolean; - } = {}, - ) => { - const { - active = [], - upcoming = [], - previous = [], - isLoading = false, - hasError = false, - } = options; - - mockUseSnapshots.mockReturnValue({ - snapshots: [...active, ...upcoming, ...previous], - categorizedSnapshots: { active, upcoming, previous }, - isLoading, - hasError, - fetchSnapshots: mockFetchSnapshots, - }); - }; - - describe('feature flag disabled', () => { - it('returns null when snapshots feature flag is disabled', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSnapshotsRewardsEnabledFlag) return false; - return undefined; - }); - setupMock({ active: [], upcoming: [] }); - - const { toJSON } = render(); - - expect(toJSON()).toBeNull(); - }); - }); - - describe('empty state', () => { - it('returns null when no snapshots and not loading/error', () => { - setupMock({ - active: [], - upcoming: [], - isLoading: false, - hasError: false, - }); - - const { toJSON } = render(); - - expect(toJSON()).toBeNull(); - }); - }); - - describe('loading state', () => { - it('renders loading skeleton when loading with no snapshots', () => { - setupMock({ - active: [], - upcoming: [], - isLoading: true, - hasError: false, - }); - - const { getByTestId } = render(); - - expect(getByTestId('skeleton-loader')).toBeTruthy(); - }); - }); - - describe('error state', () => { - it('renders error banner when error with no snapshots', () => { - setupMock({ - active: [], - upcoming: [], - isLoading: false, - hasError: true, - }); - - const { getByTestId } = render(); - - expect(getByTestId('error-banner')).toBeTruthy(); - expect(strings).toHaveBeenCalledWith( - 'rewards.snapshots_section.error_title', - ); - expect(strings).toHaveBeenCalledWith( - 'rewards.snapshots_section.error_description', - ); - }); - }); - - describe('with snapshots', () => { - it('renders section with title', () => { - const activeSnapshot = createTestSnapshot({ - id: 'active-1', - name: 'Active Snapshot', - }); - setupMock({ active: [activeSnapshot] }); - - const { getByTestId } = render(); - - expect(getByTestId('rewards-view-snapshots-section')).toBeTruthy(); - expect(strings).toHaveBeenCalledWith('rewards.snapshots_section.title'); - }); - - it('renders SnapshotTile for active snapshots', () => { - const activeSnapshot1 = createTestSnapshot({ - id: 'active-1', - name: 'Active Snapshot 1', - }); - const activeSnapshot2 = createTestSnapshot({ - id: 'active-2', - name: 'Active Snapshot 2', - }); - setupMock({ active: [activeSnapshot1, activeSnapshot2] }); - - const { getByTestId } = render(); - - expect(getByTestId('snapshot-tile-active-1')).toBeTruthy(); - expect(getByTestId('snapshot-tile-active-2')).toBeTruthy(); - }); - - it('renders UpcomingSnapshotTileCondensed for upcoming snapshots', () => { - const upcomingSnapshot1 = createTestSnapshot({ - id: 'upcoming-1', - name: 'Upcoming Snapshot 1', - }); - const upcomingSnapshot2 = createTestSnapshot({ - id: 'upcoming-2', - name: 'Upcoming Snapshot 2', - }); - setupMock({ upcoming: [upcomingSnapshot1, upcomingSnapshot2] }); - - const { getByTestId } = render(); - - expect(getByTestId('upcoming-tile-upcoming-1')).toBeTruthy(); - expect(getByTestId('upcoming-tile-upcoming-2')).toBeTruthy(); - }); - - it('renders mixed active and upcoming snapshots correctly', () => { - const activeSnapshot = createTestSnapshot({ - id: 'active-1', - name: 'Active Snapshot', - }); - const upcomingSnapshot = createTestSnapshot({ - id: 'upcoming-1', - name: 'Upcoming Snapshot', - }); - setupMock({ active: [activeSnapshot], upcoming: [upcomingSnapshot] }); - - const { getByTestId } = render(); - - expect(getByTestId('snapshot-tile-active-1')).toBeTruthy(); - expect(getByTestId('upcoming-tile-upcoming-1')).toBeTruthy(); - }); - }); - - describe('refresh indicator', () => { - it('shows loading indicator when refreshing existing data', () => { - const activeSnapshot = createTestSnapshot({ - id: 'active-1', - name: 'Active Snapshot', - }); - setupMock({ - active: [activeSnapshot], - isLoading: true, - hasError: false, - }); - - const { getByTestId, queryByTestId } = render(); - - // Section renders with snapshots (not skeleton) - expect(getByTestId('snapshot-tile-active-1')).toBeTruthy(); - // Skeleton is not shown when we have data - expect(queryByTestId('skeleton-loader')).toBeNull(); - }); - }); - - describe('sorting behavior', () => { - it('displays active snapshots before upcoming snapshots', () => { - const activeSnapshot = createTestSnapshot({ - id: 'active-1', - name: 'Active', - opensAt: '2025-03-15T00:00:00.000Z', - }); - const upcomingSnapshot = createTestSnapshot({ - id: 'upcoming-1', - name: 'Upcoming', - opensAt: '2025-03-01T00:00:00.000Z', - }); - - setupMock({ active: [activeSnapshot], upcoming: [upcomingSnapshot] }); - - const { toJSON } = render(); - const json = JSON.stringify(toJSON()); - - // Active snapshot should appear before upcoming in the rendered output - const activeIndex = json.indexOf('snapshot-tile-active-1'); - const upcomingIndex = json.indexOf('upcoming-tile-upcoming-1'); - - expect(activeIndex).toBeLessThan(upcomingIndex); - }); - }); -}); diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/SnapshotsSection/SnapshotsSection.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/SnapshotsSection/SnapshotsSection.tsx deleted file mode 100644 index 97c960fd772..00000000000 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/SnapshotsSection/SnapshotsSection.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import React, { useMemo } from 'react'; -import { ActivityIndicator, Dimensions } from 'react-native'; -import { useSelector } from 'react-redux'; -import { - Box, - Text, - TextVariant, - BoxFlexDirection, - BoxAlignItems, - BoxJustifyContent, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { strings } from '../../../../../../../../locales/i18n'; -import { selectSnapshotsRewardsEnabledFlag } from '../../../../../../../selectors/featureFlagController/rewards'; -import { useSnapshots } from '../../../../hooks/useSnapshots'; -import { - SnapshotTile, - UpcomingSnapshotTileCondensed, -} from '../../../../components/SnapshotTile'; -import { Skeleton } from '../../../../../../../component-library/components/Skeleton'; -import RewardsErrorBanner from '../../../../components/RewardsErrorBanner'; -import { REWARDS_VIEW_SELECTORS } from '../../../../Views/RewardsView.constants'; - -const SCREEN_WIDTH = Dimensions.get('window').width; -const CARD_WIDTH = SCREEN_WIDTH - 32; // Full width minus padding - -/** - * SnapshotsSection displays active and upcoming snapshots in the Overview tab. - * Shows all active snapshots first (as large tiles), then all upcoming snapshots - * (as condensed tiles). Both groups are sorted by opensAt ascending (earliest first). - */ -const SnapshotsSection: React.FC = () => { - const isSnapshotsEnabled = useSelector(selectSnapshotsRewardsEnabledFlag); - const tw = useTailwind(); - const { categorizedSnapshots, isLoading, hasError, fetchSnapshots } = - useSnapshots(); - - const { active, upcoming } = categorizedSnapshots; - - // Sort active and upcoming by opensAt ascending (earliest first) - const sortedSnapshots = useMemo(() => { - const sortByOpensAt = (a: (typeof active)[0], b: (typeof active)[0]) => - new Date(a.opensAt).getTime() - new Date(b.opensAt).getTime(); - - const sortedActive = [...active].sort(sortByOpensAt); - const sortedUpcoming = [...upcoming].sort(sortByOpensAt); - - // Active snapshots first, then upcoming - return [...sortedActive, ...sortedUpcoming]; - }, [active, upcoming]); - - const hasSnapshots = sortedSnapshots.length > 0; - - // Return null if snapshots feature is disabled - if (!isSnapshotsEnabled) { - return null; - } - - // Don't render if no snapshots and not loading/error - if (!isLoading && !hasError && !hasSnapshots) { - return null; - } - - const renderContent = () => { - // Show loading state - if (isLoading && !hasSnapshots) { - return ( - - ); - } - - // Show error state - if (hasError && !hasSnapshots) { - return ( - - ); - } - - return ( - - {sortedSnapshots.map((snapshot) => { - const isActive = active.some((s) => s.id === snapshot.id); - return isActive ? ( - - ) : ( - - ); - })} - - ); - }; - - return ( - - {/* Section Header */} - - - - {strings('rewards.snapshots_section.title')} - - {isLoading && hasSnapshots && } - - - - {/* Content */} - {renderContent()} - - ); -}; - -export default SnapshotsSection; diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/SnapshotsSection/index.ts b/app/components/UI/Rewards/components/Tabs/OverviewTab/SnapshotsSection/index.ts deleted file mode 100644 index 2b5ce67425c..00000000000 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/SnapshotsSection/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './SnapshotsSection'; -export { default as SnapshotsSection } from './SnapshotsSection'; diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx index a857f8fb1e2..deb72f54678 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx @@ -287,17 +287,17 @@ describe('WaysToEarn', () => { expect(getByText('10 points per $100')).toBeOnTheScreen(); }); - it('renders an empty list when no ways to earn exist', () => { + it('renders nothing when no ways to earn exist', () => { // Arrange const mockUseSelector = jest.requireMock('react-redux') .useSelector as jest.Mock; mockUseSelector.mockReturnValue([]); // Act - const { getByText, queryByText } = render(); + const { queryByText } = render(); // Assert - expect(getByText('Ways to earn')).toBeOnTheScreen(); + expect(queryByText('Ways to earn')).toBeNull(); expect(queryByText('Swap')).toBeNull(); }); }); diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx index 08474742742..5aaaf712cb4 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx @@ -154,9 +154,13 @@ export const WaysToEarn = () => { }); }; + if (!seasonWaysToEarn.length) { + return null; + } + return ( - - + + {strings('rewards.ways_to_earn.title')} diff --git a/app/components/UI/Rewards/components/Tabs/RewardsOverview.test.tsx b/app/components/UI/Rewards/components/Tabs/RewardsOverview.test.tsx index 7fc5aa68498..c0abda296c0 100644 --- a/app/components/UI/Rewards/components/Tabs/RewardsOverview.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/RewardsOverview.test.tsx @@ -68,20 +68,6 @@ jest.mock('./OverviewTab/WaysToEarn/WaysToEarn', () => ({ }, })); -// Mock SnapshotsSection component to avoid Redux provider requirements -jest.mock('./OverviewTab/SnapshotsSection', () => ({ - __esModule: true, - default: () => { - const ReactActual = jest.requireActual('react'); - const { View, Text } = jest.requireActual('react-native'); - return ReactActual.createElement( - View, - { testID: 'snapshots-section-mock' }, - ReactActual.createElement(Text, null, 'SnapshotsSection Mock'), - ); - }, -})); - // Mock useTailwind jest.mock('@metamask/design-system-twrnc-preset', () => ({ useTailwind: () => ({ diff --git a/app/components/UI/Rewards/components/Tabs/RewardsOverview.tsx b/app/components/UI/Rewards/components/Tabs/RewardsOverview.tsx index 458ccc89cae..1df6e3ae9c4 100644 --- a/app/components/UI/Rewards/components/Tabs/RewardsOverview.tsx +++ b/app/components/UI/Rewards/components/Tabs/RewardsOverview.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { REWARDS_VIEW_SELECTORS } from '../../Views/RewardsView.constants'; -import SnapshotsSection from './OverviewTab/SnapshotsSection'; import ActiveBoosts from './OverviewTab/ActiveBoosts'; import { useActivePointsBoosts } from '../../hooks/useActivePointsBoosts'; import { WaysToEarn } from './OverviewTab/WaysToEarn/WaysToEarn'; @@ -17,12 +16,10 @@ const RewardsOverview: React.FC = () => { return ( - - diff --git a/app/components/UI/Rewards/components/Tabs/RewardsSnapshots.test.tsx b/app/components/UI/Rewards/components/Tabs/RewardsSnapshots.test.tsx deleted file mode 100644 index 3af21d481dc..00000000000 --- a/app/components/UI/Rewards/components/Tabs/RewardsSnapshots.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import RewardsSnapshots from './RewardsSnapshots'; - -// Mock the SnapshotsTab component -jest.mock('./SnapshotsTab', () => ({ - SnapshotsTab: function MockSnapshotsTab() { - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return ReactActual.createElement(View, { testID: 'snapshots-tab-mock' }); - }, -})); - -describe('RewardsSnapshots', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders SnapshotsTab component', () => { - const { getByTestId } = render(); - - expect(getByTestId('snapshots-tab-mock')).toBeOnTheScreen(); - }); - - it('accepts optional tabLabel prop without errors', () => { - const { getByTestId } = render(); - - expect(getByTestId('snapshots-tab-mock')).toBeOnTheScreen(); - }); -}); diff --git a/app/components/UI/Rewards/components/Tabs/RewardsSnapshots.tsx b/app/components/UI/Rewards/components/Tabs/RewardsSnapshots.tsx deleted file mode 100644 index bfd7ca7a91b..00000000000 --- a/app/components/UI/Rewards/components/Tabs/RewardsSnapshots.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import { SnapshotsTab } from './SnapshotsTab'; - -interface RewardsSnapshotsProps { - tabLabel?: string; -} - -/** - * RewardsSnapshots tab displays all snapshots organized by status: - * - Active (live) - * - Upcoming - * - Previous (calculating, distributing, complete) - */ -const RewardsSnapshots: React.FC = () => ( - -); - -export default RewardsSnapshots; diff --git a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsGroup.test.tsx b/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsGroup.test.tsx deleted file mode 100644 index 5776d267960..00000000000 --- a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsGroup.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import SnapshotsGroup from './SnapshotsGroup'; -import { SnapshotTile } from '../../SnapshotTile'; -import type { SnapshotDto } from '../../../../../../core/Engine/controllers/rewards-controller/types'; - -jest.mock('@metamask/design-system-react-native', () => ({ - Box: 'Box', - Text: 'Text', - TextVariant: { HeadingMd: 'HeadingMd' }, -})); - -jest.mock('../../SnapshotTile', () => ({ - SnapshotTile: jest.fn(() => null), -})); - -const createTestSnapshot = ( - overrides: Partial = {}, -): SnapshotDto => ({ - id: 'snapshot-1', - seasonId: 'season-1', - name: 'Test Airdrop', - description: 'Test description', - tokenSymbol: 'TEST', - tokenAmount: '1000000000000000000000', - tokenChainId: '1', - tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - ...overrides, -}); - -describe('SnapshotsGroup', () => { - const mockSnapshotTile = SnapshotTile as jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('returns null when snapshots array is empty', () => { - const { toJSON } = render(); - - expect(toJSON()).toBeNull(); - }); - - it('renders title text when snapshots exist', () => { - const snapshots = [createTestSnapshot()]; - - const { getByText } = render( - , - ); - - expect(getByText('Active Snapshots')).toBeOnTheScreen(); - }); - - it('renders SnapshotTile for each snapshot', () => { - const snapshots = [ - createTestSnapshot({ id: 'snapshot-1', name: 'Snapshot One' }), - createTestSnapshot({ id: 'snapshot-2', name: 'Snapshot Two' }), - createTestSnapshot({ id: 'snapshot-3', name: 'Snapshot Three' }), - ]; - - render(); - - expect(mockSnapshotTile).toHaveBeenCalledTimes(3); - expect(mockSnapshotTile).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ snapshot: snapshots[0] }), - expect.anything(), - ); - expect(mockSnapshotTile).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ snapshot: snapshots[1] }), - expect.anything(), - ); - expect(mockSnapshotTile).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ snapshot: snapshots[2] }), - expect.anything(), - ); - }); - - it('applies testID prop to container', () => { - const snapshots = [createTestSnapshot()]; - - const { getByTestId } = render( - , - ); - - expect(getByTestId('test-snapshots-group')).toBeOnTheScreen(); - }); - - it('does not apply testID when not provided', () => { - const snapshots = [createTestSnapshot()]; - - const { queryByTestId } = render( - , - ); - - expect(queryByTestId('test-snapshots-group')).toBeNull(); - }); -}); diff --git a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsGroup.tsx b/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsGroup.tsx deleted file mode 100644 index 5a3ca96555a..00000000000 --- a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsGroup.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; -import { SnapshotTile } from '../../SnapshotTile'; -import type { SnapshotDto } from '../../../../../../core/Engine/controllers/rewards-controller/types'; - -interface SnapshotsGroupProps { - title: string; - snapshots: SnapshotDto[]; - testID?: string; -} - -/** - * Section component for displaying a group of snapshots with a title - */ -const SnapshotsGroup: React.FC = ({ - title, - snapshots, - testID, -}) => { - if (snapshots.length === 0) { - return null; - } - - return ( - - - {title} - - {snapshots.map((snapshot) => ( - - ))} - - ); -}; - -export default SnapshotsGroup; diff --git a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsTab.test.tsx b/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsTab.test.tsx deleted file mode 100644 index 49d09c24ed1..00000000000 --- a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsTab.test.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import { SnapshotsTab } from './SnapshotsTab'; -import { useSnapshots } from '../../../hooks/useSnapshots'; -import { SnapshotTile } from '../../SnapshotTile'; -import { REWARDS_VIEW_SELECTORS } from '../../../Views/RewardsView.constants'; -import type { SnapshotDto } from '../../../../../../core/Engine/controllers/rewards-controller/types'; - -jest.mock('../../../hooks/useSnapshots', () => ({ - useSnapshots: jest.fn(), -})); - -jest.mock('../../SnapshotTile', () => ({ - SnapshotTile: jest.fn(() => null), -})); - -jest.mock('@metamask/design-system-react-native', () => ({ - Box: 'Box', - Text: 'Text', - TextVariant: { - BodyMd: 'BodyMd', - BodySm: 'BodySm', - HeadingMd: 'HeadingMd', - }, - BoxFlexDirection: { Row: 'row' }, - BoxAlignItems: { Center: 'center' }, -})); - -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => ({ - style: jest.fn(() => ({ flexGrow: 1 })), - }), -})); - -jest.mock('../../../../../../component-library/components/Skeleton', () => ({ - Skeleton: 'Skeleton', -})); - -jest.mock('../../RewardsErrorBanner', () => ({ - __esModule: true, - default: function MockRewardsErrorBanner() { - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return ReactActual.createElement(View, { testID: 'rewards-error-banner' }); - }, -})); - -jest.mock('../../../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => key), -})); - -const createTestSnapshot = ( - overrides: Partial = {}, -): SnapshotDto => ({ - id: 'snapshot-1', - seasonId: 'season-1', - name: 'Test Airdrop', - description: 'Test description', - tokenSymbol: 'TEST', - tokenAmount: '1000000000000000000000', - tokenChainId: '1', - tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - ...overrides, -}); - -describe('SnapshotsTab', () => { - const mockUseSnapshots = useSnapshots as jest.Mock; - const mockSnapshotTile = SnapshotTile as jest.Mock; - const mockFetchSnapshots = jest.fn(); - - const defaultHookReturn = { - categorizedSnapshots: { - active: [], - upcoming: [], - previous: [], - }, - isLoading: false, - hasError: false, - fetchSnapshots: mockFetchSnapshots, - }; - - beforeEach(() => { - jest.clearAllMocks(); - mockUseSnapshots.mockReturnValue(defaultHookReturn); - }); - - it('renders loading skeleton when loading with no data', () => { - mockUseSnapshots.mockReturnValue({ - ...defaultHookReturn, - isLoading: true, - }); - - const { toJSON } = render(); - const jsonString = JSON.stringify(toJSON()); - - expect(jsonString).toContain('Skeleton'); - }); - - it('renders error banner when error with no data', () => { - mockUseSnapshots.mockReturnValue({ - ...defaultHookReturn, - hasError: true, - }); - - const { getByTestId } = render(); - - expect(getByTestId('rewards-error-banner')).toBeOnTheScreen(); - }); - - it('renders empty state when no snapshots exist', () => { - mockUseSnapshots.mockReturnValue(defaultHookReturn); - - const { getByText } = render(); - - expect(getByText('rewards.snapshots_tab.empty_state')).toBeOnTheScreen(); - }); - - it('renders active section when active snapshots exist', () => { - const activeSnapshot = createTestSnapshot({ id: 'active-1' }); - - mockUseSnapshots.mockReturnValue({ - ...defaultHookReturn, - categorizedSnapshots: { - active: [activeSnapshot], - upcoming: [], - previous: [], - }, - }); - - const { getByTestId, getByText } = render(); - - expect( - getByTestId(REWARDS_VIEW_SELECTORS.SNAPSHOTS_ACTIVE_SECTION), - ).toBeOnTheScreen(); - expect(getByText('rewards.snapshots_tab.active_title')).toBeOnTheScreen(); - expect(mockSnapshotTile).toHaveBeenCalledWith( - expect.objectContaining({ snapshot: activeSnapshot }), - expect.anything(), - ); - }); - - it('renders upcoming section when upcoming snapshots exist', () => { - const upcomingSnapshot = createTestSnapshot({ id: 'upcoming-1' }); - - mockUseSnapshots.mockReturnValue({ - ...defaultHookReturn, - categorizedSnapshots: { - active: [], - upcoming: [upcomingSnapshot], - previous: [], - }, - }); - - const { getByTestId, getByText } = render(); - - expect( - getByTestId(REWARDS_VIEW_SELECTORS.SNAPSHOTS_UPCOMING_SECTION), - ).toBeOnTheScreen(); - expect(getByText('rewards.snapshots_tab.upcoming_title')).toBeOnTheScreen(); - expect(mockSnapshotTile).toHaveBeenCalledWith( - expect.objectContaining({ snapshot: upcomingSnapshot }), - expect.anything(), - ); - }); - - it('renders previous section when previous snapshots exist', () => { - const previousSnapshot = createTestSnapshot({ id: 'previous-1' }); - - mockUseSnapshots.mockReturnValue({ - ...defaultHookReturn, - categorizedSnapshots: { - active: [], - upcoming: [], - previous: [previousSnapshot], - }, - }); - - const { getByTestId, getByText } = render(); - - expect( - getByTestId(REWARDS_VIEW_SELECTORS.SNAPSHOTS_PREVIOUS_SECTION), - ).toBeOnTheScreen(); - expect(getByText('rewards.snapshots_tab.previous_title')).toBeOnTheScreen(); - expect(mockSnapshotTile).toHaveBeenCalledWith( - expect.objectContaining({ snapshot: previousSnapshot }), - expect.anything(), - ); - }); - - it('renders refreshing indicator when loading with existing data', () => { - const activeSnapshot = createTestSnapshot({ id: 'active-1' }); - - mockUseSnapshots.mockReturnValue({ - ...defaultHookReturn, - isLoading: true, - categorizedSnapshots: { - active: [activeSnapshot], - upcoming: [], - previous: [], - }, - }); - - const { getByText } = render(); - - expect(getByText('rewards.snapshots_tab.refreshing')).toBeOnTheScreen(); - }); - - it('renders snapshots tab content container with correct testID', () => { - mockUseSnapshots.mockReturnValue(defaultHookReturn); - - const { getByTestId } = render(); - - expect( - getByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTENT_SNAPSHOTS), - ).toBeOnTheScreen(); - }); - - it('renders all sections when all snapshot categories have data', () => { - const activeSnapshot = createTestSnapshot({ id: 'active-1' }); - const upcomingSnapshot = createTestSnapshot({ id: 'upcoming-1' }); - const previousSnapshot = createTestSnapshot({ id: 'previous-1' }); - - mockUseSnapshots.mockReturnValue({ - ...defaultHookReturn, - categorizedSnapshots: { - active: [activeSnapshot], - upcoming: [upcomingSnapshot], - previous: [previousSnapshot], - }, - }); - - const { getByTestId } = render(); - - expect( - getByTestId(REWARDS_VIEW_SELECTORS.SNAPSHOTS_ACTIVE_SECTION), - ).toBeOnTheScreen(); - expect( - getByTestId(REWARDS_VIEW_SELECTORS.SNAPSHOTS_UPCOMING_SECTION), - ).toBeOnTheScreen(); - expect( - getByTestId(REWARDS_VIEW_SELECTORS.SNAPSHOTS_PREVIOUS_SECTION), - ).toBeOnTheScreen(); - expect(mockSnapshotTile).toHaveBeenCalledTimes(3); - }); -}); diff --git a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsTab.tsx b/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsTab.tsx deleted file mode 100644 index a2528947483..00000000000 --- a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsTab.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React from 'react'; -import { ActivityIndicator } from 'react-native'; -import { ScrollView } from 'react-native-gesture-handler'; -import { - Box, - Text, - TextVariant, - BoxFlexDirection, - BoxAlignItems, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { strings } from '../../../../../../../locales/i18n'; -import { useSnapshots } from '../../../hooks/useSnapshots'; -import { Skeleton } from '../../../../../../component-library/components/Skeleton'; -import RewardsErrorBanner from '../../RewardsErrorBanner'; -import { REWARDS_VIEW_SELECTORS } from '../../../Views/RewardsView.constants'; -import SnapshotsGroup from './SnapshotsGroup'; - -/** - * SnapshotsTab displays all snapshots organized by status: - * - Active (live) - * - Upcoming - * - Previous (calculating, distributing, complete) - */ -export const SnapshotsTab: React.FC = () => { - const tw = useTailwind(); - const { categorizedSnapshots, isLoading, hasError, fetchSnapshots } = - useSnapshots(); - - const { active, upcoming, previous } = categorizedSnapshots; - const hasSnapshots = - active.length > 0 || upcoming.length > 0 || previous.length > 0; - - const renderContent = () => { - // Show loading state - if (isLoading && !hasSnapshots) { - return ( - - - - - - - - - - - ); - } - - // Show error state - if (hasError && !hasSnapshots) { - return ( - - ); - } - - // Show empty state - if (!hasSnapshots) { - return ( - - - {strings('rewards.snapshots_tab.empty_state')} - - - ); - } - - return ( - - {/* Active Snapshots */} - - - {/* Upcoming Snapshots */} - - - {/* Previous Snapshots */} - - - ); - }; - - return ( - - {/* Loading indicator when refreshing */} - {isLoading && hasSnapshots && ( - - - - {strings('rewards.snapshots_tab.refreshing')} - - - )} - - {renderContent()} - - ); -}; diff --git a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/index.ts b/app/components/UI/Rewards/components/Tabs/SnapshotsTab/index.ts deleted file mode 100644 index abce82d10a7..00000000000 --- a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { SnapshotsTab } from './SnapshotsTab'; -export { default as SnapshotsGroup } from './SnapshotsGroup'; diff --git a/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.test.ts b/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.test.ts index 74664d6c9d1..139179bee20 100644 --- a/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.test.ts +++ b/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.test.ts @@ -1,13 +1,17 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { useGetCampaignParticipantStatus } from './useGetCampaignParticipantStatus'; import Engine from '../../../../core/Engine'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; +import { selectCampaignParticipantStatusById } from '../../../../reducers/rewards/selectors'; +import { setCampaignParticipantStatus } from '../../../../reducers/rewards'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; +import type { CampaignParticipantStatusDto } from '../../../../core/Engine/controllers/rewards-controller/types'; jest.mock('react-redux', () => ({ useSelector: jest.fn(), + useDispatch: jest.fn(), })); jest.mock('../../../../core/Engine', () => ({ @@ -26,6 +30,14 @@ jest.mock('../../../../selectors/featureFlagController/rewards', () => ({ selectCampaignsRewardsEnabledFlag: jest.fn(), })); +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantStatusById: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards', () => ({ + setCampaignParticipantStatus: jest.fn(), +})); + const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< typeof Engine.controllerMessenger.call >; @@ -34,18 +46,52 @@ const mockUseInvalidateByRewardEvents = typeof useInvalidateByRewardEvents >; const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockSetCampaignParticipantStatus = + setCampaignParticipantStatus as unknown as jest.MockedFunction< + (payload: { campaignId: string; status: CampaignParticipantStatusDto }) => { + type: string; + payload: { campaignId: string; status: CampaignParticipantStatusDto }; + } + >; +const mockSelectCampaignParticipantStatusById = + selectCampaignParticipantStatusById as jest.MockedFunction< + typeof selectCampaignParticipantStatusById + >; const SUB_ID = 'sub-123'; const CAMPAIGN_ID = 'camp-456'; -const STATUS = { optedIn: true }; +const STATUS = { optedIn: true, participantCount: 42 }; + +const mockDispatch = jest.fn(); +const mockParticipantStatusSelector = jest.fn(); function setupSelectors( subscriptionId: string | null, campaignsEnabled: boolean, + participantStatus: CampaignParticipantStatusDto | null = null, ) { + mockParticipantStatusSelector.mockReturnValue(participantStatus); + mockSelectCampaignParticipantStatusById.mockReturnValue( + mockParticipantStatusSelector, + ); + + let currentStatus = participantStatus; + + mockDispatch.mockImplementation((action) => { + if ( + action?.type === 'rewards/setCampaignParticipantStatus' && + action.payload?.status + ) { + currentStatus = action.payload.status; + mockParticipantStatusSelector.mockReturnValue(currentStatus); + } + }); + mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return subscriptionId; if (selector === selectCampaignsRewardsEnabledFlag) return campaignsEnabled; + if (selector === mockParticipantStatusSelector) return currentStatus; return undefined; }); } @@ -53,6 +99,11 @@ function setupSelectors( describe('useGetCampaignParticipantStatus', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + mockSetCampaignParticipantStatus.mockImplementation((payload) => ({ + type: 'rewards/setCampaignParticipantStatus', + payload, + })); }); it('skips fetch and returns null status when feature flag is disabled', async () => { @@ -67,7 +118,7 @@ describe('useGetCampaignParticipantStatus', () => { expect(result.current.status).toBeNull(); }); - it('fetches and returns status on mount', async () => { + it('fetches and dispatches status on mount', async () => { setupSelectors(SUB_ID, true); mockCall.mockResolvedValueOnce(STATUS as never); @@ -83,6 +134,12 @@ describe('useGetCampaignParticipantStatus', () => { CAMPAIGN_ID, SUB_ID, ); + expect(mockDispatch).toHaveBeenCalledWith( + mockSetCampaignParticipantStatus({ + campaignId: CAMPAIGN_ID, + status: STATUS, + }), + ); expect(result.current.status).toEqual(STATUS); expect(result.current.isLoading).toBe(false); expect(result.current.hasError).toBe(false); @@ -105,7 +162,10 @@ describe('useGetCampaignParticipantStatus', () => { it('subscribes to RewardsController:campaignOptedIn to auto-refetch', () => { setupSelectors(SUB_ID, true); - mockCall.mockResolvedValue({ optedIn: false } as never); + mockCall.mockResolvedValue({ + optedIn: false, + participantCount: 0, + } as never); renderHook(() => useGetCampaignParticipantStatus(CAMPAIGN_ID)); @@ -116,9 +176,10 @@ describe('useGetCampaignParticipantStatus', () => { }); it('allows manual refetch', async () => { + const INITIAL_STATUS = { optedIn: false, participantCount: 0 }; setupSelectors(SUB_ID, true); mockCall - .mockResolvedValueOnce({ optedIn: false } as never) + .mockResolvedValueOnce(INITIAL_STATUS as never) .mockResolvedValueOnce(STATUS as never); const { result, waitForNextUpdate } = renderHook(() => @@ -127,7 +188,7 @@ describe('useGetCampaignParticipantStatus', () => { await act(async () => { await waitForNextUpdate(); }); - expect(result.current.status).toEqual({ optedIn: false }); + expect(result.current.status).toEqual(INITIAL_STATUS); await act(async () => { result.current.refetch(); diff --git a/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.ts b/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.ts index d4b2166f77d..a8383061879 100644 --- a/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.ts +++ b/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.ts @@ -1,8 +1,10 @@ import { useCallback, useEffect, useMemo, 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 { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; +import { selectCampaignParticipantStatusById } from '../../../../reducers/rewards/selectors'; +import { setCampaignParticipantStatus } from '../../../../reducers/rewards'; import type { CampaignParticipantStatusDto } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; @@ -27,15 +29,13 @@ export const useGetCampaignParticipantStatus = ( ): UseGetCampaignParticipantStatusResult => { const subscriptionId = useSelector(selectRewardsSubscriptionId); const isCampaignsEnabled = useSelector(selectCampaignsRewardsEnabledFlag); - const [status, setStatus] = useState( - null, - ); + const status = useSelector(selectCampaignParticipantStatusById(campaignId)); + const dispatch = useDispatch(); const [isLoading, setIsLoading] = useState(false); const [hasError, setHasError] = useState(false); const fetchStatus = useCallback(async (): Promise => { if (!isCampaignsEnabled || !subscriptionId || !campaignId) { - setStatus(null); return; } @@ -47,13 +47,13 @@ export const useGetCampaignParticipantStatus = ( campaignId, subscriptionId, ); - setStatus(result); + dispatch(setCampaignParticipantStatus({ campaignId, status: result })); } catch { setHasError(true); } finally { setIsLoading(false); } - }, [subscriptionId, isCampaignsEnabled, campaignId]); + }, [dispatch, subscriptionId, isCampaignsEnabled, campaignId]); useEffect(() => { fetchStatus(); diff --git a/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts b/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts index fa2f47b3254..6c8140c664b 100644 --- a/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts +++ b/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts @@ -28,7 +28,7 @@ const mockUseSelector = useSelector as jest.MockedFunction; const SUB_ID = 'sub-123'; const CAMPAIGN_ID = 'camp-456'; -const STATUS = { optedIn: true }; +const STATUS = { optedIn: true, participantCount: 42 }; function setupSelectors( subscriptionId: string | null, diff --git a/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts b/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts index 9c6c58b4662..050039e053b 100644 --- a/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts +++ b/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts @@ -61,6 +61,19 @@ jest.mock('./useInvalidateByRewardEvents', () => ({ useInvalidateByRewardEvents: jest.fn(), })); +jest.mock('../components/Campaigns/CampaignTile.utils', () => ({ + getCampaignStatus: jest.fn( + (campaign: { startDate: string; endDate: string }) => { + const now = new Date(); + const startDate = new Date(campaign.startDate); + const endDate = new Date(campaign.endDate); + if (now < startDate) return 'upcoming'; + if (now >= startDate && now < endDate) return 'active'; + return 'complete'; + }, + ), +})); + const createTestCampaign = ( overrides: Partial = {}, ): CampaignDto => ({ @@ -72,6 +85,7 @@ const createTestCampaign = ( termsAndConditions: null, excludedRegions: [], statusLabel: 'Active', + details: null, ...overrides, }); @@ -336,4 +350,104 @@ describe('useRewardCampaigns', () => { ); }); }); + + describe('categorizedCampaigns', () => { + it('categorizes campaigns into active, upcoming, and previous', () => { + const activeCampaign = createTestCampaign({ + id: 'active-1', + startDate: '2020-01-01T00:00:00.000Z', + endDate: '2099-12-31T23:59:59.999Z', + }); + const upcomingCampaign = createTestCampaign({ + id: 'upcoming-1', + startDate: '2099-06-01T00:00:00.000Z', + endDate: '2099-12-31T23:59:59.999Z', + }); + const completeCampaign = createTestCampaign({ + id: 'complete-1', + startDate: '2020-01-01T00:00:00.000Z', + endDate: '2020-12-31T23:59:59.999Z', + }); + + setupSelectorMocks({ + campaigns: [activeCampaign, upcomingCampaign, completeCampaign], + }); + + const { result } = renderHook(() => useRewardCampaigns()); + + expect(result.current.categorizedCampaigns.active).toEqual([ + activeCampaign, + ]); + expect(result.current.categorizedCampaigns.upcoming).toEqual([ + upcomingCampaign, + ]); + expect(result.current.categorizedCampaigns.previous).toEqual([ + completeCampaign, + ]); + }); + + it('returns empty categories when no campaigns', () => { + setupSelectorMocks({ campaigns: [] }); + + const { result } = renderHook(() => useRewardCampaigns()); + + expect(result.current.categorizedCampaigns).toEqual({ + active: [], + upcoming: [], + previous: [], + }); + }); + + it('sorts upcoming by startDate ascending', () => { + const upcomingLater = createTestCampaign({ + id: 'upcoming-2', + startDate: '2099-09-01T00:00:00.000Z', + endDate: '2099-12-31T23:59:59.999Z', + }); + const upcomingEarlier = createTestCampaign({ + id: 'upcoming-1', + startDate: '2099-06-01T00:00:00.000Z', + endDate: '2099-12-31T23:59:59.999Z', + }); + + setupSelectorMocks({ + campaigns: [upcomingLater, upcomingEarlier], + }); + + const { result } = renderHook(() => useRewardCampaigns()); + + expect(result.current.categorizedCampaigns.upcoming[0].id).toBe( + 'upcoming-1', + ); + expect(result.current.categorizedCampaigns.upcoming[1].id).toBe( + 'upcoming-2', + ); + }); + + it('sorts previous by endDate descending', () => { + const completeOlder = createTestCampaign({ + id: 'complete-1', + startDate: '2020-01-01T00:00:00.000Z', + endDate: '2020-06-30T23:59:59.999Z', + }); + const completeNewer = createTestCampaign({ + id: 'complete-2', + startDate: '2020-07-01T00:00:00.000Z', + endDate: '2020-12-31T23:59:59.999Z', + }); + + setupSelectorMocks({ + campaigns: [completeOlder, completeNewer], + }); + + const { result } = renderHook(() => useRewardCampaigns()); + + expect(result.current.categorizedCampaigns.previous[0].id).toBe( + 'complete-2', + ); + expect(result.current.categorizedCampaigns.previous[1].id).toBe( + 'complete-1', + ); + }); + }); }); diff --git a/app/components/UI/Rewards/hooks/useRewardCampaigns.ts b/app/components/UI/Rewards/hooks/useRewardCampaigns.ts index 8309f87fb99..47ec5398881 100644 --- a/app/components/UI/Rewards/hooks/useRewardCampaigns.ts +++ b/app/components/UI/Rewards/hooks/useRewardCampaigns.ts @@ -16,10 +16,19 @@ import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; import type { CampaignDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; + +interface CategorizedCampaigns { + active: CampaignDto[]; + upcoming: CampaignDto[]; + previous: CampaignDto[]; +} interface UseRewardCampaignsReturn { /** Campaigns fetched from the API, or empty array when flag is disabled */ campaigns: CampaignDto[]; + /** Campaigns categorized by status */ + categorizedCampaigns: CategorizedCampaigns; /** Whether campaigns are loading */ isLoading: boolean; /** Whether there was an error fetching campaigns */ @@ -30,6 +39,7 @@ interface UseRewardCampaignsReturn { /** * Custom hook to fetch and manage campaigns data from the rewards API. + * Categorizes campaigns into active, upcoming, and previous (complete). * Returns an empty list when the rewards-campaigns-enabled feature flag is off. */ export const useRewardCampaigns = (): UseRewardCampaignsReturn => { @@ -72,6 +82,39 @@ export const useRewardCampaigns = (): UseRewardCampaignsReturn => { } }, [dispatch, subscriptionId, isCampaignsEnabled]); + const categorizedCampaigns = useMemo((): CategorizedCampaigns => { + const campaignsList = campaigns ?? []; + const active: CampaignDto[] = []; + const upcoming: CampaignDto[] = []; + const previous: CampaignDto[] = []; + + campaignsList.forEach((campaign) => { + const status = getCampaignStatus(campaign); + switch (status) { + case 'active': + active.push(campaign); + break; + case 'upcoming': + upcoming.push(campaign); + break; + case 'complete': + previous.push(campaign); + break; + } + }); + + upcoming.sort( + (a, b) => + new Date(a.startDate).getTime() - new Date(b.startDate).getTime(), + ); + + previous.sort( + (a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime(), + ); + + return { active, upcoming, previous }; + }, [campaigns]); + useFocusEffect( useCallback(() => { fetchCampaigns(); @@ -91,6 +134,7 @@ export const useRewardCampaigns = (): UseRewardCampaignsReturn => { return { campaigns: campaigns ?? [], + categorizedCampaigns, isLoading, hasError, fetchCampaigns, diff --git a/app/components/UI/Rewards/hooks/useSnapshots.test.ts b/app/components/UI/Rewards/hooks/useSnapshots.test.ts deleted file mode 100644 index 7868e11991b..00000000000 --- a/app/components/UI/Rewards/hooks/useSnapshots.test.ts +++ /dev/null @@ -1,526 +0,0 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import { useDispatch, useSelector } from 'react-redux'; -import { useFocusEffect } from '@react-navigation/native'; -import { useSnapshots } from './useSnapshots'; -import Engine from '../../../../core/Engine'; -import { - setSnapshots, - setSnapshotsLoading, - setSnapshotsError, -} from '../../../../reducers/rewards'; -import { - selectSeasonId, - selectSnapshots, - selectSnapshotsLoading, - selectSnapshotsError, -} from '../../../../reducers/rewards/selectors'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { selectSnapshotsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; -import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; -import { getSnapshotStatus } from '../components/SnapshotTile/SnapshotTile.utils'; -import type { SnapshotDto } from '../../../../core/Engine/controllers/rewards-controller/types'; - -// Mock dependencies -jest.mock('react-redux', () => ({ - useDispatch: jest.fn(), - useSelector: jest.fn(), -})); - -jest.mock('../../../../core/Engine', () => ({ - controllerMessenger: { - call: jest.fn(), - }, -})); - -jest.mock('../../../../reducers/rewards', () => ({ - setSnapshots: jest.fn(), - setSnapshotsLoading: jest.fn(), - setSnapshotsError: jest.fn(), -})); - -jest.mock('../../../../reducers/rewards/selectors', () => ({ - selectSeasonId: jest.fn(), - selectSnapshots: jest.fn(), - selectSnapshotsLoading: jest.fn(), - selectSnapshotsError: jest.fn(), -})); - -jest.mock('../../../../selectors/rewards', () => ({ - selectRewardsSubscriptionId: jest.fn(), -})); - -jest.mock('../../../../selectors/featureFlagController/rewards', () => ({ - selectSnapshotsRewardsEnabledFlag: jest.fn(), -})); - -jest.mock('@react-navigation/native', () => ({ - useFocusEffect: jest.fn(), -})); - -jest.mock('./useInvalidateByRewardEvents', () => ({ - useInvalidateByRewardEvents: jest.fn(), -})); - -jest.mock('../components/SnapshotTile/SnapshotTile.utils', () => ({ - getSnapshotStatus: jest.fn(), -})); - -/** - * Creates a test snapshot with customizable overrides - */ -const createTestSnapshot = ( - overrides: Partial = {}, -): SnapshotDto => ({ - id: `snapshot-${Math.random().toString(36).substr(2, 9)}`, - seasonId: 'season-1', - name: 'Test Snapshot', - description: 'Test description', - tokenSymbol: 'TEST', - tokenAmount: '1000000000000000000', - tokenChainId: '1', - tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - ...overrides, -}); - -describe('useSnapshots', () => { - const mockDispatch = jest.fn(); - const mockUseFocusEffect = useFocusEffect as jest.MockedFunction< - typeof useFocusEffect - >; - const mockEngineCall = Engine.controllerMessenger.call as jest.MockedFunction< - typeof Engine.controllerMessenger.call - >; - const mockUseDispatch = useDispatch as jest.MockedFunction< - typeof useDispatch - >; - const mockUseSelector = useSelector as jest.MockedFunction< - typeof useSelector - >; - const mockSetSnapshots = setSnapshots as jest.MockedFunction< - typeof setSnapshots - >; - const mockSetSnapshotsLoading = setSnapshotsLoading as jest.MockedFunction< - typeof setSnapshotsLoading - >; - const mockSetSnapshotsError = setSnapshotsError as jest.MockedFunction< - typeof setSnapshotsError - >; - const mockUseInvalidateByRewardEvents = - useInvalidateByRewardEvents as jest.MockedFunction< - typeof useInvalidateByRewardEvents - >; - const mockGetSnapshotStatus = getSnapshotStatus as jest.MockedFunction< - typeof getSnapshotStatus - >; - - beforeEach(() => { - jest.clearAllMocks(); - mockUseDispatch.mockReturnValue(mockDispatch); - mockSetSnapshots.mockReturnValue({ - type: 'rewards/setSnapshots', - payload: null, - }); - mockSetSnapshotsLoading.mockReturnValue({ - type: 'rewards/setSnapshotsLoading', - payload: false, - }); - mockSetSnapshotsError.mockReturnValue({ - type: 'rewards/setSnapshotsError', - payload: false, - }); - mockUseFocusEffect.mockClear(); - mockUseInvalidateByRewardEvents.mockClear(); - }); - - const setupSelectorMocks = ( - options: { - seasonId?: string | null; - subscriptionId?: string | null; - snapshots?: SnapshotDto[] | null; - isLoading?: boolean; - hasError?: boolean; - isSnapshotsEnabled?: boolean; - } = {}, - ) => { - const { - seasonId = 'season-1', - subscriptionId = 'subscription-1', - snapshots = null, - isLoading = false, - hasError = false, - isSnapshotsEnabled = true, - } = options; - - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonId) return seasonId; - if (selector === selectRewardsSubscriptionId) return subscriptionId; - if (selector === selectSnapshots) return snapshots; - if (selector === selectSnapshotsLoading) return isLoading; - if (selector === selectSnapshotsError) return hasError; - if (selector === selectSnapshotsRewardsEnabledFlag) - return isSnapshotsEnabled; - return undefined; - }); - }; - - describe('initial state', () => { - it('returns initial state from selectors', () => { - const testSnapshots = [createTestSnapshot()]; - setupSelectorMocks({ - snapshots: testSnapshots, - isLoading: false, - hasError: false, - }); - mockGetSnapshotStatus.mockReturnValue('live'); - - const { result } = renderHook(() => useSnapshots()); - - expect(result.current.snapshots).toEqual(testSnapshots); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); - expect(typeof result.current.fetchSnapshots).toBe('function'); - }); - }); - - describe('fetchSnapshots', () => { - it('calls Engine controller when fetching snapshots', async () => { - setupSelectorMocks(); - const mockSnapshotsData = [createTestSnapshot()]; - mockEngineCall.mockResolvedValueOnce(mockSnapshotsData); - mockGetSnapshotStatus.mockReturnValue('live'); - - const { result } = renderHook(() => useSnapshots()); - - await act(async () => { - await result.current.fetchSnapshots(); - }); - - expect(mockEngineCall).toHaveBeenCalledWith( - 'RewardsController:getSnapshots', - 'season-1', - 'subscription-1', - ); - }); - - it('dispatches loading state before fetch', async () => { - setupSelectorMocks(); - mockEngineCall.mockResolvedValueOnce([]); - mockGetSnapshotStatus.mockReturnValue('live'); - - const { result } = renderHook(() => useSnapshots()); - - await act(async () => { - await result.current.fetchSnapshots(); - }); - - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsLoading(true)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsError(false)); - }); - - it('dispatches snapshots on successful fetch', async () => { - setupSelectorMocks(); - const mockSnapshotsData = [createTestSnapshot()]; - mockEngineCall.mockResolvedValueOnce(mockSnapshotsData); - mockGetSnapshotStatus.mockReturnValue('live'); - - const { result } = renderHook(() => useSnapshots()); - - await act(async () => { - await result.current.fetchSnapshots(); - }); - - expect(mockDispatch).toHaveBeenCalledWith( - mockSetSnapshots(mockSnapshotsData), - ); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsLoading(false)); - }); - - it('dispatches error state on fetch failure', async () => { - setupSelectorMocks(); - const mockError = new Error('Network failed'); - mockEngineCall.mockRejectedValueOnce(mockError); - mockGetSnapshotStatus.mockReturnValue('live'); - - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - - const { result } = renderHook(() => useSnapshots()); - - await act(async () => { - await result.current.fetchSnapshots(); - }); - - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsError(true)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsLoading(false)); - expect(consoleErrorSpy).toHaveBeenCalledWith('Error fetching snapshots'); - - consoleErrorSpy.mockRestore(); - }); - - it('does not fetch when subscriptionId is null', async () => { - setupSelectorMocks({ subscriptionId: null }); - mockGetSnapshotStatus.mockReturnValue('live'); - - const { result } = renderHook(() => useSnapshots()); - - await act(async () => { - await result.current.fetchSnapshots(); - }); - - expect(mockEngineCall).not.toHaveBeenCalled(); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshots(null)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsLoading(false)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsError(false)); - }); - - it('does not fetch when seasonId is null', async () => { - setupSelectorMocks({ seasonId: null }); - mockGetSnapshotStatus.mockReturnValue('live'); - - const { result } = renderHook(() => useSnapshots()); - - await act(async () => { - await result.current.fetchSnapshots(); - }); - - expect(mockEngineCall).not.toHaveBeenCalled(); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshots(null)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsLoading(false)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsError(false)); - }); - - it('does not fetch when isSnapshotsEnabled is false', async () => { - setupSelectorMocks({ isSnapshotsEnabled: false }); - mockGetSnapshotStatus.mockReturnValue('live'); - - const { result } = renderHook(() => useSnapshots()); - - await act(async () => { - await result.current.fetchSnapshots(); - }); - - expect(mockEngineCall).not.toHaveBeenCalled(); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshots(null)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsLoading(false)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsError(false)); - }); - }); - - describe('categorizedSnapshots', () => { - it('categorizes snapshots by status (live to active, upcoming to upcoming, others to previous)', () => { - const liveSnapshot = createTestSnapshot({ id: 'live-1' }); - const upcomingSnapshot1 = createTestSnapshot({ id: 'upcoming-1' }); - const upcomingSnapshot2 = createTestSnapshot({ id: 'upcoming-2' }); - const calculatingSnapshot = createTestSnapshot({ id: 'calculating-1' }); - const distributingSnapshot = createTestSnapshot({ id: 'distributing-1' }); - const completeSnapshot = createTestSnapshot({ id: 'complete-1' }); - - const allSnapshots = [ - liveSnapshot, - upcomingSnapshot1, - upcomingSnapshot2, - calculatingSnapshot, - distributingSnapshot, - completeSnapshot, - ]; - - setupSelectorMocks({ snapshots: allSnapshots }); - - mockGetSnapshotStatus.mockImplementation((snapshot) => { - switch (snapshot.id) { - case 'live-1': - return 'live'; - case 'upcoming-1': - case 'upcoming-2': - return 'upcoming'; - case 'calculating-1': - return 'calculating'; - case 'distributing-1': - return 'distributing'; - case 'complete-1': - return 'complete'; - default: - return 'upcoming'; - } - }); - - const { result } = renderHook(() => useSnapshots()); - - expect(result.current.categorizedSnapshots.active).toHaveLength(1); - expect(result.current.categorizedSnapshots.active[0].id).toBe('live-1'); - - expect(result.current.categorizedSnapshots.upcoming).toHaveLength(2); - expect( - result.current.categorizedSnapshots.upcoming.map((s) => s.id), - ).toContain('upcoming-1'); - expect( - result.current.categorizedSnapshots.upcoming.map((s) => s.id), - ).toContain('upcoming-2'); - - expect(result.current.categorizedSnapshots.previous).toHaveLength(3); - expect( - result.current.categorizedSnapshots.previous.map((s) => s.id), - ).toContain('calculating-1'); - expect( - result.current.categorizedSnapshots.previous.map((s) => s.id), - ).toContain('distributing-1'); - expect( - result.current.categorizedSnapshots.previous.map((s) => s.id), - ).toContain('complete-1'); - }); - - it('sorts upcoming by opensAt ascending', () => { - const upcomingEarlier = createTestSnapshot({ - id: 'upcoming-earlier', - opensAt: '2025-03-01T00:00:00.000Z', - }); - const upcomingLater = createTestSnapshot({ - id: 'upcoming-later', - opensAt: '2025-03-15T00:00:00.000Z', - }); - const upcomingMiddle = createTestSnapshot({ - id: 'upcoming-middle', - opensAt: '2025-03-08T00:00:00.000Z', - }); - - setupSelectorMocks({ - snapshots: [upcomingLater, upcomingEarlier, upcomingMiddle], - }); - - mockGetSnapshotStatus.mockReturnValue('upcoming'); - - const { result } = renderHook(() => useSnapshots()); - - const upcomingIds = result.current.categorizedSnapshots.upcoming.map( - (s) => s.id, - ); - - expect(upcomingIds).toEqual([ - 'upcoming-earlier', - 'upcoming-middle', - 'upcoming-later', - ]); - }); - - it('sorts previous by closesAt descending', () => { - const previousEarlier = createTestSnapshot({ - id: 'previous-earlier', - closesAt: '2025-01-01T00:00:00.000Z', - }); - const previousLater = createTestSnapshot({ - id: 'previous-later', - closesAt: '2025-03-15T00:00:00.000Z', - }); - const previousMiddle = createTestSnapshot({ - id: 'previous-middle', - closesAt: '2025-02-08T00:00:00.000Z', - }); - - setupSelectorMocks({ - snapshots: [previousEarlier, previousLater, previousMiddle], - }); - - mockGetSnapshotStatus.mockReturnValue('complete'); - - const { result } = renderHook(() => useSnapshots()); - - const previousIds = result.current.categorizedSnapshots.previous.map( - (s) => s.id, - ); - - expect(previousIds).toEqual([ - 'previous-later', - 'previous-middle', - 'previous-earlier', - ]); - }); - - it('returns empty categories when snapshots is null', () => { - setupSelectorMocks({ snapshots: null }); - - const { result } = renderHook(() => useSnapshots()); - - expect(result.current.categorizedSnapshots).toEqual({ - active: [], - upcoming: [], - previous: [], - }); - }); - }); - - describe('useFocusEffect integration', () => { - it('registers focus effect callback', () => { - setupSelectorMocks(); - mockGetSnapshotStatus.mockReturnValue('live'); - - renderHook(() => useSnapshots()); - - expect(mockUseFocusEffect).toHaveBeenCalledWith(expect.any(Function)); - }); - - it('fetches snapshots when focus effect is triggered', async () => { - setupSelectorMocks(); - const mockSnapshotsData = [createTestSnapshot()]; - mockEngineCall.mockResolvedValueOnce(mockSnapshotsData); - mockGetSnapshotStatus.mockReturnValue('live'); - - renderHook(() => useSnapshots()); - - expect(mockUseFocusEffect).toHaveBeenCalledWith(expect.any(Function)); - - const focusCallback = mockUseFocusEffect.mock.calls[0][0]; - - await act(async () => { - focusCallback(); - }); - - expect(mockEngineCall).toHaveBeenCalledWith( - 'RewardsController:getSnapshots', - 'season-1', - 'subscription-1', - ); - }); - }); - - describe('useInvalidateByRewardEvents integration', () => { - it('registers invalidation events', () => { - setupSelectorMocks(); - mockGetSnapshotStatus.mockReturnValue('live'); - - renderHook(() => useSnapshots()); - - expect(mockUseInvalidateByRewardEvents).toHaveBeenCalledWith( - ['RewardsController:accountLinked', 'RewardsController:balanceUpdated'], - expect.any(Function), - ); - }); - - it('passes fetchSnapshots as callback to invalidation hook', async () => { - setupSelectorMocks(); - const mockSnapshotsData = [createTestSnapshot()]; - mockEngineCall.mockResolvedValueOnce(mockSnapshotsData); - mockGetSnapshotStatus.mockReturnValue('live'); - - renderHook(() => useSnapshots()); - - const invalidationCallback = - mockUseInvalidateByRewardEvents.mock.calls[0][1]; - - await act(async () => { - await invalidationCallback(); - }); - - expect(mockEngineCall).toHaveBeenCalledWith( - 'RewardsController:getSnapshots', - 'season-1', - 'subscription-1', - ); - }); - }); -}); diff --git a/app/components/UI/Rewards/hooks/useSnapshots.ts b/app/components/UI/Rewards/hooks/useSnapshots.ts deleted file mode 100644 index 83822261434..00000000000 --- a/app/components/UI/Rewards/hooks/useSnapshots.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { useCallback, useRef, useMemo } from 'react'; -import Engine from '../../../../core/Engine'; -import { useDispatch, useSelector } from 'react-redux'; -import { - setSnapshots, - setSnapshotsLoading, - setSnapshotsError, -} from '../../../../reducers/rewards'; -import { - selectSeasonId, - selectSnapshots, - selectSnapshotsLoading, - selectSnapshotsError, -} from '../../../../reducers/rewards/selectors'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; -import { useFocusEffect } from '@react-navigation/native'; -import type { SnapshotDto } from '../../../../core/Engine/controllers/rewards-controller/types'; -import { getSnapshotStatus } from '../components/SnapshotTile/SnapshotTile.utils'; -import { selectSnapshotsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; - -interface CategorizedSnapshots { - active: SnapshotDto[]; - upcoming: SnapshotDto[]; - previous: SnapshotDto[]; -} - -interface UseSnapshotsReturn { - /** All snapshots */ - snapshots: SnapshotDto[] | null; - /** Categorized snapshots by status */ - categorizedSnapshots: CategorizedSnapshots; - /** Whether snapshots are loading */ - isLoading: boolean; - /** Whether there was an error fetching snapshots */ - hasError: boolean; - /** Fetch snapshots from the API */ - fetchSnapshots: () => Promise; -} - -/** - * Custom hook to fetch and manage snapshots data from the rewards API. - * Categorizes snapshots into active (live), upcoming, and previous (calculating/distributing/complete). - */ -export const useSnapshots = (): UseSnapshotsReturn => { - const seasonId = useSelector(selectSeasonId); - const subscriptionId = useSelector(selectRewardsSubscriptionId); - const snapshots = useSelector(selectSnapshots); - const isLoading = useSelector(selectSnapshotsLoading); - const hasError = useSelector(selectSnapshotsError); - const dispatch = useDispatch(); - const isLoadingRef = useRef(false); - const isSnapshotsEnabled = useSelector(selectSnapshotsRewardsEnabledFlag); - - const fetchSnapshots = useCallback(async (): Promise => { - if (!subscriptionId || !seasonId || !isSnapshotsEnabled) { - dispatch(setSnapshots(null)); - dispatch(setSnapshotsLoading(false)); - dispatch(setSnapshotsError(false)); - return; - } - - if (isLoadingRef.current) { - return; - } - - try { - isLoadingRef.current = true; - dispatch(setSnapshotsLoading(true)); - dispatch(setSnapshotsError(false)); - - const snapshotsData = await Engine.controllerMessenger.call( - 'RewardsController:getSnapshots', - seasonId, - subscriptionId, - ); - - dispatch(setSnapshots(snapshotsData)); - } catch { - dispatch(setSnapshotsError(true)); - console.error('Error fetching snapshots'); - } finally { - isLoadingRef.current = false; - dispatch(setSnapshotsLoading(false)); - } - }, [dispatch, seasonId, subscriptionId, isSnapshotsEnabled]); - - // Categorize snapshots by status - const categorizedSnapshots = useMemo((): CategorizedSnapshots => { - if (!snapshots) { - return { active: [], upcoming: [], previous: [] }; - } - - const active: SnapshotDto[] = []; - const upcoming: SnapshotDto[] = []; - const previous: SnapshotDto[] = []; - - snapshots.forEach((snapshot) => { - const status = getSnapshotStatus(snapshot); - switch (status) { - case 'live': - active.push(snapshot); - break; - case 'upcoming': - upcoming.push(snapshot); - break; - case 'calculating': - case 'distributing': - case 'complete': - previous.push(snapshot); - break; - } - }); - - // Sort upcoming by opensAt date (earliest first) - upcoming.sort( - (a, b) => new Date(a.opensAt).getTime() - new Date(b.opensAt).getTime(), - ); - - // Sort previous by closesAt date (most recent first) - previous.sort( - (a, b) => new Date(b.closesAt).getTime() - new Date(a.closesAt).getTime(), - ); - - return { active, upcoming, previous }; - }, [snapshots]); - - useFocusEffect( - useCallback(() => { - fetchSnapshots(); - }, [fetchSnapshots]), - ); - - const invalidateEvents = useMemo( - () => - [ - 'RewardsController:accountLinked', - 'RewardsController:balanceUpdated', - ] as const, - [], - ); - - // Listen for reward events to trigger refetch - useInvalidateByRewardEvents(invalidateEvents, fetchSnapshots); - - return { - snapshots, - categorizedSnapshots, - isLoading, - hasError, - fetchSnapshots, - }; -}; diff --git a/app/components/UI/SDKLoading/index.tsx b/app/components/UI/SDKLoading/index.tsx index 5025dc02cea..f87b5411770 100644 --- a/app/components/UI/SDKLoading/index.tsx +++ b/app/components/UI/SDKLoading/index.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, import/no-commonjs */ +/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, import-x/no-commonjs */ import type { ThemeColors } from '@metamask/design-tokens'; import LottieView from 'lottie-react-native'; import React from 'react'; diff --git a/app/components/UI/SecurityOptionToggle/styles.ts b/app/components/UI/SecurityOptionToggle/styles.ts index 73b35c8fcd8..dc6692e3dcd 100644 --- a/app/components/UI/SecurityOptionToggle/styles.ts +++ b/app/components/UI/SecurityOptionToggle/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; export const createStyles = () => diff --git a/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.test.tsx b/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.test.tsx new file mode 100644 index 00000000000..7a6454e3cad --- /dev/null +++ b/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.test.tsx @@ -0,0 +1,251 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { Linking, useColorScheme } from 'react-native'; +import SecurityTrustScreen from './SecurityTrustScreen'; +import { strings } from '../../../../../locales/i18n'; + +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); + +jest.mock('react-native', () => { + const actual = jest.requireActual('react-native'); + return { + ...actual, + Linking: { + openURL: jest.fn(() => Promise.resolve()), + }, + useColorScheme: jest.fn(() => 'light'), + }; +}); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + }), + useRoute: () => ({ + params: { + address: '0x1234567890abcdef', + chainId: '0x1', + symbol: 'TEST', + decimals: 18, + name: 'Test Token', + isNative: false, + securityData: { + resultType: 'Verified', + maliciousScore: '0', + features: [ + { + featureId: 'VERIFIED_CONTRACT', + type: 'Info', + description: 'Contract is verified', + }, + { + featureId: 'HIGH_REPUTATION_TOKEN', + type: 'Benign', + description: 'Token has high reputation', + }, + ], + fees: { + buy: 1, + sell: 2, + transfer: 0, + transferFeeMaxAmount: null, + }, + financialStats: { + supply: 1000000000000000000000000, + holdersCount: 5000, + topHolders: [ + { + label: 'Holder 1', + name: null, + address: '0xholder1', + holdingPercentage: 15, + }, + { + label: 'Holder 2', + name: null, + address: '0xholder2', + holdingPercentage: 10, + }, + ], + tradeVolume24h: 1000000, + lockedLiquidityPct: 80, + markets: [], + }, + metadata: { + externalLinks: { + homepage: 'https://example.com', + twitterPage: 'testtoken', + telegramChannelId: 'testtoken', + }, + }, + created: '2023-01-01T00:00:00Z', + }, + }, + }), +})); + +jest.mock('../../../Views/confirmations/hooks/useNetworkName', () => ({ + useNetworkName: () => 'Ethereum Mainnet', +})); + +jest.mock('../../../hooks/useBlockExplorer', () => ({ + __esModule: true, + default: () => ({ + getBlockExplorerTokenUrl: (address: string) => + `https://etherscan.io/address/${address}`, + getBlockExplorerName: () => 'Etherscan', + }), +})); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ + top: 0, + bottom: 0, + left: 0, + right: 0, + }), +})); + +jest.mock('../../TokenDetails/components/TokenDetailsStickyFooter', () => ({ + __esModule: true, + default: () => null, +})); + +jest.mock('../../TokenDetails/hooks/useTokenActions', () => ({ + useTokenActions: jest.fn(() => ({ + onBuy: jest.fn(), + goToSwaps: jest.fn(), + hasEligibleSwapTokens: true, + networkModal: null, + })), +})); + +describe('SecurityTrustScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + const { getByText } = render(); + expect(getByText(strings('security_trust.verified'))).toBeTruthy(); + }); + + it('displays token security result label', () => { + const { getByText } = render(); + expect(getByText(strings('security_trust.verified'))).toBeTruthy(); + }); + + it('displays token distribution section', () => { + const { getByText } = render(); + expect( + getByText(strings('security_trust.token_distribution')), + ).toBeTruthy(); + expect(getByText(strings('security_trust.total_supply'))).toBeTruthy(); + }); + + it('displays token info section', () => { + const { getByText } = render(); + expect(getByText(strings('security_trust.token_info'))).toBeTruthy(); + expect(getByText('Type')).toBeTruthy(); + expect(getByText('Network')).toBeTruthy(); + expect(getByText('ERC-20')).toBeTruthy(); + expect(getByText('Ethereum Mainnet')).toBeTruthy(); + }); + + it('displays buy and sell tax section', () => { + const { getByText } = render(); + expect(getByText('Buy/Sell Tax')).toBeTruthy(); + expect(getByText('Buy tax')).toBeTruthy(); + expect(getByText('Sell tax')).toBeTruthy(); + }); + + it('displays official links section when metadata is available', () => { + const { getByText } = render(); + expect(getByText(strings('security_trust.official_links'))).toBeTruthy(); + expect(getByText(strings('security_trust.website'))).toBeTruthy(); + expect(getByText('@testtoken')).toBeTruthy(); + }); + + it('displays disclaimer at the bottom', () => { + const { getByText } = render(); + expect( + getByText(strings('security_trust.evaluation_disclaimer')), + ).toBeTruthy(); + }); + + it('calls navigation.goBack when back button is pressed', () => { + const { getByTestId } = render(); + + const backButton = getByTestId('security-trust-back-button'); + fireEvent.press(backButton); + + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('opens external link when link is pressed', () => { + const mockOpenURL = Linking.openURL as jest.Mock; + mockOpenURL.mockReturnValue(Promise.resolve()); + + const { getByText } = render(); + + const websiteLink = getByText(strings('security_trust.website')); + fireEvent.press(websiteLink); + + expect(mockOpenURL).toHaveBeenCalledWith('https://example.com'); + }); + + it('handles link opening errors gracefully', async () => { + const mockOpenURL = Linking.openURL as jest.Mock; + mockOpenURL.mockReturnValue(Promise.reject(new Error('Failed to open'))); + + const { getByText } = render(); + + const websiteLink = getByText(strings('security_trust.website')); + fireEvent.press(websiteLink); + + expect(mockOpenURL).toHaveBeenCalledWith('https://example.com'); + }); + + it('applies dark mode color scheme to progress bar', () => { + const mockUseColorScheme = useColorScheme as jest.Mock; + mockUseColorScheme.mockReturnValue('dark'); + + render(); + + expect(mockUseColorScheme).toHaveBeenCalled(); + }); + + it('applies light mode color scheme to progress bar', () => { + const mockUseColorScheme = useColorScheme as jest.Mock; + mockUseColorScheme.mockReturnValue('light'); + + render(); + + expect(mockUseColorScheme).toHaveBeenCalled(); + }); + + it('displays correct fee values from mock data', () => { + const { getByText } = render(); + + expect(getByText('1.0%')).toBeTruthy(); + expect(getByText('2.0%')).toBeTruthy(); + expect(getByText('0.0%')).toBeTruthy(); + }); + + it('displays correct holder distribution from topHolders array', () => { + const { getByText } = render(); + + expect(getByText('25.0%')).toBeTruthy(); + expect(getByText('75.0%')).toBeTruthy(); + }); + + it('renders feature tags from TokenSecurityFeature objects', () => { + const { getByText } = render(); + + expect(getByText('Published contract')).toBeTruthy(); + expect(getByText('Established reputation')).toBeTruthy(); + }); +}); diff --git a/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx b/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx new file mode 100644 index 00000000000..70b4687e6b2 --- /dev/null +++ b/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx @@ -0,0 +1,626 @@ +import React, { useCallback } from 'react'; +import { + ScrollView, + View, + Linking, + TouchableOpacity, + useColorScheme, +} from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + Text, + TextVariant, + TextColor, + Icon, + IconName, + IconSize, + IconColor, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + FontWeight, + ButtonBase, + ButtonBaseSize, +} from '@metamask/design-system-react-native'; +import { useNavigation, useRoute } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import type { Hex } from '@metamask/utils'; +import { strings } from '../../../../../locales/i18n'; +import { useNetworkName } from '../../../Views/confirmations/hooks/useNetworkName'; +import type { TokenDetailsRouteParams } from '../../TokenDetails/constants/constants'; +import { + getFeatureTags, + formatFeePercent, + getTop10HoldingPct, + formatCompactSupply, + getResultTypeConfig, +} from '../utils/securityUtils'; +import TokenDetailsStickyFooter from '../../TokenDetails/components/TokenDetailsStickyFooter'; +import useBlockExplorer from '../../../hooks/useBlockExplorer'; +import { useTokenActions } from '../../TokenDetails/hooks/useTokenActions'; + +const SectionHeader: React.FC<{ title: string }> = ({ title }) => ( + + {title} + +); + +const SecurityTrustScreen: React.FC = () => { + const tw = useTailwind(); + const colorScheme = useColorScheme(); + const navigation = useNavigation(); + const route = useRoute(); + const insets = useSafeAreaInsets(); + + const params = route.params as TokenDetailsRouteParams; + const securityData = params?.securityData ?? null; + const explorer = useBlockExplorer(params?.chainId); + const networkName = useNetworkName(params?.chainId as Hex); + + // Get action handlers from hook (single source of truth) + const { onBuy, goToSwaps, hasEligibleSwapTokens, networkModal } = + useTokenActions({ + token: params, + networkName, + }); + + const fees = securityData?.fees ?? null; + const features = securityData?.features ?? []; + const { tags: featureTags } = getFeatureTags( + features, + securityData?.resultType, + true, + ); + + const { + label: resultLabel, + textColor: resultTextColor, + subtitle: resultSubtitle, + icon: tagIcon, + iconColor: tagIconColor, + } = getResultTypeConfig(securityData?.resultType); + const financialStats = securityData?.financialStats ?? null; + const metadata = securityData?.metadata ?? null; + + const top10Pct = getTop10HoldingPct(financialStats); + const otherPct = top10Pct !== null ? Math.max(0, 100 - top10Pct) : null; + const barFillStyle = React.useMemo( + () => ({ width: `${top10Pct ?? 0}%` as `${number}%` }), + [top10Pct], + ); + const formattedCreatedDate = React.useMemo(() => { + const raw = securityData?.created; + if (!raw) return strings('security_trust.na'); + try { + return new Date(raw).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch { + return raw; + } + }, [securityData?.created]); + + const tokenAgeDisplay = React.useMemo(() => { + const raw = securityData?.created; + if (!raw) return strings('security_trust.na'); + try { + const diffMs = Date.now() - new Date(raw).getTime(); + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (days < 30) return `${days}d`; + if (days < 365) return `${Math.floor(days / 30)}mo`; + return `${Math.floor(days / 365)}yr`; + } catch { + return strings('security_trust.na'); + } + }, [securityData?.created]); + + const tokenType = params?.isNative ? 'Native' : 'ERC-20'; + + const openLink = useCallback((url: string) => { + Linking.openURL(url).catch(() => null); + }, []); + + const scrollContentStyle = React.useMemo( + () => ({ + paddingTop: 16, + paddingBottom: insets.bottom + 24, + paddingLeft: 16, + paddingRight: 16, + }), + [insets.bottom], + ); + + return ( + + {networkModal} + + navigation.goBack()} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + testID="security-trust-back-button" + > + + + + + {strings('security_trust.title')} + + + + + + + {/* ══ Section 1: Security Score header ════════════════════════════════ */} + + + {resultLabel} + + + {resultSubtitle} + + {featureTags.length > 0 && ( + + {featureTags.map((tag) => ( + + {tagIcon && tagIconColor && ( + + )} + + {tag.label} + + + ))} + + )} + + + + + + + {/* ══ Section 2: Token Distribution ═══════════════════════════════════ */} + + + + + + {strings('security_trust.total_supply')} + + + {formatCompactSupply(financialStats?.supply, params?.decimals)}{' '} + {params?.symbol ?? ''} + + + + + {top10Pct !== null && ( + + + + + + )} + + + + + + + {strings('security_trust.top_10_holders')} + + + + {top10Pct !== null + ? `${top10Pct.toFixed(1)}%` + : strings('security_trust.na')} + + + + + + + + {strings('security_trust.other')} + + + + {otherPct !== null + ? `${otherPct.toFixed(1)}%` + : strings('security_trust.na')} + + + + + + + + + {/* ══ Section 8: Buy/Sell Tax ══════════════════════════════════════════ */} + + + + {( + [ + { label: strings('security_trust.buy_tax'), value: fees?.buy }, + { + label: strings('security_trust.sell_tax'), + value: fees?.sell, + }, + { + label: strings('security_trust.transfer'), + value: fees?.transfer, + }, + ] as const + ).map(({ label, value }) => ( + + + {formatFeePercent(value)} + + + {label} + + + ))} + + {fees !== null && + fees.transfer === 0 && + fees.buy === 0 && + fees.sell === 0 && ( + + + + {strings('security_trust.no_hidden_fees_detected')} + + + )} + + + + + + + {/* ══ Section 9: Token Info ════════════════════════════════════════════ */} + + + + + + {strings('security_trust.created')} + + + {formattedCreatedDate} + + + + + {strings('security_trust.token_age')} + + + {tokenAgeDisplay} + + + + + + + {strings('security_trust.network')} + + + {networkName ?? strings('security_trust.na')} + + + + + {strings('security_trust.type')} + + + {tokenType} + + + + + + + + + + {/* ══ Section 11: Official Links ═══════════════════════════════════════ */} + {metadata?.externalLinks && ( + <> + + + {metadata.externalLinks.homepage && ( + + openLink(metadata.externalLinks.homepage || '') + } + size={ButtonBaseSize.Md} + twClassName={(pressed) => + `rounded-lg bg-muted px-3 ${pressed ? 'opacity-70' : ''}` + } + startIconName={IconName.Global} + startIconProps={{ + color: IconColor.IconDefault, + size: IconSize.Sm, + }} + > + + {strings('security_trust.website')} + + + )} + {metadata.externalLinks.twitterPage && ( + + openLink( + `https://x.com/${metadata.externalLinks.twitterPage}`, + ) + } + size={ButtonBaseSize.Md} + twClassName={(pressed) => + `rounded-lg bg-muted px-3 ${pressed ? 'opacity-70' : ''}` + } + startIconName={IconName.X} + startIconProps={{ + color: IconColor.IconDefault, + size: IconSize.Sm, + }} + > + + {`@${metadata.externalLinks.twitterPage}`} + + + )} + {metadata.externalLinks.telegramChannelId && ( + + openLink( + `https://t.me/${metadata.externalLinks.telegramChannelId}`, + ) + } + size={ButtonBaseSize.Md} + twClassName={(pressed) => + `rounded-lg bg-muted px-3 ${pressed ? 'opacity-70' : ''}` + } + startIconName={IconName.Global} + startIconProps={{ + color: IconColor.IconDefault, + size: IconSize.Sm, + }} + > + + {strings('security_trust.telegram')} + + + )} + {Boolean(params?.address && !params.isNative) && + (() => { + const blockExplorerUrl = explorer.getBlockExplorerTokenUrl( + params.address, + params.chainId, + ); + const blockExplorerName = explorer.getBlockExplorerName( + params.chainId, + ); + + return blockExplorerUrl ? ( + openLink(blockExplorerUrl)} + size={ButtonBaseSize.Md} + twClassName={(pressed) => + `rounded-lg bg-muted px-3 ${pressed ? 'opacity-70' : ''}` + } + startIconName={IconName.Global} + startIconProps={{ + color: IconColor.IconDefault, + size: IconSize.Sm, + }} + > + + {blockExplorerName || + strings('security_trust.etherscan')} + + + ) : null; + })()} + + + )} + + + + + + {strings('security_trust.evaluation_disclaimer')} + + + + + + ); +}; + +export default SecurityTrustScreen; diff --git a/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.test.tsx b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.test.tsx new file mode 100644 index 00000000000..93541e833ec --- /dev/null +++ b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.test.tsx @@ -0,0 +1,254 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import SecurityTrustEntryCard from './SecurityTrustEntryCard'; +import { strings } from '../../../../../../locales/i18n'; +import type { TokenSecurityData } from '../../types'; +import type { TokenDetailsRouteParams } from '../../../TokenDetails/constants/constants'; +import Routes from '../../../../../constants/navigation/Routes'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + }), +})); + +const mockToken: TokenDetailsRouteParams = { + address: '0x1234567890abcdef', + chainId: '0x1', + symbol: 'TEST', + decimals: 18, + name: 'Test Token', + isNative: false, + image: 'https://example.com/token.png', + balance: '1000000000000000000', + logo: 'https://example.com/logo.png', + isETH: false, +}; + +const mockSecurityData: TokenSecurityData = { + resultType: 'Verified', + maliciousScore: '0', + features: [ + { + featureId: 'liquidity_pools', + type: 'info', + description: 'Has liquidity pools', + }, + { + featureId: 'verified_contract', + type: 'info', + description: 'Contract is verified', + }, + ], + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0.01, + sell: 0.02, + }, + financialStats: { + supply: 1000000000000000000000000, + topHolders: [], + holdersCount: 5000, + tradeVolume24h: null, + lockedLiquidityPct: null, + markets: [], + }, + metadata: { + externalLinks: { + homepage: 'https://example.com', + twitterPage: 'testtoken', + telegramChannelId: null, + }, + }, + created: '2023-01-01T00:00:00Z', +}; + +describe('SecurityTrustEntryCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders loading state with skeletons', () => { + const { queryByText, getByTestId } = render( + , + ); + + expect(queryByText(strings('security_trust.title'))).toBeNull(); + expect(getByTestId('security-trust-entry-card')).toBeTruthy(); + }); + + it('renders security data with title and result label', () => { + const { getByText } = render( + , + ); + + expect(getByText(strings('security_trust.title'))).toBeTruthy(); + expect(getByText(strings('security_trust.verified'))).toBeTruthy(); + }); + + it('displays arrow icon when details are available', () => { + const { getByText } = render( + , + ); + + expect(getByText(strings('security_trust.title'))).toBeTruthy(); + expect(getByText(strings('security_trust.verified'))).toBeTruthy(); + }); + + it('navigates to security trust screen when pressed with details', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('security-trust-entry-card')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.SECURITY_TRUST, { + ...mockToken, + securityData: mockSecurityData, + }); + }); + + it('does not navigate when pressed without details', () => { + const securityDataNoFeatures: TokenSecurityData = { + resultType: 'Verified', + maliciousScore: '0', + features: [], + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0, + sell: null, + }, + financialStats: { + supply: 0, + topHolders: [], + holdersCount: 0, + tradeVolume24h: null, + lockedLiquidityPct: null, + markets: [], + }, + metadata: { + externalLinks: { + homepage: null, + twitterPage: null, + telegramChannelId: null, + }, + }, + created: '2023-01-01T00:00:00Z', + }; + + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('security-trust-entry-card')); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not display arrow icon when no details available', () => { + const securityDataNoFeatures: TokenSecurityData = { + resultType: 'Verified', + maliciousScore: '0', + features: [], + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0, + sell: null, + }, + financialStats: { + supply: 0, + topHolders: [], + holdersCount: 0, + tradeVolume24h: null, + lockedLiquidityPct: null, + markets: [], + }, + metadata: { + externalLinks: { + homepage: null, + twitterPage: null, + telegramChannelId: null, + }, + }, + created: '2023-01-01T00:00:00Z', + }; + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('icon-arrow-right')).toBeNull(); + }); + + it('displays subtitle when no features but subtitle exists', () => { + const securityDataNoFeatures: TokenSecurityData = { + resultType: 'NotEnoughData', + maliciousScore: '0', + features: [], + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0, + sell: null, + }, + financialStats: { + supply: 0, + topHolders: [], + holdersCount: 0, + tradeVolume24h: null, + lockedLiquidityPct: null, + markets: [], + }, + metadata: { + externalLinks: { + homepage: null, + twitterPage: null, + telegramChannelId: null, + }, + }, + created: '2023-01-01T00:00:00Z', + }; + + const { getByText } = render( + , + ); + + expect( + getByText('Security analysis could not be loaded for this token.'), + ).toBeTruthy(); + }); +}); diff --git a/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx new file mode 100644 index 00000000000..cd5a30f182f --- /dev/null +++ b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { Pressable } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import Skeleton from '../../../../../component-library/components-temp/Skeleton/Skeleton'; +import { + Box, + Text, + TextVariant, + TextColor, + Icon, + IconName, + IconSize, + IconColor, + BoxFlexDirection, + BoxAlignItems, + FontWeight, +} from '@metamask/design-system-react-native'; +import { useNavigation } from '@react-navigation/native'; +import type { TokenSecurityData } from '../../types'; +import { getFeatureTags, getResultTypeConfig } from '../../utils/securityUtils'; +import type { TokenDetailsRouteParams } from '../../../TokenDetails/constants/constants'; +import Routes from '../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../locales/i18n'; + +interface SecurityTrustEntryCardProps { + securityData: TokenSecurityData | null; + isLoading: boolean; + token: TokenDetailsRouteParams; +} + +const SecurityTrustEntryCard: React.FC = ({ + securityData, + isLoading, + token, +}) => { + const tw = useTailwind(); + const navigation = useNavigation(); + + const config = getResultTypeConfig(securityData?.resultType); + const tagIcon = config.icon; + const tagIconColor = config.iconColor; + const { tags: featureTags, remainingCount } = securityData + ? getFeatureTags(securityData.features ?? [], securityData.resultType) + : { tags: [], remainingCount: 0 }; + + const hasDetails = (securityData?.features?.length ?? 0) > 0; + + const handlePress = () => { + if (!hasDetails) return; + navigation.navigate( + Routes.SECURITY_TRUST as never, + { + ...token, + securityData, + } as never, + ); + }; + + const content = isLoading ? ( + + + + + + + + + ) : ( + + + + {strings('security_trust.title')} + + {hasDetails && ( + + )} + + + {config.label} + + {hasDetails ? ( + featureTags.length > 0 && ( + + {featureTags.map((tag) => ( + + {tagIcon && tagIconColor && ( + + )} + + {tag.label} + + + ))} + {remainingCount > 0 && ( + + + +{remainingCount} {strings('security_trust.more')} + + + )} + + ) + ) : config.subtitle ? ( + + {config.subtitle} + + ) : null} + + ); + + return ( + tw.style(hasDetails && pressed && 'opacity-70')} + testID="security-trust-entry-card" + > + {content} + + ); +}; + +export default SecurityTrustEntryCard; diff --git a/app/components/UI/SecurityTrust/types.ts b/app/components/UI/SecurityTrust/types.ts new file mode 100644 index 00000000000..4bc6113598b --- /dev/null +++ b/app/components/UI/SecurityTrust/types.ts @@ -0,0 +1,13 @@ +export type { + TokenSecurityData, + TokenSecurityFeature, + TokenSecurityFees, + TokenSecurityFinancialStats, + TokenSecurityHolder, + TokenSecurityMarket, + TokenSecurityMetadata, +} from '@metamask/assets-controllers'; + +export interface FeatureTag { + label: string; +} diff --git a/app/components/UI/SecurityTrust/utils/securityUtils.test.ts b/app/components/UI/SecurityTrust/utils/securityUtils.test.ts new file mode 100644 index 00000000000..3fde5998b99 --- /dev/null +++ b/app/components/UI/SecurityTrust/utils/securityUtils.test.ts @@ -0,0 +1,366 @@ +import type { + TokenSecurityFeature, + TokenSecurityFinancialStats, +} from '../types'; +import { + getFeatureTags, + formatFeePercent, + getTop10HoldingPct, + formatCompactSupply, + getResultTypeConfig, +} from './securityUtils'; +import { + TextColor, + IconName, + IconColor, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../locales/i18n'; + +describe('securityUtils', () => { + describe('getResultTypeConfig', () => { + it('returns config for Verified result type', () => { + const config = getResultTypeConfig('Verified'); + + expect(config.label).toBe(strings('security_trust.verified')); + expect(config.textColor).toBe(TextColor.SuccessDefault); + expect(config.subtitle).toBe(strings('security_trust.subtitle_known')); + expect(config.icon).toBe(IconName.SecurityTick); + expect(config.iconColor).toBe(IconColor.SuccessDefault); + }); + + it('returns config for Benign result type', () => { + const config = getResultTypeConfig('Benign'); + + expect(config.label).toBe(strings('security_trust.no_issues')); + expect(config.textColor).toBe(TextColor.SuccessDefault); + expect(config.subtitle).toBe( + strings('security_trust.subtitle_no_issues'), + ); + expect(config.icon).toBe(IconName.SecurityTick); + expect(config.iconColor).toBe(IconColor.SuccessDefault); + }); + + it('returns config for Warning result type', () => { + const config = getResultTypeConfig('Warning'); + + expect(config.label).toBe(strings('security_trust.suspicious')); + expect(config.textColor).toBe(TextColor.WarningDefault); + expect(config.subtitle).toBe( + strings('security_trust.subtitle_suspicious'), + ); + expect(config.icon).toBe(IconName.Warning); + expect(config.iconColor).toBe(IconColor.WarningDefault); + }); + + it('returns config for Spam result type', () => { + const config = getResultTypeConfig('Spam'); + + expect(config.label).toBe(strings('security_trust.suspicious')); + expect(config.textColor).toBe(TextColor.WarningDefault); + expect(config.subtitle).toBe( + strings('security_trust.subtitle_suspicious'), + ); + expect(config.icon).toBe(IconName.Warning); + expect(config.iconColor).toBe(IconColor.WarningDefault); + }); + + it('returns config for Malicious result type', () => { + const config = getResultTypeConfig('Malicious'); + + expect(config.label).toBe(strings('security_trust.malicious_label')); + expect(config.textColor).toBe(TextColor.ErrorDefault); + expect(config.subtitle).toBe( + strings('security_trust.subtitle_malicious'), + ); + expect(config.icon).toBe(IconName.Danger); + expect(config.iconColor).toBe(IconColor.ErrorDefault); + }); + + it('returns default config for undefined result type', () => { + const config = getResultTypeConfig(undefined); + + expect(config.label).toBe(strings('security_trust.data_unavailable')); + expect(config.textColor).toBe(TextColor.TextAlternative); + expect(config.subtitle).toBe( + strings('security_trust.subtitle_unavailable'), + ); + expect(config.icon).toBeUndefined(); + expect(config.iconColor).toBeUndefined(); + }); + + it('returns default config for unknown result type', () => { + const config = getResultTypeConfig('UnknownType'); + + expect(config.label).toBe(strings('security_trust.data_unavailable')); + expect(config.textColor).toBe(TextColor.TextAlternative); + expect(config.subtitle).toBe( + strings('security_trust.subtitle_unavailable'), + ); + expect(config.icon).toBeUndefined(); + expect(config.iconColor).toBeUndefined(); + }); + }); + describe('getFeatureTags', () => { + const makeFeature = (featureId: string): TokenSecurityFeature => + ({ featureId }) as TokenSecurityFeature; + + describe('Low risk (Verified / Benign)', () => { + it('returns positive tags for known positive feature IDs', () => { + const features = [ + makeFeature('VERIFIED_CONTRACT'), + makeFeature('HIGH_REPUTATION_TOKEN'), + ]; + + const { tags, remainingCount } = getFeatureTags(features, 'Verified'); + + expect(tags).toEqual([ + { label: 'Published contract' }, + { label: 'Established reputation' }, + ]); + expect(remainingCount).toBe(0); + }); + + it('ignores negative feature IDs when resultType is Verified', () => { + const features = [ + makeFeature('RUGPULL'), + makeFeature('VERIFIED_CONTRACT'), + ]; + + const { tags } = getFeatureTags(features, 'Verified'); + + expect(tags).toEqual([{ label: 'Published contract' }]); + }); + + it('caps display at 4 positive tags with no remainingCount', () => { + const features = [ + makeFeature('HIGH_REPUTATION_TOKEN'), + makeFeature('LISTED_ON_CENTRALIZED_EXCHANGE'), + makeFeature('VERIFIED_CONTRACT'), + makeFeature('HIGH_TRADE_VOLUME'), + makeFeature('UNKNOWN_EXTRA'), + ].map((f) => f); + + const { tags, remainingCount } = getFeatureTags(features, 'Verified'); + + expect(tags.length).toBeLessThanOrEqual(4); + expect(remainingCount).toBe(0); + }); + + it('defaults to positive behaviour when resultType is undefined', () => { + const features = [makeFeature('HIGH_REPUTATION_TOKEN')]; + + const { tags } = getFeatureTags(features, undefined); + + expect(tags).toEqual([{ label: 'Established reputation' }]); + }); + }); + + describe('Medium risk (Warning / Spam)', () => { + it('returns Warning-type negative tags for Warning resultType', () => { + const features = [ + makeFeature('HONEYPOT'), + makeFeature('AIRDROP_PATTERN'), + ]; + + const { tags, remainingCount } = getFeatureTags(features, 'Warning'); + + expect(tags).toEqual([ + { label: 'Honeypot risk' }, + { label: 'Suspicious airdrop' }, + ]); + expect(remainingCount).toBe(0); + }); + + it('returns Spam-type negative tags for Spam resultType', () => { + const features = [makeFeature('IMPERSONATOR_HIGH_CONFIDENCE')]; + + const { tags } = getFeatureTags(features, 'Spam'); + + expect(tags).toEqual([{ label: 'Likely impersonator' }]); + }); + + it('ignores Malicious features when resultType is Warning', () => { + const features = [makeFeature('RUGPULL'), makeFeature('HONEYPOT')]; + + const { tags } = getFeatureTags(features, 'Warning'); + + expect(tags).toEqual([{ label: 'Honeypot risk' }]); + }); + + it('caps display at 3 and returns correct remainingCount', () => { + const features = [ + makeFeature('HONEYPOT'), + makeFeature('AIRDROP_PATTERN'), + makeFeature('INORGANIC_VOLUME'), + makeFeature('DYNAMIC_ANALYSIS'), + makeFeature('UNSTABLE_TOKEN_PRICE'), + ]; + + const { tags, remainingCount } = getFeatureTags(features, 'Warning'); + + expect(tags).toHaveLength(3); + expect(remainingCount).toBe(2); + }); + }); + + describe('High risk (Malicious)', () => { + it('returns Malicious-type negative tags', () => { + const features = [ + makeFeature('RUGPULL'), + makeFeature('KNOWN_MALICIOUS'), + ]; + + const { tags, remainingCount } = getFeatureTags(features, 'Malicious'); + + expect(tags).toEqual([ + { label: 'Rugpull risk' }, + { label: 'Known malicious' }, + ]); + expect(remainingCount).toBe(0); + }); + + it('ignores Warning features when resultType is Malicious', () => { + const features = [makeFeature('HONEYPOT'), makeFeature('RUGPULL')]; + + const { tags } = getFeatureTags(features, 'Malicious'); + + expect(tags).toEqual([{ label: 'Rugpull risk' }]); + }); + + it('caps display at 3 and returns correct remainingCount', () => { + const features = [ + makeFeature('RUGPULL'), + makeFeature('KNOWN_MALICIOUS'), + makeFeature('UNSELLABLE_TOKEN'), + makeFeature('SANCTIONED_CREATOR'), + makeFeature('POST_DUMP'), + makeFeature('TOKEN_BACKDOOR'), + ]; + + const { tags, remainingCount } = getFeatureTags(features, 'Malicious'); + + expect(tags).toHaveLength(3); + expect(remainingCount).toBe(3); + }); + }); + + it('ignores unknown feature IDs in all modes', () => { + const features = [makeFeature('UNKNOWN_FEATURE')]; + + expect(getFeatureTags(features, 'Verified').tags).toEqual([]); + expect(getFeatureTags(features, 'Malicious').tags).toEqual([]); + expect(getFeatureTags(features, 'Warning').tags).toEqual([]); + }); + }); + + describe('formatFeePercent', () => { + it('formats a number as a percentage with one decimal', () => { + expect(formatFeePercent(5)).toBe('5.0%'); + }); + + it('formats zero', () => { + expect(formatFeePercent(0)).toBe('0.0%'); + }); + + it('returns N/A for null', () => { + expect(formatFeePercent(null)).toBe('N/A'); + }); + + it('returns N/A for undefined', () => { + expect(formatFeePercent(undefined)).toBe('N/A'); + }); + }); + + describe('getTop10HoldingPct', () => { + it('sums holder percentages', () => { + const stats = { + topHolders: [ + { holdingPercentage: 10 }, + { holdingPercentage: 15 }, + { holdingPercentage: 5 }, + ], + } as TokenSecurityFinancialStats; + + expect(getTop10HoldingPct(stats)).toBe(30); + }); + + it('caps at 100', () => { + const stats = { + topHolders: [{ holdingPercentage: 60 }, { holdingPercentage: 50 }], + } as TokenSecurityFinancialStats; + + expect(getTop10HoldingPct(stats)).toBe(100); + }); + + it('treats missing holdingPercentage as 0', () => { + const stats = { + topHolders: [ + { holdingPercentage: 10 }, + { holdingPercentage: undefined }, + ], + } as unknown as TokenSecurityFinancialStats; + + expect(getTop10HoldingPct(stats)).toBe(10); + }); + + it('returns null when no topHolders', () => { + expect( + getTop10HoldingPct({ + topHolders: [], + } as unknown as TokenSecurityFinancialStats), + ).toBeNull(); + }); + + it('returns null for null stats', () => { + expect(getTop10HoldingPct(null)).toBeNull(); + }); + + it('returns null for undefined stats', () => { + expect(getTop10HoldingPct(undefined)).toBeNull(); + }); + }); + + describe('formatCompactSupply', () => { + it('returns N/A for null', () => { + expect(formatCompactSupply(null)).toBe('N/A'); + }); + + it('returns N/A for undefined', () => { + expect(formatCompactSupply(undefined)).toBe('N/A'); + }); + + it('formats quadrillions', () => { + expect(formatCompactSupply(2e15)).toBe('2.00Q'); + }); + + it('formats trillions', () => { + expect(formatCompactSupply(1.5e12)).toBe('1.50T'); + }); + + it('formats billions', () => { + expect(formatCompactSupply(10e9)).toBe('10.00B'); + }); + + it('formats millions', () => { + expect(formatCompactSupply(5_000_000)).toBe('5.00M'); + }); + + it('formats thousands', () => { + expect(formatCompactSupply(1_500)).toBe('1.50K'); + }); + + it('formats small values as integers', () => { + expect(formatCompactSupply(42)).toBe('42'); + }); + + it('adjusts by decimals when provided', () => { + const rawSupply = 1.6e25; + const result = formatCompactSupply(rawSupply, 18); + expect(result).toBe('16.00M'); + }); + + it('does not adjust when decimals is 0', () => { + expect(formatCompactSupply(5_000_000, 0)).toBe('5.00M'); + }); + }); +}); diff --git a/app/components/UI/SecurityTrust/utils/securityUtils.ts b/app/components/UI/SecurityTrust/utils/securityUtils.ts new file mode 100644 index 00000000000..adb4417f45f --- /dev/null +++ b/app/components/UI/SecurityTrust/utils/securityUtils.ts @@ -0,0 +1,284 @@ +import { + IconColor, + IconName, + TextColor, +} from '@metamask/design-system-react-native'; +import { + type FeatureTag, + type TokenSecurityData, + type TokenSecurityFeature, + type TokenSecurityFinancialStats, +} from '../types'; +import { strings } from '../../../../../locales/i18n'; + +export interface ResultTypeConfig { + label: string; + textColor: TextColor; + subtitle?: string; + icon?: IconName; + iconColor?: IconColor; +} + +export const getResultTypeConfig = ( + resultType: string | undefined, +): ResultTypeConfig => { + switch (resultType) { + case 'Verified': + return { + label: strings('security_trust.verified'), + textColor: TextColor.SuccessDefault, + subtitle: strings('security_trust.subtitle_known'), + icon: IconName.SecurityTick, + iconColor: IconColor.SuccessDefault, + }; + case 'Benign': + return { + label: strings('security_trust.no_issues'), + textColor: TextColor.SuccessDefault, + subtitle: strings('security_trust.subtitle_no_issues'), + icon: IconName.SecurityTick, + iconColor: IconColor.SuccessDefault, + }; + case 'Warning': + case 'Spam': + return { + label: strings('security_trust.suspicious'), + textColor: TextColor.WarningDefault, + subtitle: strings('security_trust.subtitle_suspicious'), + icon: IconName.Warning, + iconColor: IconColor.WarningDefault, + }; + case 'Malicious': + return { + label: strings('security_trust.malicious_label'), + textColor: TextColor.ErrorDefault, + subtitle: strings('security_trust.subtitle_malicious'), + icon: IconName.Danger, + iconColor: IconColor.ErrorDefault, + }; + default: + return { + label: strings('security_trust.data_unavailable'), + textColor: TextColor.TextAlternative, + subtitle: strings('security_trust.subtitle_unavailable'), + }; + } +}; + +/** Blockaid-assigned feature type, as documented in the Blockaid token-scan API. */ +export type BlockaidFeatureType = + | 'Benign' + | 'Info' + | 'Warning' + | 'Spam' + | 'Malicious'; + +interface FeatureDefinition { + label: string; + type: BlockaidFeatureType; +} + +/** Positive-signal features (Benign / Info) */ +const POSITIVE_FEATURE_LABELS: Record = { + HIGH_REPUTATION_TOKEN: { label: 'Established reputation', type: 'Benign' }, + LISTED_ON_CENTRALIZED_EXCHANGE: { + label: 'Listed on exchange', + type: 'Benign', + }, + VERIFIED_CONTRACT: { label: 'Published contract', type: 'Info' }, + HIGH_TRADE_VOLUME: { label: 'High trading volume', type: 'Info' }, +}; + +/** Negative-signal features (Malicious / Spam / Warning / risk-bearing Info) */ +const NEGATIVE_FEATURE_LABELS: Record = { + // Malicious + KNOWN_MALICIOUS: { label: 'Known malicious', type: 'Malicious' }, + METADATA: { label: 'Suspicious metadata', type: 'Malicious' }, + IMPERSONATOR_SENSITIVE_ASSET: { + label: 'Impersonates a sensitive asset', + type: 'Malicious', + }, + STATIC_CODE_SIGNATURE: { label: 'Suspicious code', type: 'Malicious' }, + RUGPULL: { label: 'Rugpull risk', type: 'Malicious' }, + HIGH_TRANSFER_FEE: { label: 'High transfer fee', type: 'Malicious' }, + HIGH_BUY_FEE: { label: 'High buy fee', type: 'Malicious' }, + HIGH_SELL_FEE: { label: 'High sell fee', type: 'Malicious' }, + UNSELLABLE_TOKEN: { label: 'Unsellable token', type: 'Malicious' }, + SANCTIONED_CREATOR: { label: 'Sanctioned creator', type: 'Malicious' }, + SIMILAR_MALICIOUS_CONTRACT: { + label: 'Resembles malicious contract', + type: 'Malicious', + }, + TOKEN_BACKDOOR: { label: 'Token backdoor', type: 'Malicious' }, + POST_DUMP: { label: 'Possible price manipulation', type: 'Malicious' }, + + // Spam + IMPERSONATOR_HIGH_CONFIDENCE: { label: 'Likely impersonator', type: 'Spam' }, + IMPERSONATOR_MEDIUM_CONFIDENCE: { + label: 'Possible impersonator', + type: 'Spam', + }, + + // Warning + AIRDROP_PATTERN: { label: 'Suspicious airdrop', type: 'Warning' }, + IMPERSONATOR: { label: 'Impersonator', type: 'Warning' }, + INORGANIC_VOLUME: { label: 'Artificial volume', type: 'Warning' }, + DYNAMIC_ANALYSIS: { label: 'Suspicious behavior', type: 'Warning' }, + UNSTABLE_TOKEN_PRICE: { label: 'Unstable price', type: 'Warning' }, + INAPPROPRIATE_CONTENT: { label: 'Inappropriate content', type: 'Warning' }, + HONEYPOT: { label: 'Honeypot risk', type: 'Warning' }, + SPAM_TEXT: { label: 'Spam text', type: 'Warning' }, + INSUFFICIENT_LOCKED_LIQUIDITY: { + label: 'Low locked liquidity', + type: 'Warning', + }, + CONCENTRATED_SUPPLY_DISTRIBUTION: { + label: 'Concentrated supply', + type: 'Warning', + }, + WASH_TRADING: { label: 'Wash trading', type: 'Warning' }, + FAKE_VOLUME: { label: 'Fake volume', type: 'Warning' }, + HIDDEN_SUPPLY_BY_KEY_HOLDER: { label: 'Undisclosed supply', type: 'Warning' }, + HEAVILY_SNIPED: { label: 'Heavy bot activity', type: 'Warning' }, + FAKE_TRADE_MAKER_COUNT: { label: 'Inflated trader count', type: 'Warning' }, + LOW_REPUTATION_CREATOR: { + label: 'Creator has low reputation', + type: 'Warning', + }, + SNIPE_AT_MINT: { label: 'Bot activity at launch', type: 'Warning' }, + + // Info – risk-bearing capabilities + IMPERSONATOR_LOW_CONFIDENCE: { + label: 'Unconfirmed impersonator', + type: 'Warning', + }, // used to be Info, but now it's Warning + IS_MINTABLE: { label: 'Mintable', type: 'Info' }, + CAN_BLACKLIST: { label: 'Can blacklist', type: 'Info' }, + CAN_WHITELIST: { label: 'Can whitelist', type: 'Info' }, + HAS_TRADING_COOLDOWN: { label: 'Trading cooldown', type: 'Info' }, + EXTERNAL_FUNCTIONS: { label: 'External calls', type: 'Info' }, + HIDDEN_OWNER: { label: 'Hidden owner', type: 'Info' }, + TRANSFER_PAUSEABLE: { label: 'Transfers pauseable', type: 'Info' }, + PROXY_CONTRACT: { label: 'Proxy contract', type: 'Info' }, + MODIFIABLE_TAXES: { label: 'Modifiable taxes', type: 'Info' }, + OWNER_CAN_CHANGE_BALANCE: { label: 'Owner can change balance', type: 'Info' }, + TRANSFER_FROM_REVERTS: { label: 'Transfer reversals enabled', type: 'Info' }, + TRANSFER_HOOK_ENABLED: { label: 'Transfer hook enabled', type: 'Info' }, + CONFIDENTIAL_TRANSFERS_ENABLED: { + label: 'Confidential transfers', + type: 'Info', + }, + NON_TRANSERABLE: { label: 'Non-transferable', type: 'Info' }, +}; + +export interface FeatureTagsResult { + tags: FeatureTag[]; + remainingCount: number; +} + +const FEATURE_TAG_DISPLAY_MAX = 3; +const POSITIVE_FEATURE_TAG_DISPLAY_MAX = 4; + +/** + * Returns up to 3 feature tags for the entry card, filtered by resultType, + * plus a count of additional matching features beyond the display limit. + * + * - Low (Verified/Benign): positive features only, no remainingCount. + * - Medium (Warning/Spam): Warning + Spam negative features, with overflow count. + * - High (Malicious): Malicious negative features, with overflow count. + */ +export const getFeatureTags = ( + features: TokenSecurityFeature[], + resultType?: TokenSecurityData['resultType'], + showAll = false, +): FeatureTagsResult => { + const tags: FeatureTag[] = []; + let totalMatching = 0; + + if (resultType === 'Malicious') { + for (const feature of features) { + const def = NEGATIVE_FEATURE_LABELS[feature.featureId]; + if (def?.type === 'Malicious') { + totalMatching++; + if (showAll || tags.length < FEATURE_TAG_DISPLAY_MAX) { + tags.push({ label: def.label }); + } + } + } + } else if (resultType === 'Warning' || resultType === 'Spam') { + for (const feature of features) { + const def = NEGATIVE_FEATURE_LABELS[feature.featureId]; + if (def?.type === 'Warning' || def?.type === 'Spam') { + totalMatching++; + if (showAll || tags.length < FEATURE_TAG_DISPLAY_MAX) { + tags.push({ label: def.label }); + } + } + } + } else { + // Low (Verified/Benign) or no resultType: positive features only + for (const feature of features) { + const def = POSITIVE_FEATURE_LABELS[feature.featureId]; + if (def) { + if (showAll || tags.length < POSITIVE_FEATURE_TAG_DISPLAY_MAX) { + tags.push({ label: def.label }); + } + } + } + return { tags, remainingCount: 0 }; + } + + return { + tags, + remainingCount: showAll + ? 0 + : Math.max(0, totalMatching - FEATURE_TAG_DISPLAY_MAX), + }; +}; + +/** + * Format a fee value (0-100 range) as a percentage string, or "N/A" if null. + */ +export const formatFeePercent = (fee: number | null | undefined): string => { + if (fee === null || fee === undefined) return 'N/A'; + return `${fee.toFixed(1)}%`; +}; + +/** + * Sum the holding percentages of top holders. + */ +export const getTop10HoldingPct = ( + financialStats: TokenSecurityFinancialStats | null | undefined, +): number | null => { + if (!financialStats?.topHolders?.length) return null; + const sum = financialStats.topHolders.reduce( + (acc, h) => acc + (h.holdingPercentage ?? 0), + 0, + ); + return Math.min(sum, 100); +}; + +/** + * Format a raw token supply number to a compact string with unit. + */ +export const formatCompactSupply = ( + supply: number | null | undefined, + decimals?: number, +): string => { + if (supply === null || supply === undefined) return 'N/A'; + const adjusted = + decimals != null && decimals > 0 ? supply / 10 ** decimals : supply; + const units: [number, string][] = [ + [1e15, 'Q'], + [1e12, 'T'], + [1e9, 'B'], + [1e6, 'M'], + [1e3, 'K'], + ]; + for (const [threshold, suffix] of units) { + if (adjusted >= threshold) { + return `${(adjusted / threshold).toFixed(2)}${suffix}`; + } + } + return adjusted.toFixed(0); +}; diff --git a/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx b/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx index a1aff88a20f..f27c5151294 100644 --- a/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx +++ b/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx @@ -7,9 +7,9 @@ import { getAppStateForConfirmation, upgradeAccountConfirmation, } from '../../../../util/test/confirm-data-helpers'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as AlertContextFunctions from '../../../Views/confirmations/context/alert-system-context/alert-system-context'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as BatchApprovalUtils from '../../../Views/confirmations/hooks/7702/useBatchApproveBalanceChanges'; import { AlertKeys } from '../../../Views/confirmations/constants/alerts'; import { RowAlertKey } from '../../../Views/confirmations/components/UI/info-row/alert-row/constants'; diff --git a/app/components/UI/SimulationDetails/SimulationDetails.test.tsx b/app/components/UI/SimulationDetails/SimulationDetails.test.tsx index a3f8406176e..de7ab82d0fa 100644 --- a/app/components/UI/SimulationDetails/SimulationDetails.test.tsx +++ b/app/components/UI/SimulationDetails/SimulationDetails.test.tsx @@ -16,7 +16,7 @@ import { getAppStateForConfirmation, } from '../../../util/test/confirm-data-helpers'; import { MMM_ORIGIN } from '../../Views/confirmations/constants/confirmations'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as BatchApprovalUtils from '../../Views/confirmations/hooks/7702/useBatchApproveBalanceChanges'; import AnimatedSpinner from '../AnimatedSpinner'; import SimulationDetails from './SimulationDetails'; diff --git a/app/components/UI/SliderButton/index.js b/app/components/UI/SliderButton/index.js index 6e38be6769d..024ff7b0048 100644 --- a/app/components/UI/SliderButton/index.js +++ b/app/components/UI/SliderButton/index.js @@ -18,10 +18,10 @@ import { fontStyles } from '../../../styles/common'; import Device from '../../../util/device'; import { useTheme } from '../../../util/theme'; -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ const SliderBgImg = require('./assets/slider_button_gradient.png'); const SliderShineImg = require('./assets/slider_button_shine.png'); -/* eslint-enable import/no-commonjs */ +/* eslint-enable import-x/no-commonjs */ const DIAMETER = 60; const MARGIN = DIAMETER * 0.16; diff --git a/app/components/UI/SlippageSlider/index.js b/app/components/UI/SlippageSlider/index.js index 83bfedb3250..873e4eb9b0b 100644 --- a/app/components/UI/SlippageSlider/index.js +++ b/app/components/UI/SlippageSlider/index.js @@ -18,9 +18,9 @@ import { fontStyles } from '../../../styles/common'; import { useTheme } from '../../../util/theme'; import Svg, { Path } from 'react-native-svg'; -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ const SlippageSliderBgImg = require('../../../images/slippage-slider-bg.png'); -/* eslint-enable import/no-commonjs */ +/* eslint-enable import-x/no-commonjs */ const DIAMETER = 30; const TRACK_PADDING = 2; diff --git a/app/components/UI/Stake/components/StakingBalance/StakingBalance.styles.ts b/app/components/UI/Stake/components/StakingBalance/StakingBalance.styles.ts index 2084372cce1..9f57fb6c855 100644 --- a/app/components/UI/Stake/components/StakingBalance/StakingBalance.styles.ts +++ b/app/components/UI/Stake/components/StakingBalance/StakingBalance.styles.ts @@ -1,16 +1,13 @@ -import { StyleSheet } from 'react-native'; +import { StyleSheet, TextStyle } from 'react-native'; import { Theme } from '../../../../../util/theme/models'; const styleSheet = (params: { theme: Theme }) => StyleSheet.create({ container: { marginTop: 16, - padding: 16, borderRadius: 12, - backgroundColor: params.theme.colors.background.section, }, stakingEarnings: { - paddingHorizontal: 16, paddingTop: 16, }, badgeWrapper: { @@ -18,16 +15,21 @@ const styleSheet = (params: { theme: Theme }) => }, balances: { flex: 1, - justifyContent: 'center', - marginLeft: 16, - alignSelf: 'center', + flexDirection: 'column', + alignItems: 'flex-start', + alignContent: 'flex-start', + paddingLeft: 16, }, ethLogo: { - width: 32, - height: 32, - borderRadius: 16, + width: 40, + height: 40, + borderRadius: 20, overflow: 'hidden', }, + tokenAmount: { + ...params.theme.typography.sBodySM, + color: params.theme.colors.text.alternative, + } as TextStyle, bannerStyles: { marginVertical: 8, }, diff --git a/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx b/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx index 6eb4fe4a66e..008d9dabc6e 100644 --- a/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx +++ b/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx @@ -12,12 +12,16 @@ import Badge, { import BadgeWrapper, { BadgePosition, } from '../../../../../component-library/components/Badges/BadgeWrapper'; +import SensitiveText, { + SensitiveTextLength, +} from '../../../../../component-library/components/Texts/SensitiveText'; import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../component-library/hooks'; import { RootState } from '../../../../../reducers'; import { selectNetworkConfigurationByChainId } from '../../../../../selectors/networkController'; +import { selectPrivacyMode } from '../../../../../selectors/preferencesController'; import { getTimeDifferenceFromNow } from '../../../../../util/date'; import { getDecimalChainId } from '../../../../../util/networks'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; @@ -68,6 +72,7 @@ const StakingBalanceContent = ({ asset }: StakingBalanceProps) => { ); const isPooledStakingEnabled = useSelector(selectPooledStakingEnabledFlag); + const privacyMode = useSelector(selectPrivacyMode); const { styles } = useStyles(styleSheet, { theme }); @@ -211,8 +216,12 @@ const StakingBalanceContent = ({ asset }: StakingBalanceProps) => { {hasEthToUnstake && !isLoadingPooledStakesData && ( + } > { {strings('stake.staked_ethereum')} - - - + + {stakedBalanceETH} + )} diff --git a/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx b/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx index 835fff6f656..a3b0e2ca419 100644 --- a/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx +++ b/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx @@ -3,9 +3,6 @@ import React from 'react'; import { View, ViewProps } from 'react-native'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../../../locales/i18n'; -import Button, { - ButtonVariants, -} from '../../../../../../component-library/components/Buttons/Button'; import { useStyles } from '../../../../../../component-library/hooks'; import Routes from '../../../../../../constants/navigation/Routes'; import Engine from '../../../../../../core/Engine'; @@ -21,6 +18,12 @@ import useStakingChain from '../../../hooks/useStakingChain'; import styleSheet from './StakingButtons.styles'; import { trace, TraceName } from '../../../../../../util/trace'; import useStakingEligibility from '../../../hooks/useStakingEligibility'; +import { + Button, + ButtonSize, + ButtonVariant, + Text, +} from '@metamask/design-system-react-native'; interface StakingButtonsProps extends Pick { asset: TokenI; @@ -106,23 +109,27 @@ const StakingButtons = ({ )} {isPooledStakingEnabled && isEligible && ( )} ); diff --git a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap index 661e248b588..8f9ae540fe1 100644 --- a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap +++ b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap @@ -49,10 +49,10 @@ exports[`StakingBalance render matches snapshot 1`] = ` false, false, { - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, }, ] } @@ -75,10 +75,10 @@ exports[`StakingBalance render matches snapshot 1`] = ` style={ [ { - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, }, { "backgroundColor": "#eee", @@ -172,10 +172,11 @@ exports[`StakingBalance render matches snapshot 1`] = ` @@ -198,31 +199,15 @@ exports[`StakingBalance render matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#131416", + "color": "#66676a", "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontSize": 14, + "fontWeight": "400", "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } - > - - - +0.00% - - - + /> + > + + + +0.00% + + + @@ -414,92 +414,185 @@ exports[`StakingBalance render matches snapshot 1`] = ` ] } > - - - Unstake - - - + Unstake + + + + - - Stake more - - + + Stake more + + + Your earnings - + @@ -991,31 +1091,15 @@ exports[`StakingBalance should match the snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#131416", + "color": "#66676a", "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontSize": 14, + "fontWeight": "400", "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } - > - - - +0.00% - - - + /> + > + + + +0.00% + + + @@ -1207,92 +1306,185 @@ exports[`StakingBalance should match the snapshot 1`] = ` ] } > - - - Unstake - - - + Unstake + + + + - - Stake more - - + + Stake more + + + Your earnings - + { keyValueSecondaryText: { alignItems: 'flex-end', }, + stakingEarningsContent: { + paddingBottom: 24, + }, }); }; diff --git a/app/components/UI/Stake/components/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap b/app/components/UI/Stake/components/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap index e6e495cb298..b4f506a35ca 100644 --- a/app/components/UI/Stake/components/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap +++ b/app/components/UI/Stake/components/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap @@ -23,7 +23,13 @@ exports[`Staking Earnings displays pooled-staking maintenance banner when featur > Your earnings - + Your earnings - + { {strings('stake.your_earnings')} - + {isPooledStakingServiceInterruptionBannerEnabled && ( )} diff --git a/app/components/UI/Stake/sdk/stakeSdkProvider.test.tsx b/app/components/UI/Stake/sdk/stakeSdkProvider.test.tsx index ecb47cabd3a..5ca92483384 100644 --- a/app/components/UI/Stake/sdk/stakeSdkProvider.test.tsx +++ b/app/components/UI/Stake/sdk/stakeSdkProvider.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import renderWithProvider from '../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../util/test/initial-root-state'; import { StakeSDKProvider } from '../sdk/stakeSdkProvider'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as useStakeContextHook from '../hooks/useStakeContext'; import { View } from 'react-native'; import Text from '../../../../component-library/components/Texts/Text'; diff --git a/app/components/UI/StyledButton/index.js b/app/components/UI/StyledButton/index.js index abc72eacf5e..43543a0cad1 100644 --- a/app/components/UI/StyledButton/index.js +++ b/app/components/UI/StyledButton/index.js @@ -1,4 +1,4 @@ -import StyledButton from './StyledButton'; // eslint-disable-line import/no-unresolved +import StyledButton from './StyledButton'; // eslint-disable-line import-x/no-unresolved /** * @deprecated The `` component has been deprecated in favor of ` + + + ) : ( + + )} + + + ); +}; + +SecurityBadgeBottomSheet.displayName = 'SecurityBadgeBottomSheet'; + +export default SecurityBadgeBottomSheet; diff --git a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx index 19491de52f4..1a66240d845 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx @@ -7,8 +7,6 @@ describe('TokenDetailsInlineHeader', () => { const mockOnOptionsPress = jest.fn(); const defaultProps = { - title: 'ETH', - networkName: 'Ethereum Mainnet', onBackPress: mockOnBackPress, onOptionsPress: mockOnOptionsPress, }; @@ -18,30 +16,6 @@ describe('TokenDetailsInlineHeader', () => { }); describe('rendering', () => { - it('renders title text', () => { - const { getByText } = render( - , - ); - - expect(getByText('ETH')).toBeOnTheScreen(); - }); - - it('renders network name when provided', () => { - const { getByText } = render( - , - ); - - expect(getByText('Ethereum Mainnet')).toBeOnTheScreen(); - }); - - it('does not render network name when empty string', () => { - const { queryByText } = render( - , - ); - - expect(queryByText('Ethereum Mainnet')).not.toBeOnTheScreen(); - }); - it('renders back button with testID', () => { const { getByTestId } = render( , @@ -60,13 +34,11 @@ describe('TokenDetailsInlineHeader', () => { }); it('renders placeholder when onOptionsPress is falsy', () => { - const props = { - ...defaultProps, - onOptionsPress: undefined, - }; - const { getByTestId, queryByTestId } = render( - , + , ); expect(getByTestId('back-arrow-button')).toBeOnTheScreen(); @@ -95,29 +67,4 @@ describe('TokenDetailsInlineHeader', () => { expect(mockOnOptionsPress).toHaveBeenCalledTimes(1); }); }); - - describe('edge cases', () => { - it('handles long title text with truncation', () => { - const longTitle = 'VeryLongTokenSymbolName'; - const { getByText } = render( - , - ); - - const titleElement = getByText(longTitle); - expect(titleElement.props.numberOfLines).toBe(1); - }); - - it('handles long network name with truncation', () => { - const longNetworkName = 'Very Long Network Name That Should Be Truncated'; - const { getByText } = render( - , - ); - - const networkElement = getByText(longNetworkName); - expect(networkElement.props.numberOfLines).toBe(1); - }); - }); }); diff --git a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx index 8b89ce855c5..9ef4505a863 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx @@ -1,9 +1,5 @@ import React from 'react'; import { View, StyleSheet } from 'react-native'; -import Text, { - TextVariant, - TextColor, -} from '../../../../component-library/components/Texts/Text'; import { Theme } from '@metamask/design-tokens'; import { useStyles } from '../../../hooks/useStyles'; import { @@ -34,10 +30,6 @@ const inlineHeaderStyles = (params: { leftButton: { marginLeft: 16, }, - titleWrapper: { - flex: 1, - alignItems: 'center', - }, rightButton: { marginRight: 16, }, @@ -45,17 +37,16 @@ const inlineHeaderStyles = (params: { marginRight: 16, width: 24, }, + spacer: { + flex: 1, + }, }); }; export const TokenDetailsInlineHeader = ({ - title, - networkName, onBackPress, onOptionsPress, }: { - title: string; - networkName: string; onBackPress: () => void; onOptionsPress: (() => void) | undefined; }) => { @@ -70,20 +61,7 @@ export const TokenDetailsInlineHeader = ({ iconName={IconName.ArrowLeft} testID="back-arrow-button" /> - - - {title} - - {networkName ? ( - - {networkName} - - ) : null} - + {onOptionsPress ? ( void; + goToSwaps: () => void; + hasEligibleSwapTokens: boolean; +} + +const TokenDetailsStickyFooter: React.FC = ({ + token, + securityData, + onBuy, + goToSwaps, + hasEligibleSwapTokens, +}) => { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + + const { isBuyable } = useTokenBuyability(token); + const { isTokenTradingOpen } = useRWAToken(); + + const showSwapButton = hasEligibleSwapTokens; + const showBuyButton = isBuyable || !hasEligibleSwapTokens; + + const handleFooterAction = useCallback( + (action: () => void, source: string) => { + const resultType = securityData?.resultType; + + const configMap: Record< + string, + { + icon: IconName; + iconColor: IconColor; + title: string; + description: string; + } + > = { + Warning: { + icon: IconName.Warning, + iconColor: IconColor.WarningDefault, + title: strings('security_trust.risky_token_title'), + description: strings('security_trust.risky_token_description', { + symbol: token.symbol, + }), + }, + Spam: { + icon: IconName.Warning, + iconColor: IconColor.WarningDefault, + title: strings('security_trust.risky_token_title'), + description: strings('security_trust.risky_token_description', { + symbol: token.symbol, + }), + }, + Malicious: { + icon: IconName.Danger, + iconColor: IconColor.ErrorDefault, + title: strings('security_trust.malicious_token_title'), + description: strings( + 'security_trust.malicious_token_sheet_description', + { + symbol: token.symbol, + }, + ), + }, + }; + + const config = resultType ? configMap[resultType] : undefined; + + if (!config) { + action(); + return; + } + + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.MODAL.SECURITY_BADGE_BOTTOM_SHEET, + params: { + ...config, + onProceed: action, + source, + severity: resultType, + tokenAddress: token.address, + tokenSymbol: token.symbol, + chainId: token.chainId, + }, + }); + }, + [ + navigation, + securityData?.resultType, + token.symbol, + token.address, + token.chainId, + ], + ); + + const footerStyle = React.useMemo( + () => ({ + backgroundColor: colors.background.default, + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: insets.bottom + 6, + }), + [colors.background.default, insets.bottom], + ); + + return ( + <> + {isTokenTradingOpen(token as BridgeToken) && ( + + handleFooterAction( + () => goToSwaps(), + strings('asset_overview.swap'), + ), + }, + ] + : []), + ...(showBuyButton + ? [ + { + variant: ButtonVariants.Primary, + label: strings('asset_overview.buy_button'), + size: ButtonSize.Lg, + onPress: () => + handleFooterAction( + onBuy, + strings('asset_overview.buy_button'), + ), + }, + ] + : []), + ]} + buttonsAlignment={ButtonsAlignment.Horizontal} + /> + )} + + ); +}; + +export default TokenDetailsStickyFooter; diff --git a/app/components/UI/TokenDetails/constants/constants.ts b/app/components/UI/TokenDetails/constants/constants.ts index aae3fbf70ac..0e09238e3f1 100644 --- a/app/components/UI/TokenDetails/constants/constants.ts +++ b/app/components/UI/TokenDetails/constants/constants.ts @@ -1,4 +1,5 @@ import type { TokenI } from '../../Tokens/types'; +import type { TokenSecurityData } from '@metamask/assets-controllers'; /** * Source of navigation to Token Details page @@ -22,4 +23,5 @@ export enum TokenDetailsSource { */ export interface TokenDetailsRouteParams extends TokenI { source?: TokenDetailsSource; + securityData?: TokenSecurityData; } diff --git a/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts b/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts index f005da54278..d974d2b6f52 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts @@ -301,9 +301,10 @@ describe('useTokenActions', () => { const expectedAssetId = 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F'; expect(mockGoToBuy).toHaveBeenCalledTimes(1); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: expectedAssetId, - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { assetId: expectedAssetId }, + { buyFlowOrigin: 'tokenInfo' }, + ); expect(mockCreateEventBuilder).toHaveBeenCalledWith( MetaMetricsEvents.ACTION_BUTTON_CLICKED, @@ -453,9 +454,10 @@ describe('useTokenActions', () => { expect(mockIsCaipAssetType).toHaveBeenCalledWith(solanaToken.address); expect(mockFormatAddressToAssetId).not.toHaveBeenCalled(); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: solanaToken.address, - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { assetId: solanaToken.address }, + { buyFlowOrigin: 'tokenInfo' }, + ); }); it('uses token.address directly for trending non-EVM tokens with CAIP address', () => { @@ -488,9 +490,10 @@ describe('useTokenActions', () => { trendingSolanaToken.address, ); expect(mockFormatAddressToAssetId).not.toHaveBeenCalled(); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: trendingSolanaToken.address, - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { assetId: trendingSolanaToken.address }, + { buyFlowOrigin: 'tokenInfo' }, + ); }); it('uses formatAddressToAssetId for EVM tokens with hex address', () => { @@ -532,9 +535,10 @@ describe('useTokenActions', () => { evmToken.address, evmToken.chainId, ); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: expectedAssetId, - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { assetId: expectedAssetId }, + { buyFlowOrigin: 'tokenInfo' }, + ); }); it('uses formatAddressToAssetId for trending EVM tokens', () => { @@ -574,9 +578,10 @@ describe('useTokenActions', () => { trendingEvmToken.address, trendingEvmToken.chainId, ); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: expectedAssetId, - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { assetId: expectedAssetId }, + { buyFlowOrigin: 'tokenInfo' }, + ); }); it('passes undefined assetId when formatAddressToAssetId throws an error', () => { @@ -594,9 +599,10 @@ describe('useTokenActions', () => { result.current.handleBuyPress(); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: undefined, - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { assetId: undefined }, + { buyFlowOrigin: 'tokenInfo' }, + ); }); it('passes undefined assetId when formatAddressToAssetId returns null', () => { @@ -612,9 +618,10 @@ describe('useTokenActions', () => { result.current.handleBuyPress(); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: undefined, - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { assetId: undefined }, + { buyFlowOrigin: 'tokenInfo' }, + ); }); }); diff --git a/app/components/UI/TokenDetails/hooks/useTokenActions.ts b/app/components/UI/TokenDetails/hooks/useTokenActions.ts index 45fc936fa1d..78feb0d4f30 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenActions.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenActions.ts @@ -325,7 +325,7 @@ export const useTokenActions = ({ .build(), ); - goToBuy({ assetId }); + goToBuy({ assetId }, { buyFlowOrigin: 'tokenInfo' }); }, [ trackEvent, createEventBuilder, @@ -434,7 +434,7 @@ export const useTokenActions = ({ assetId = undefined; } - goToBuy({ assetId }); + goToBuy({ assetId }, { buyFlowOrigin: 'tokenInfo' }); return; } diff --git a/app/components/UI/TokenDetails/hooks/useTokenSecurityData.test.ts b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.test.ts new file mode 100644 index 00000000000..34dab03dff2 --- /dev/null +++ b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.test.ts @@ -0,0 +1,240 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; +import { useTokenSecurityData } from './useTokenSecurityData'; +import { + fetchTokenAssets, + TokenSecurityData, +} from '@metamask/assets-controllers'; +import type { CaipAssetType } from '@metamask/utils'; + +jest.mock('@metamask/assets-controllers', () => ({ + fetchTokenAssets: jest.fn(), +})); + +const mockFetchTokenAssets = jest.mocked(fetchTokenAssets); + +const mockSecurityData: TokenSecurityData = { + resultType: 'Verified', + maliciousScore: '0', + features: [ + { + featureId: 'liquidity_pools', + type: 'info', + description: 'Has liquidity pools', + }, + ], + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0, + sell: null, + }, + financialStats: { + supply: 1000000, + topHolders: [], + holdersCount: 100, + tradeVolume24h: null, + lockedLiquidityPct: null, + markets: [], + }, + metadata: { + externalLinks: { + homepage: null, + twitterPage: null, + telegramChannelId: null, + }, + }, + created: '2023-01-01T00:00:00Z', +}; + +describe('useTokenSecurityData', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns prefetched data immediately without fetching', () => { + const assetId = 'eip155:1/erc20:0x1234' as CaipAssetType; + + const { result } = renderHook(() => + useTokenSecurityData({ + assetId, + prefetchedData: mockSecurityData, + }), + ); + + expect(result.current.securityData).toBe(mockSecurityData); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(mockFetchTokenAssets).not.toHaveBeenCalled(); + }); + + it('fetches security data when assetId is provided', async () => { + const assetId = 'eip155:1/erc20:0x1234' as CaipAssetType; + mockFetchTokenAssets.mockResolvedValue([ + { + assetId, + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + securityData: mockSecurityData, + }, + ]); + + const { result } = renderHook(() => useTokenSecurityData({ assetId })); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockFetchTokenAssets).toHaveBeenCalledWith([assetId], { + includeTokenSecurityData: true, + }); + expect(result.current.securityData).toBe(mockSecurityData); + expect(result.current.error).toBeNull(); + }); + + it('sets error when fetch fails', async () => { + const assetId = 'eip155:1/erc20:0x1234' as CaipAssetType; + const mockError = new Error('Fetch failed'); + mockFetchTokenAssets.mockRejectedValue(mockError); + + const { result } = renderHook(() => useTokenSecurityData({ assetId })); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe(mockError); + expect(result.current.securityData).toBeNull(); + }); + + it('does not fetch when assetId is null', () => { + const { result } = renderHook(() => + useTokenSecurityData({ assetId: null }), + ); + + expect(result.current.isLoading).toBe(false); + expect(result.current.securityData).toBeNull(); + expect(result.current.error).toBeNull(); + expect(mockFetchTokenAssets).not.toHaveBeenCalled(); + }); + + it('handles empty security data from API', async () => { + const assetId = 'eip155:1/erc20:0x1234' as CaipAssetType; + mockFetchTokenAssets.mockResolvedValue([ + { + assetId, + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + }, + ]); + + const { result } = renderHook(() => useTokenSecurityData({ assetId })); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.securityData).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('refetches when assetId changes', async () => { + const assetId1 = 'eip155:1/erc20:0x1111' as CaipAssetType; + const assetId2 = 'eip155:1/erc20:0x2222' as CaipAssetType; + + mockFetchTokenAssets.mockResolvedValue([ + { + assetId: assetId1, + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + securityData: mockSecurityData, + }, + ]); + + const { result, rerender } = renderHook( + ({ assetId }) => useTokenSecurityData({ assetId }), + { initialProps: { assetId: assetId1 } }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockFetchTokenAssets).toHaveBeenCalledTimes(1); + expect(mockFetchTokenAssets).toHaveBeenCalledWith([assetId1], { + includeTokenSecurityData: true, + }); + + rerender({ assetId: assetId2 }); + + await waitFor(() => { + expect(mockFetchTokenAssets).toHaveBeenCalledTimes(2); + }); + + expect(mockFetchTokenAssets).toHaveBeenCalledWith([assetId2], { + includeTokenSecurityData: true, + }); + }); + + it('cleans up on unmount', async () => { + const assetId = 'eip155:1/erc20:0x1234' as CaipAssetType; + mockFetchTokenAssets.mockImplementation( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve([ + { + assetId, + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + securityData: mockSecurityData, + }, + ]), + 100, + ); + }), + ); + + const { unmount } = renderHook(() => useTokenSecurityData({ assetId })); + + unmount(); + + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + it('stops loading when assetId changes to null', async () => { + const testAssetId = 'eip155:1/erc20:0x1234' as CaipAssetType; + mockFetchTokenAssets.mockResolvedValue([ + { + assetId: testAssetId, + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + securityData: mockSecurityData, + }, + ]); + + const { result, rerender } = renderHook( + ({ assetId }: { assetId: CaipAssetType | null }) => + useTokenSecurityData({ assetId }), + { initialProps: { assetId: testAssetId as CaipAssetType | null } }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.securityData).toBe(mockSecurityData); + + rerender({ assetId: null as CaipAssetType | null }); + + expect(result.current.isLoading).toBe(false); + expect(mockFetchTokenAssets).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/TokenDetails/hooks/useTokenSecurityData.ts b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.ts new file mode 100644 index 00000000000..177878838f6 --- /dev/null +++ b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.ts @@ -0,0 +1,75 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import type { CaipAssetType } from '@metamask/utils'; +import { + fetchTokenAssets, + TokenSecurityData, +} from '@metamask/assets-controllers'; + +interface UseTokenSecurityDataOpts { + /** CAIP-19 asset ID. When null, no fetch is attempted. */ + assetId: CaipAssetType | null; + /** Pre-fetched security data from trending/search — returned immediately if provided. */ + prefetchedData?: TokenSecurityData; +} + +interface UseTokenSecurityDataResult { + securityData: TokenSecurityData | null; + isLoading: boolean; + error: Error | null; +} + +export const useTokenSecurityData = ({ + assetId, + prefetchedData, +}: UseTokenSecurityDataOpts): UseTokenSecurityDataResult => { + const [securityData, setSecurityData] = useState( + prefetchedData ?? null, + ); + const [isLoading, setIsLoading] = useState(!prefetchedData && !!assetId); + const [error, setError] = useState(null); + const isMountedRef = useRef(true); + + const fetchData = useCallback(async () => { + if (!assetId) return; + try { + const assets = await fetchTokenAssets([assetId], { + includeTokenSecurityData: true, + }); + if (!isMountedRef.current) return; + const asset = assets?.[0]; + setSecurityData(asset?.securityData ?? null); + setError(null); + } catch (err) { + if (!isMountedRef.current) return; + setError(err as Error); + } finally { + if (isMountedRef.current) { + setIsLoading(false); + } + } + }, [assetId]); + + useEffect(() => { + isMountedRef.current = true; + + if (prefetchedData) { + setSecurityData(prefetchedData); + setIsLoading(false); + return; + } + + if (!assetId) { + setIsLoading(false); + return; + } + + setIsLoading(true); + fetchData(); + + return () => { + isMountedRef.current = false; + }; + }, [assetId, prefetchedData, fetchData]); + + return { securityData, isLoading, error }; +}; diff --git a/app/components/UI/TokenDetails/hooks/useTokenTransactions.ts b/app/components/UI/TokenDetails/hooks/useTokenTransactions.ts index 1b71a3de4c2..df04c39c438 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenTransactions.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenTransactions.ts @@ -81,9 +81,9 @@ export interface UseTokenTransactionsResult { } // Cache for non-EVM transactions -// eslint-disable-next-line import/no-mutable-exports +// eslint-disable-next-line import-x/no-mutable-exports let cachedFilteredTransactions: Transaction[] | null = null; -// eslint-disable-next-line import/no-mutable-exports +// eslint-disable-next-line import-x/no-mutable-exports let cacheKey: string | null = null; /** diff --git a/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.test.tsx b/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.test.tsx index 3eaa613597f..e73fe1e01df 100644 --- a/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.test.tsx +++ b/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.test.tsx @@ -50,22 +50,27 @@ jest.mock('../../../../../component-library/components/Texts/Text', () => { }; }); -jest.mock('../../../../../component-library/components/Buttons/Button', () => { +jest.mock('@metamask/design-system-react-native', () => { /* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ const ReactMock = require('react'); + const actual = jest.requireActual('@metamask/design-system-react-native'); return { - __esModule: true, - default: ({ label, onPress, testID }: Record) => + ...actual, + Button: ({ children, onPress }: Record) => ReactMock.createElement( 'View', { - testID: testID ?? 'edit-network-button', + testID: 'edit-network-button', onPress, + accessibilityRole: 'button', + accessible: true, }, - ReactMock.createElement('Text', {}, label), + ReactMock.createElement( + 'Text', + { accessibilityRole: 'text' }, + children, + ), ), - ButtonVariants: { Secondary: 'Secondary' }, - ButtonSize: { Lg: 'Lg' }, }; }); diff --git a/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.tsx b/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.tsx index 664f4a5cf2d..17acbe5f275 100644 --- a/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.tsx +++ b/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.tsx @@ -6,10 +6,11 @@ import { StyleSheet, View } from 'react-native'; import SheetHeader from '../../../../../component-library/components/Sheet/SheetHeader'; import { strings } from '../../../../../../locales/i18n'; import Text from '../../../../../component-library/components/Texts/Text'; -import Button, { - ButtonVariants, +import { + Button, + ButtonVariant, ButtonSize, -} from '../../../../../component-library/components/Buttons/Button'; +} from '@metamask/design-system-react-native'; import { selectEvmNetworkConfigurationsByChainId } from '../../../../../selectors/networkController'; import { useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; @@ -36,9 +37,6 @@ const createStyles = (colors: Colors) => paddingTop: 0, borderWidth: 0, }, - editNetworkButton: { - width: '100%', - }, notch: { width: 40, height: 4, @@ -114,12 +112,13 @@ export const ScamWarningModal = ({ diff --git a/app/components/UI/Tokens/index.test.tsx b/app/components/UI/Tokens/index.test.tsx index 507ee64df26..d9cbe094e71 100644 --- a/app/components/UI/Tokens/index.test.tsx +++ b/app/components/UI/Tokens/index.test.tsx @@ -19,17 +19,17 @@ import { TokenList } from './TokenList/TokenList'; import { ScrollView } from 'react-native-gesture-handler'; import { TokenI } from './types'; import { MUSD_TOKEN_ADDRESS } from '../Earn/constants/musd'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as MusdConversionAssetListCtaModule from '../Earn/components/Musd/MusdConversionAssetListCta'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as TokenListControlBarModule from './TokenListControlBar/TokenListControlBar'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as AssetsListSelectorsModule from '../../../selectors/assets/assets-list'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as RefreshTokensModule from './util/refreshTokens'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as RemoveEvmTokenModule from './util/removeEvmToken'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as RemoveNonEvmTokenModule from './util/removeNonEvmToken'; const mockUseMusdConversionEligibility = jest.fn(() => ({ isEligible: true, diff --git a/app/components/UI/TransactionElement/index.js b/app/components/UI/TransactionElement/index.js index c6469b44983..3e37a74dc43 100644 --- a/app/components/UI/TransactionElement/index.js +++ b/app/components/UI/TransactionElement/index.js @@ -135,7 +135,7 @@ const createStyles = (colors, typography) => }, }); -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ const transactionIconApprove = require('../../../images/transaction-icons/approve.png'); const transactionIconInteraction = require('../../../images/transaction-icons/interaction.png'); const transactionIconSent = require('../../../images/transaction-icons/send.png'); @@ -147,7 +147,7 @@ const transactionIconInteractionFailed = require('../../../images/transaction-ic const transactionIconSentFailed = require('../../../images/transaction-icons/send-failed.png'); const transactionIconReceivedFailed = require('../../../images/transaction-icons/receive-failed.png'); const transactionIconSwapFailed = require('../../../images/transaction-icons/swap-failed.png'); -/* eslint-enable import/no-commonjs */ +/* eslint-enable import-x/no-commonjs */ const NEW_TRANSACTION_DETAILS_TYPES = [ TransactionType.musdClaim, diff --git a/app/components/UI/Transactions/index.js b/app/components/UI/Transactions/index.js index 0f77cbb705c..36b0de58aa1 100644 --- a/app/components/UI/Transactions/index.js +++ b/app/components/UI/Transactions/index.js @@ -168,6 +168,10 @@ class Transactions extends PureComponent { * Optional header component */ header: PropTypes.object, + /** + * When true, suppresses the empty state footer when there are no transactions + */ + hideEmptyState: PropTypes.bool, /** * Optional header height */ @@ -359,6 +363,10 @@ class Transactions extends PureComponent { }; renderEmpty = () => { + if (this.props.hideEmptyState) { + return null; + } + const { colors } = this.context || mockTheme; const styles = createStyles(colors); @@ -470,7 +478,6 @@ class Transactions extends PureComponent { }; getParamsToSend = (transactionObject) => { - // Legacy tx with gasPrice 0x0 would produce 0 from the modal; fall back to market estimate so the replacement gets mined. if ( transactionObject && transactionObject.gasPrice !== undefined && @@ -580,7 +587,7 @@ class Transactions extends PureComponent { }; cancelUnsignedQRTransaction = async (tx) => { - await Engine.context.ApprovalController.reject( + await Engine.context.ApprovalController.rejectRequest( tx.id, providerErrors.userRejectedRequest(), ); diff --git a/app/components/UI/Transactions/index.test.tsx b/app/components/UI/Transactions/index.test.tsx index e83af62696a..d30212e9ff1 100644 --- a/app/components/UI/Transactions/index.test.tsx +++ b/app/components/UI/Transactions/index.test.tsx @@ -59,8 +59,8 @@ jest.mock('../../../util/transaction-controller', () => ({ jest.mock('../../../core/Engine', () => ({ context: { ApprovalController: { - accept: jest.fn(), - reject: jest.fn(), + acceptRequest: jest.fn(), + rejectRequest: jest.fn(), }, TransactionController: { stopTransaction: jest.fn(), @@ -79,14 +79,6 @@ jest.mock('../TransactionElement', () => ({ })); // Mock other connected components -jest.mock( - '../../Views/confirmations/legacy/components/UpdateEIP1559Tx', - () => ({ - __esModule: true, - default: () => null, - }), -); - jest.mock('../TransactionActionModal', () => ({ __esModule: true, default: () => null, @@ -634,7 +626,7 @@ describe('Transactions', () => { }); it('should test Engine context methods', () => { - expect(Engine.context.ApprovalController.accept).toBeDefined(); + expect(Engine.context.ApprovalController.acceptRequest).toBeDefined(); expect( Engine.context.TransactionController.stopTransaction, ).toBeDefined(); @@ -2105,7 +2097,6 @@ describe('UnconnectedTransactions Component Direct Method Testing', () => { instance.props = { ...defaultTestProps, loading: false }; instance.renderLoader = jest.fn(); instance.renderList = jest.fn(); - instance.renderUpdateTxEIP1559Gas = jest.fn(); instance.toggleRetry = jest.fn(); instance.retry = jest.fn(); diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx index 8a6c17a3d7c..8dd4341253e 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx @@ -163,6 +163,7 @@ const getAssetNavigationParams = (token: TrendingAsset) => { isFromTrending: true, source: TokenDetailsSource.Trending, rwaData: token.rwaData, + securityData: token.securityData, }; }; diff --git a/app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.ts b/app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.ts index baaeb61ad69..022bb99d0c7 100644 --- a/app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.ts +++ b/app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.ts @@ -130,6 +130,7 @@ export const useRwaTokens = (opts?: { rwaData: asset.rwaData as unknown as | TrendingAsset['rwaData'] | undefined, + securityData: asset.securityData, })); if (searchQuery?.trim()) { diff --git a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts index 396d0caa19e..543e90e72c7 100644 --- a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts +++ b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts @@ -2,7 +2,7 @@ import { useSearchRequest } from './useSearchRequest'; import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; import { act, waitFor } from '@testing-library/react-native'; import { CaipChainId } from '@metamask/utils'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as assetsControllers from '@metamask/assets-controllers'; const createMockSearchResult = (overrides = {}) => ({ diff --git a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts index 385c7b6578d..8e0d63772ea 100644 --- a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts +++ b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts @@ -1,6 +1,10 @@ import { useCallback, useEffect, useState, useRef, useMemo } from 'react'; import { CaipChainId } from '@metamask/utils'; -import { searchTokens, TrendingAsset } from '@metamask/assets-controllers'; +import { + searchTokens, + TrendingAsset, + TokenSecurityData, +} from '@metamask/assets-controllers'; import { useStableArray } from '../../../Perps/hooks/useStableArray'; import { TRENDING_NETWORKS_LIST } from '../../utils/trendingNetworksList'; @@ -14,6 +18,7 @@ interface SearchResult { price: string; pricePercentChange1d: string; rwaData?: TrendingAsset['rwaData']; + securityData?: TokenSecurityData; } const DEBOUNCE_MS = 300; @@ -85,6 +90,7 @@ export const useSearchRequest = (options: { const searchResults = await searchTokens(stableChainIds, debouncedQuery, { limit, includeMarketData, + includeTokenSecurityData: true, }); // Only update state if this is still the current request if (currentRequestId === requestIdRef.current) { diff --git a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts index 5ee00790319..7ff71499cac 100644 --- a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts +++ b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts @@ -6,7 +6,7 @@ import { } from './useTrendingRequest'; import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; import { act, waitFor } from '@testing-library/react-native'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as assetsControllers from '@metamask/assets-controllers'; import { CaipChainId } from '@metamask/utils'; import { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; diff --git a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts index 5c4b6822a3e..e20179843c7 100644 --- a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts +++ b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts @@ -222,6 +222,7 @@ export const useTrendingRequest = (options: { minMarketCap, maxMarketCap, excludeLabels: ['stable_coin', 'blue_chip'], + includeTokenSecurityData: true, }); // Only update state if this is still the current request if (currentRequestId === requestIdRef.current) { diff --git a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts index b8baa6eb536..9e9cec68616 100644 --- a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts +++ b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts @@ -122,6 +122,7 @@ export const useTrendingSearch = (opts?: { rwaData: asset.rwaData as unknown as | TrendingAsset['rwaData'] | undefined, + securityData: asset.securityData, }); } }); diff --git a/app/components/UI/TurnOffRememberMeModal/index.ts b/app/components/UI/TurnOffRememberMeModal/index.ts index 0ce7d6e4a77..ce34416dc02 100644 --- a/app/components/UI/TurnOffRememberMeModal/index.ts +++ b/app/components/UI/TurnOffRememberMeModal/index.ts @@ -1,2 +1,2 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ export { default as TurnOffRememberMeModal } from './TurnOffRememberMeModal'; diff --git a/app/components/UI/TurnOffRememberMeModal/styles.ts b/app/components/UI/TurnOffRememberMeModal/styles.ts index 064ddbfeb42..f5686801cf8 100644 --- a/app/components/UI/TurnOffRememberMeModal/styles.ts +++ b/app/components/UI/TurnOffRememberMeModal/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { fontStyles } from '../../../styles/common'; import { StyleSheet } from 'react-native'; diff --git a/app/components/UI/UpdateNeeded/UpdateNeeded.tsx b/app/components/UI/UpdateNeeded/UpdateNeeded.tsx index 278fd9c3bcd..685104719b2 100644 --- a/app/components/UI/UpdateNeeded/UpdateNeeded.tsx +++ b/app/components/UI/UpdateNeeded/UpdateNeeded.tsx @@ -27,7 +27,7 @@ import { ScrollView } from 'react-native-gesture-handler'; import generateDeviceAnalyticsMetaData from '../../../util/metrics'; import { useMetrics } from '../../../components/hooks/useMetrics'; -/* eslint-disable import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ +/* eslint-disable import-x/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ const foxLogo = require('../../../images/branding/fox.png'); const metamaskName = require('../../../images/branding/metamask-name.png'); diff --git a/app/components/UI/UpdateNeeded/index.ts b/app/components/UI/UpdateNeeded/index.ts index 8a8061daad7..36f095816a1 100644 --- a/app/components/UI/UpdateNeeded/index.ts +++ b/app/components/UI/UpdateNeeded/index.ts @@ -1,2 +1,2 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ export { default as UpdateNeeded } from './UpdateNeeded'; diff --git a/app/components/UI/UpdateNeeded/styles.ts b/app/components/UI/UpdateNeeded/styles.ts index 27f4532e66f..56f23091939 100644 --- a/app/components/UI/UpdateNeeded/styles.ts +++ b/app/components/UI/UpdateNeeded/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; // TODO: Replace "any" with type diff --git a/app/components/UI/WhatsNewModal/WhatsNewModal.constants.ts b/app/components/UI/WhatsNewModal/WhatsNewModal.constants.ts index 8d9296ef726..cba233d9068 100644 --- a/app/components/UI/WhatsNewModal/WhatsNewModal.constants.ts +++ b/app/components/UI/WhatsNewModal/WhatsNewModal.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/no-commonjs, import/prefer-default-export, @typescript-eslint/no-require-imports */ +/* eslint-disable import-x/no-commonjs, import-x/prefer-default-export, @typescript-eslint/no-require-imports */ import { WhatsNew, SlideContentType } from './types'; import { strings } from '../../../../locales/i18n'; diff --git a/app/components/Views/AccountActions/AccountActions.test.tsx b/app/components/Views/AccountActions/AccountActions.test.tsx index 908afbc4e17..e29553fa2da 100644 --- a/app/components/Views/AccountActions/AccountActions.test.tsx +++ b/app/components/Views/AccountActions/AccountActions.test.tsx @@ -22,9 +22,9 @@ import { KeyringTypes } from '@metamask/keyring-controller'; import ExtendedKeyringTypes from '../../../constants/keyringTypes'; import { strings } from '../../../../locales/i18n'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as Networks7702 from '../confirmations/hooks/7702/useEIP7702Networks'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as AddressUtils from '../../../util/address'; import { act } from '@testing-library/react-hooks'; import { RPC } from '../../../constants/network'; diff --git a/app/components/Views/AccountBackupStep1/__snapshots__/index.test.tsx.snap b/app/components/Views/AccountBackupStep1/__snapshots__/index.test.tsx.snap index c9ef7235e42..aee7cdab906 100644 --- a/app/components/Views/AccountBackupStep1/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/AccountBackupStep1/__snapshots__/index.test.tsx.snap @@ -13,7 +13,9 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot 1`] = ` style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -27,7 +29,9 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot 1`] = ` style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -36,49 +40,69 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot 1`] = ` Step 2 of 3 Secure your wallet @@ -88,32 +112,39 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot 1`] = ` style={ { "height": 250, - "marginHorizontal": "auto", + "marginLeft": "auto", + "marginRight": "auto", "width": 250, } } /> Don’t risk losing your funds. Protect your wallet by saving your @@ -122,13 +153,17 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + undefined, + ] } testID="seedphrase-link" > @@ -141,13 +176,17 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot 1`] = ` It’s the only way to recover your wallet if you get locked out of the app or get a new device. @@ -156,93 +195,187 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot 1`] = ` - - - + - Get started - - + "textAlign": "center", + }, + undefined, + ] + } + > + Get started + - - - + - Remind me later - - + "textAlign": "center", + }, + undefined, + ] + } + > + Remind me later + @@ -264,7 +397,9 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot with statu style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -278,7 +413,9 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot with statu style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -287,49 +424,69 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot with statu Step 2 of 3 Secure your wallet @@ -339,32 +496,39 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot with statu style={ { "height": 250, - "marginHorizontal": "auto", + "marginLeft": "auto", + "marginRight": "auto", "width": 250, } } /> Don’t risk losing your funds. Protect your wallet by saving your @@ -373,13 +537,17 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot with statu accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + undefined, + ] } testID="seedphrase-link" > @@ -392,13 +560,17 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot with statu It’s the only way to recover your wallet if you get locked out of the app or get a new device. @@ -407,93 +579,187 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot with statu - - - + - Get started - - + "textAlign": "center", + }, + undefined, + ] + } + > + Get started + - - - + - Remind me later - - + "textAlign": "center", + }, + undefined, + ] + } + > + Remind me later + @@ -515,7 +781,9 @@ exports[`AccountBackupStep1 Snapshots iOS render matches snapshot 1`] = ` style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 8, } } @@ -529,7 +797,9 @@ exports[`AccountBackupStep1 Snapshots iOS render matches snapshot 1`] = ` style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 8, } } @@ -538,49 +808,69 @@ exports[`AccountBackupStep1 Snapshots iOS render matches snapshot 1`] = ` Step 2 of 3 Secure your wallet @@ -590,32 +880,39 @@ exports[`AccountBackupStep1 Snapshots iOS render matches snapshot 1`] = ` style={ { "height": 250, - "marginHorizontal": "auto", + "marginLeft": "auto", + "marginRight": "auto", "width": 250, } } /> Don’t risk losing your funds. Protect your wallet by saving your @@ -624,13 +921,17 @@ exports[`AccountBackupStep1 Snapshots iOS render matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + undefined, + ] } testID="seedphrase-link" > @@ -643,13 +944,17 @@ exports[`AccountBackupStep1 Snapshots iOS render matches snapshot 1`] = ` It’s the only way to recover your wallet if you get locked out of the app or get a new device. @@ -658,93 +963,187 @@ exports[`AccountBackupStep1 Snapshots iOS render matches snapshot 1`] = ` - - - + - Get started - - + "textAlign": "center", + }, + undefined, + ] + } + > + Get started + - - - + - Remind me later - - + "textAlign": "center", + }, + undefined, + ] + } + > + Remind me later + @@ -766,7 +1165,9 @@ exports[`AccountBackupStep1 Theme appearance renders dark SRP design image by de style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -780,7 +1181,9 @@ exports[`AccountBackupStep1 Theme appearance renders dark SRP design image by de style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -789,49 +1192,69 @@ exports[`AccountBackupStep1 Theme appearance renders dark SRP design image by de Step 2 of 3 Secure your wallet @@ -841,32 +1264,39 @@ exports[`AccountBackupStep1 Theme appearance renders dark SRP design image by de style={ { "height": 250, - "marginHorizontal": "auto", + "marginLeft": "auto", + "marginRight": "auto", "width": 250, } } /> Don’t risk losing your funds. Protect your wallet by saving your @@ -875,13 +1305,17 @@ exports[`AccountBackupStep1 Theme appearance renders dark SRP design image by de accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + undefined, + ] } testID="seedphrase-link" > @@ -894,13 +1328,17 @@ exports[`AccountBackupStep1 Theme appearance renders dark SRP design image by de It’s the only way to recover your wallet if you get locked out of the app or get a new device. @@ -909,93 +1347,187 @@ exports[`AccountBackupStep1 Theme appearance renders dark SRP design image by de - - - + - Get started - - + "textAlign": "center", + }, + undefined, + ] + } + > + Get started + - - - + - Remind me later - - + "textAlign": "center", + }, + undefined, + ] + } + > + Remind me later + @@ -1017,7 +1549,9 @@ exports[`AccountBackupStep1 Theme appearance renders light SRP design image for style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -1031,7 +1565,9 @@ exports[`AccountBackupStep1 Theme appearance renders light SRP design image for style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -1040,49 +1576,69 @@ exports[`AccountBackupStep1 Theme appearance renders light SRP design image for Step 2 of 3 Secure your wallet @@ -1092,32 +1648,39 @@ exports[`AccountBackupStep1 Theme appearance renders light SRP design image for style={ { "height": 250, - "marginHorizontal": "auto", + "marginLeft": "auto", + "marginRight": "auto", "width": 250, } } /> Don’t risk losing your funds. Protect your wallet by saving your @@ -1126,13 +1689,17 @@ exports[`AccountBackupStep1 Theme appearance renders light SRP design image for accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + undefined, + ] } testID="seedphrase-link" > @@ -1145,13 +1712,17 @@ exports[`AccountBackupStep1 Theme appearance renders light SRP design image for It’s the only way to recover your wallet if you get locked out of the app or get a new device. @@ -1160,93 +1731,187 @@ exports[`AccountBackupStep1 Theme appearance renders light SRP design image for - - - + - Get started - - + "textAlign": "center", + }, + undefined, + ] + } + > + Get started + - - - + - Remind me later - - + "textAlign": "center", + }, + undefined, + ] + } + > + Remind me later + diff --git a/app/components/Views/AccountBackupStep1/index.js b/app/components/Views/AccountBackupStep1/index.js index 98a34d49d6b..4aec4fa6333 100644 --- a/app/components/Views/AccountBackupStep1/index.js +++ b/app/components/Views/AccountBackupStep1/index.js @@ -1,8 +1,6 @@ import React, { useState, useEffect } from 'react'; import { ScrollView, - View, - StyleSheet, BackHandler, Image, Platform, @@ -10,32 +8,32 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import PropTypes from 'prop-types'; -import { fontStyles } from '../../../styles/common'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + BoxJustifyContent, + Button, + ButtonVariant, + ButtonSize, + Text, + TextVariant, + TextColor, +} from '@metamask/design-system-react-native'; import { strings } from '../../../../locales/i18n'; import AndroidBackHandler from '../AndroidBackHandler'; import Device from '../../../util/device'; -import scaling from '../../../util/scaling'; import Engine from '../../../core/Engine'; import { connect } from 'react-redux'; import { saveOnboardingEvent as saveEvent } from '../../../actions/onboarding'; import { MetaMetricsEvents } from '../../../core/Analytics'; -import StorageWrapper from '../../../store/storage-wrapper'; import { useTheme } from '../../../util/theme'; import { ManualBackUpStepsSelectorsIDs } from '../ManualBackupStep1/ManualBackUpSteps.testIds'; import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding'; -import Routes from '../../../../app/constants/navigation/Routes'; +import Routes from '../../../constants/navigation/Routes'; import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; import SRPDesignLight from '../../../images/secure_wallet_light.png'; import SRPDesignDark from '../../../images/secure_wallet_dark.png'; -import Button, { - ButtonVariants, - ButtonWidthTypes, - ButtonSize, -} from '../../../component-library/components/Buttons/Button'; -import Text, { - TextVariant, - TextColor, -} from '../../../component-library/components/Texts/Text'; import { CommonActions, useNavigation } from '@react-navigation/native'; import { useMetrics } from '../../hooks/useMetrics'; import { @@ -45,75 +43,11 @@ import { import { TraceName, endTrace } from '../../../util/trace'; import { AppThemeKey } from '../../../util/theme/models'; -const createStyles = (colors) => - StyleSheet.create({ - mainWrapper: { - backgroundColor: colors.background.default, - flex: 1, - paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight || 24 : 8, - }, - scrollviewWrapper: { - flexGrow: 1, - }, - wrapper: { - flex: 1, - paddingHorizontal: 16, - }, - content: { - alignItems: 'center', - justifyContent: 'flex-start', - flex: 1, - marginBottom: 10, - }, - title: { - textAlign: 'left', - alignSelf: 'flex-start', - marginBottom: 16, - }, - text: { - marginTop: 32, - justifyContent: 'center', - alignSelf: 'flex-start', - flexDirection: 'column', - rowGap: 16, - }, - label: { - lineHeight: scaling.scale(20), - fontSize: scaling.scale(14), - color: colors.text.default, - textAlign: 'left', - ...fontStyles.normal, - }, - buttonWrapper: { - flex: 1, - justifyContent: 'flex-end', - flexDirection: 'column', - rowGap: 16, - marginBottom: Platform.select({ - ios: 16, - android: 24, - default: 16, - }), - }, - srpDesign: { - width: 250, - height: 250, - marginHorizontal: 'auto', - }, - headerLeft: { - marginLeft: 16, - }, - }); - -/** - * View that's shown during the first step of - * the backup seed phrase flow - */ const AccountBackupStep1 = (props) => { const [hasFunds, setHasFunds] = useState(false); - const { colors, themeAppearance } = useTheme(); - const styles = createStyles(colors); + const { themeAppearance } = useTheme(); const { isEnabled: isMetricsEnabled } = useMetrics(); + const tw = useTailwind(); const track = (event, properties) => { const eventBuilder = MetricsEventBuilder.createEventBuilder(event); @@ -123,27 +57,21 @@ const AccountBackupStep1 = (props) => { const navigation = useNavigation(); - useEffect( - () => { - // Check if user has funds - if (Engine.hasFunds()) setHasFunds(true); - - // Disable back press - const hardwareBackPress = () => true; + useEffect(() => { + if (Engine.hasFunds()) setHasFunds(true); - // Add event listener - BackHandler.addEventListener('hardwareBackPress', hardwareBackPress); + const hardwareBackPress = () => true; + BackHandler.addEventListener('hardwareBackPress', hardwareBackPress); - // Remove event listener on cleanup - return () => { - BackHandler.removeEventListener('hardwareBackPress', hardwareBackPress); - }; - }, - [], // Run only when component mounts - ); + return () => { + BackHandler.removeEventListener('hardwareBackPress', hardwareBackPress); + }; + }, []); const goNext = () => { - navigation.navigate('ManualBackupStep1', { ...props.route.params }); + navigation.navigate('ManualBackupStep1', { + ...props.route.params, + }); track(MetaMetricsEvents.WALLET_SECURITY_STARTED); }; @@ -205,24 +133,37 @@ const AccountBackupStep1 = (props) => { }; return ( - + - - + + {strings('manual_backup_step_1.steps', { currentStep: 2, totalSteps: 3, })} - + {strings('account_backup_step_1.title')} @@ -232,14 +173,17 @@ const AccountBackupStep1 = (props) => { ? SRPDesignDark : SRPDesignLight } - style={styles.srpDesign} + style={tw.style('w-[250px] h-[250px] mx-auto')} /> - - + + {strings('account_backup_step_1.info_text_1_1')}{' '} @@ -248,36 +192,42 @@ const AccountBackupStep1 = (props) => { {strings('account_backup_step_1.info_text_1_3')}{' '} - + {strings('account_backup_step_1.info_text_1_4')} - - - - - + + + + + + {!hasFunds && ( )} - - + + {Device.isAndroid() && ( @@ -286,19 +236,19 @@ const AccountBackupStep1 = (props) => { ); }; +const mapDispatchToProps = (dispatch) => ({ + saveOnboardingEvent: (...eventArgs) => dispatch(saveEvent(eventArgs)), +}); + AccountBackupStep1.propTypes = { /** - * Object that represents the current route info like params passed to it + * Object that represents the current route info like params passed to it. */ route: PropTypes.object, /** - * Action to save onboarding event + * Action to save onboarding event. */ saveOnboardingEvent: PropTypes.func, }; -const mapDispatchToProps = (dispatch) => ({ - saveOnboardingEvent: (...eventArgs) => dispatch(saveEvent(eventArgs)), -}); - export default connect(null, mapDispatchToProps)(AccountBackupStep1); diff --git a/app/components/Views/AccountBackupStep1B/__snapshots__/index.test.tsx.snap b/app/components/Views/AccountBackupStep1B/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 7cf11a04f98..00000000000 --- a/app/components/Views/AccountBackupStep1B/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,788 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AccountBackupStep1B render matches snapshot 1`] = ` - - - - - - - - - - 1 - - - - - - - 2 - - - - - - - 3 - - - - - - - - MetaMask password - - - - - Secure wallet - - - - - Confirm Secret Recovery Phrase - - - - - - - 🔒 - - - Secure your wallet - - - - Secure your wallet's - - - Secret Recovery Phrase. - - - - - -  - - - Why is it important? - - - - - - Manual - - - Write down your Secret Recovery Phrase on a piece of paper and store in a safe place. - - - Security level: Very strong - - - - - - - - Risks are: - - - • - You lose it - - - • - You forget where you put it - - - • - Someone else finds it - - - Other options: Doesn’t have to be paper! - - - Tips: - - - • - Store in bank vault - - - • - Store in a safe - - - • - Store in multiple secret places - - - - Start - - - - - - - - -`; diff --git a/app/components/Views/AccountBackupStep1B/index.test.tsx b/app/components/Views/AccountBackupStep1B/index.test.tsx index c71dd570f75..caa6cf33cec 100644 --- a/app/components/Views/AccountBackupStep1B/index.test.tsx +++ b/app/components/Views/AccountBackupStep1B/index.test.tsx @@ -3,12 +3,25 @@ import AccountBackupStep1B from './'; import renderWithProvider from '../../../util/test/renderWithProvider'; import { useNavigation } from '@react-navigation/native'; import { strings } from '../../../../locales/i18n'; -import { fireEvent } from '@testing-library/react-native'; +import { act, fireEvent, waitFor } from '@testing-library/react-native'; import AndroidBackHandler from '../AndroidBackHandler'; import Device from '../../../util/device'; import Routes from '../../../constants/navigation/Routes'; import { InteractionManager } from 'react-native'; +jest.mock('../../UI/ActionModal', () => { + const { View } = jest.requireActual('react-native'); + return ({ + children, + modalVisible, + }: { + children: React.ReactNode; + modalVisible: boolean; + }) => ( + {modalVisible ? children : null} + ); +}); + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -38,25 +51,42 @@ jest .spyOn(InteractionManager, 'runAfterInteractions') .mockImplementation(mockRunAfterInteractions); -describe('AccountBackupStep1B', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); +const createMockNavigation = () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + setOptions: jest.fn(), +}); + +const defaultState = { + engine: { + backgroundState: { + SeedlessOnboardingController: { + vault: undefined as string | undefined, + }, + }, + }, +}; +const seedlessState = { + engine: { + backgroundState: { + SeedlessOnboardingController: { + vault: 'encrypted-vault-data', + }, + }, + }, +}; + +describe('AccountBackupStep1B', () => { afterEach(() => { jest.clearAllMocks(); - jest.useFakeTimers({ legacyFakeTimers: true }); }); - const setupTest = () => { - const mockNavigate = jest.fn(); - const mockGoBack = jest.fn(); - const mockSetOptions = jest.fn(); + const setupTest = (stateOverride = defaultState) => { + const mockNav = createMockNavigation(); - const mockNavigation = (useNavigation as jest.Mock).mockReturnValue({ - navigate: mockNavigate, - goBack: mockGoBack, - setOptions: mockSetOptions, + const mockNavHook = (useNavigation as jest.Mock).mockReturnValue({ + ...mockNav, addListener: jest.fn(), removeListener: jest.fn(), isFocused: jest.fn(), @@ -64,118 +94,165 @@ describe('AccountBackupStep1B', () => { }); const wrapper = renderWithProvider( - , - { - state: { - engine: { - backgroundState: { - SeedlessOnboardingController: { - vault: 'encrypted-vault-data', - }, - }, - }, - }, - }, + , + { state: stateOverride }, ); - return { - wrapper, - mockNavigate, - mockGoBack, - mockSetOptions, - mockNavigation, - }; + return { wrapper, mockNav, mockNavHook }; }; - it('render matches snapshot', () => { - const { wrapper, mockNavigation } = setupTest(); - expect(wrapper).toMatchSnapshot(); - mockNavigation.mockRestore(); - }); + describe('rendering', () => { + it('renders title and SRP explanation link', () => { + const { wrapper } = setupTest(); - it('render title and srp link', () => { - const { wrapper, mockNavigation } = setupTest(); + const title = wrapper.getByText(strings('account_backup_step_1B.title')); + const srpLink = wrapper.getByText( + strings('account_backup_step_1B.subtitle_2'), + ); - const title = wrapper.getByText(strings('account_backup_step_1B.title')); - expect(title).toBeOnTheScreen(); + expect(title).toBeOnTheScreen(); + expect(srpLink).toBeOnTheScreen(); + }); - const srpLink = wrapper.getByText( - strings('account_backup_step_1B.subtitle_2'), - ); - expect(srpLink).toBeOnTheScreen(); - mockNavigation.mockRestore(); - }); + it('renders why-important info button', () => { + const { wrapper } = setupTest(); - it('opens the seed phrase modal on srp link press', () => { - const { wrapper, mockNavigate, mockNavigation } = setupTest(); + const whyImportantButton = wrapper.getByText( + strings('account_backup_step_1B.why_important'), + ); - const srpLink = wrapper.getByText( - strings('account_backup_step_1B.subtitle_2'), - ); - expect(srpLink).toBeOnTheScreen(); + expect(whyImportantButton).toBeOnTheScreen(); + }); + + it('renders start CTA button', () => { + const { wrapper } = setupTest(); + + const ctaButton = wrapper.getByText( + strings('account_backup_step_1B.cta_text'), + ); + + expect(ctaButton).toBeOnTheScreen(); + }); + + it('renders AndroidBackHandler on Android', () => { + (Device.isAndroid as jest.Mock).mockReturnValue(true); + const { wrapper } = setupTest(); + + const androidBackHandler = wrapper.UNSAFE_getByType(AndroidBackHandler); + + expect(androidBackHandler.props.customBackPress).toBeDefined(); + }); + + it('sets navigation header with empty left component', () => { + const { mockNav } = setupTest(); - fireEvent.press(srpLink); - expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SEEDPHRASE_MODAL, + expect(mockNav.setOptions).toHaveBeenCalled(); + const headerLeft = mockNav.setOptions.mock.calls[0][0].headerLeft(); + + expect(React.isValidElement(headerLeft)).toBe(true); }); - mockNavigation.mockRestore(); }); - it('render start button and on press it should navigate to ManualBackupStep1', () => { - const { wrapper, mockNavigate, mockNavigation } = setupTest(); - const ctaButton = wrapper.getByText( - strings('account_backup_step_1B.cta_text'), - ); - expect(ctaButton).toBeOnTheScreen(); - fireEvent.press(ctaButton); - expect(mockNavigate).toHaveBeenCalledWith( - Routes.ONBOARDING.MANUAL_BACKUP.STEP_1, - { + describe('navigation', () => { + it('navigates to SRP modal when explanation link is pressed', () => { + const { wrapper, mockNav } = setupTest(); + + const srpLink = wrapper.getByText( + strings('account_backup_step_1B.subtitle_2'), + ); + fireEvent.press(srpLink); + + expect(mockNav.navigate).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + { screen: Routes.SHEET.SEEDPHRASE_MODAL }, + ); + }); + + it('navigates to ManualBackupStep1 with settingsBackup when CTA is pressed', () => { + const { wrapper, mockNav } = setupTest(); + + const ctaButton = wrapper.getByText( + strings('account_backup_step_1B.cta_text'), + ); + fireEvent.press(ctaButton); + + expect(mockNav.navigate).toHaveBeenCalledWith('ManualBackupStep1', { settingsBackup: true, - }, - ); - mockNavigation.mockRestore(); - }); + }); + }); - it('render AndroidBackHandler when on Android and on back press function is called with null', () => { - const mockIsAndroid = (Device.isAndroid as jest.Mock).mockReturnValue(true); + it('navigates to webview when learn more is pressed in why-secure modal', async () => { + const { wrapper, mockNav } = setupTest(); - const { wrapper, mockNavigation } = setupTest(); + await act(async () => { + fireEvent.press( + wrapper.getByText(strings('account_backup_step_1B.why_important')), + ); + }); - // Verify AndroidBackHandler is rendered - const androidBackHandler = wrapper.UNSAFE_getByType(AndroidBackHandler); - expect(androidBackHandler).toBeDefined(); + await waitFor(() => { + expect( + wrapper.getByText(strings('account_backup_step_1B.learn_more')), + ).toBeOnTheScreen(); + }); - // Verify customBackPress prop is passed - expect(androidBackHandler.props.customBackPress).toBeDefined(); + await act(async () => { + fireEvent.press( + wrapper.getByText(strings('account_backup_step_1B.learn_more')), + ); + }); - // Test that pressing back triggers the correct navigation - androidBackHandler.props.customBackPress(); - expect(null).toBe(null); - mockIsAndroid.mockRestore(); - mockNavigation.mockRestore(); + expect(mockNav.navigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: 'https://support.metamask.io/privacy-and-security/basic-safety-and-security-tips-for-metamask/', + title: strings('drawer.metamask_support'), + }, + }); + }); }); - it('render header left button', () => { - const { mockSetOptions, mockNavigation } = setupTest(); + describe('why-secure modal', () => { + it('reveals modal content when why-important button is pressed', async () => { + const { wrapper } = setupTest(); + + await act(async () => { + fireEvent.press( + wrapper.getByText(strings('account_backup_step_1B.why_important')), + ); + }); + + await waitFor(() => { + expect( + wrapper.getByText(strings('account_backup_step_1B.why_secure_title')), + ).toBeOnTheScreen(); + }); + }); + + it('keeps modal hidden when seedless onboarding login flow is active', async () => { + const { wrapper } = setupTest(seedlessState); + + await act(async () => { + fireEvent.press( + wrapper.getByText(strings('account_backup_step_1B.why_important')), + ); + }); + + expect( + wrapper.queryByText(strings('account_backup_step_1B.why_secure_title')), + ).toBeNull(); + }); + }); - // Verify that setOptions was called with the correct configuration - expect(mockSetOptions).toHaveBeenCalled(); - const setOptionsCall = mockSetOptions.mock.calls[0][0]; + describe('android back handler', () => { + it('returns null when back press is triggered', () => { + (Device.isAndroid as jest.Mock).mockReturnValue(true); + const { wrapper } = setupTest(); - // Get the headerLeft function from the options - const headerLeftComponent = setOptionsCall.headerLeft(); + const androidBackHandler = wrapper.UNSAFE_getByType(AndroidBackHandler); + const result = androidBackHandler.props.customBackPress(); - // Verify the headerLeft component exists and is a valid React element - expect(headerLeftComponent).toBeDefined(); - expect(React.isValidElement(headerLeftComponent)).toBe(true); - mockNavigation.mockRestore(); + expect(result).toBeNull(); + }); }); }); diff --git a/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.test.tsx b/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.test.tsx index 87f4a8681d2..4462cbd6ab0 100644 --- a/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.test.tsx +++ b/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.test.tsx @@ -56,6 +56,10 @@ jest.mock('../../../../core/Engine', () => ({ }, }, }, + accountIdByAddress: { + '0x1234': '0x1234', + '0x5678': '0x5678', + }, }, }, }, diff --git a/app/components/Views/AccountPermissions/AccountPermissions.test.tsx b/app/components/Views/AccountPermissions/AccountPermissions.test.tsx index 70aa9ad9250..c14b10700c2 100644 --- a/app/components/Views/AccountPermissions/AccountPermissions.test.tsx +++ b/app/components/Views/AccountPermissions/AccountPermissions.test.tsx @@ -185,6 +185,10 @@ jest.mock('../../../core/Engine', () => ({ }, selectedAccount: 'mock-id-1', }, + accountIdByAddress: { + '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272': 'mock-id-1', + '0xd018538c87232ff95acbce4870629b75640a78e7': 'mock-id-2', + }, }, }, AccountTrackerController: { diff --git a/app/components/Views/AccountsMenu/AccountsMenu.test.tsx b/app/components/Views/AccountsMenu/AccountsMenu.test.tsx index b8c9eb5ae4b..523ac78c818 100644 --- a/app/components/Views/AccountsMenu/AccountsMenu.test.tsx +++ b/app/components/Views/AccountsMenu/AccountsMenu.test.tsx @@ -637,6 +637,26 @@ describe('AccountsMenu', () => { }); }); + describe('Networks Row', () => { + it('render Networks row', () => { + const { getByText, getByTestId } = render(); + + expect(getByText('accounts_menu.networks')).toBeOnTheScreen(); + expect(getByTestId(AccountsMenuSelectorsIDs.NETWORKS)).toBeOnTheScreen(); + }); + + it('navigate to NetworksManagement when Networks is pressed', () => { + const { getByTestId } = render(); + const networksButton = getByTestId(AccountsMenuSelectorsIDs.NETWORKS); + + fireEvent.press(networksButton); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.SETTINGS.NETWORKS_MANAGEMENT, + ); + }); + }); + describe('RESOURCES Section', () => { describe('About MetaMask Row', () => { it('render About MetaMask row', () => { diff --git a/app/components/Views/AccountsMenu/AccountsMenu.tsx b/app/components/Views/AccountsMenu/AccountsMenu.tsx index 039b176b34e..474c6917a41 100644 --- a/app/components/Views/AccountsMenu/AccountsMenu.tsx +++ b/app/components/Views/AccountsMenu/AccountsMenu.tsx @@ -42,7 +42,6 @@ import { selectIsMetamaskNotificationsEnabled, } from '../../../selectors/notifications'; import { selectIsBackupAndSyncEnabled } from '../../../selectors/identity'; -import { useNetworkManagementEnabled } from '../../../selectors/featureFlagController/networkManagement/useNetworkManagementEnabled'; const AccountsMenu = () => { const tw = useTailwind(); @@ -121,8 +120,6 @@ const AccountsMenu = () => { readNotificationCount, isBackupAndSyncEnabled, ]); - const isNetworkManagementEnabled = useNetworkManagementEnabled(); - const handleBack = useCallback(() => { navigation.goBack(); }, [navigation]); @@ -458,17 +455,13 @@ const AccountsMenu = () => { )} {/* Networks Row */} - {isNetworkManagementEnabled && ( - - } - label={strings('accounts_menu.networks')} - endAccessory={arrowRightIcon} - onPress={onPressNetworks} - testID={AccountsMenuSelectorsIDs.NETWORKS} - /> - )} + } + label={strings('accounts_menu.networks')} + endAccessory={arrowRightIcon} + onPress={onPressNetworks} + testID={AccountsMenuSelectorsIDs.NETWORKS} + /> {separator} diff --git a/app/components/Views/AccountsMenu/__snapshots__/AccountsMenu.test.tsx.snap b/app/components/Views/AccountsMenu/__snapshots__/AccountsMenu.test.tsx.snap index 1b04a99b047..298f661af2c 100644 --- a/app/components/Views/AccountsMenu/__snapshots__/AccountsMenu.test.tsx.snap +++ b/app/components/Views/AccountsMenu/__snapshots__/AccountsMenu.test.tsx.snap @@ -228,7 +228,7 @@ exports[`AccountsMenu Snapshots match snapshot when MetaMask Card is hidden 1`] "justifyContent": "center", "opacity": 1, "paddingHorizontal": 4, - "paddingVertical": 16, + "paddingVertical": 12, }, false, ] @@ -770,6 +770,151 @@ exports[`AccountsMenu Snapshots match snapshot when MetaMask Card is hidden 1`] + + + + + + + accounts_menu.networks + + + + + + + + { const currentNetworkName = getNetworkInfo(0)?.networkName; - const tabViewRef = useRef(); const params = useParams(); const perpsEnabledFlag = useSelector(selectPerpsEnabledFlag); const isPerpsEnabled = useMemo( () => perpsEnabledFlag && isEvmSelected, [perpsEnabledFlag, isEvmSelected], ); - const [activeTabIndex, setActiveTabIndex] = useState(0); const predictEnabledFlag = useSelector(selectPredictEnabledFlag); const isPredictEnabled = useMemo( () => predictEnabledFlag, @@ -129,6 +127,17 @@ const ActivityView = () => { // Perps comes after Transactions (0) and Orders (1) const perpsTabIndex = useMemo(() => 2, []); + const [initialTabIndex] = useState(() => { + if (params.redirectToOrders) { + return 1; + } + if (isPerpsEnabled && params.redirectToPerpsTransactions) { + return perpsTabIndex; + } + return 0; + }); + const [activeTabIndex, setActiveTabIndex] = useState(initialTabIndex); + // Predict comes after Transactions (0), Orders (1), and optionally Perps const predictTabIndex = useMemo( () => (isPerpsEnabled ? 3 : 2), @@ -139,22 +148,26 @@ const ActivityView = () => { const isPredictTabActive = isPredictEnabled && activeTabIndex === predictTabIndex; + const handleChangeTab = useCallback(({ i }) => { + setActiveTabIndex(i); + }, []); + useFocusEffect( useCallback(() => { + const nextParams = {}; if (params.redirectToOrders) { - const orderTabNumber = 1; - navigation.setParams({ redirectToOrders: false }); - tabViewRef.current?.goToTabIndex(orderTabNumber); - } else if (isPerpsEnabled && params.redirectToPerpsTransactions) { - navigation.setParams({ redirectToPerpsTransactions: false }); - tabViewRef.current?.goToTabIndex(perpsTabIndex); + nextParams.redirectToOrders = false; + } + if (params.redirectToPerpsTransactions) { + nextParams.redirectToPerpsTransactions = false; + } + if (Object.keys(nextParams).length > 0) { + navigation.setParams(nextParams); } }, [ navigation, params.redirectToOrders, - isPerpsEnabled, params.redirectToPerpsTransactions, - perpsTabIndex, ]), ); @@ -190,8 +203,8 @@ const ActivityView = () => { setActiveTabIndex(i)} + initialActiveIndex={initialTabIndex} + onChangeTab={handleChangeTab} tabsListContentTwClassName="px-0 pb-3" testID={ActivitiesViewSelectorsIDs.TABS_CONTAINER} > @@ -249,13 +262,11 @@ const ActivityView = () => { tabLabel={strings('perps.transactions.title')} style={styles.tabWrapper} > - {isPerpsTabActive ? ( - - - - - - ) : null} + + + {isPerpsTabActive ? : null} + + )} diff --git a/app/components/Views/ActivityView/index.test.tsx b/app/components/Views/ActivityView/index.test.tsx index 83b2aa7876b..c6ae66b3ab9 100644 --- a/app/components/Views/ActivityView/index.test.tsx +++ b/app/components/Views/ActivityView/index.test.tsx @@ -4,7 +4,7 @@ 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'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as networkManagerUtils from '../../UI/NetworkManager'; import { useCurrentNetworkInfo } from '../../hooks/useCurrentNetworkInfo'; import { ActivitiesViewSelectorsIDs } from './ActivitiesView.testIds'; @@ -27,11 +27,14 @@ jest.mock('../../UI/Predict/selectors/featureFlags', () => ({ // Track which tabs are rendered - populated by mock let renderedTabs: string[] = []; +let lastInitialActiveIndex: number | undefined; // Helper to get rendered tabs for assertions const getRenderedTabs = () => renderedTabs; +const getLastInitialActiveIndex = () => lastInitialActiveIndex; const clearRenderedTabs = () => { renderedTabs = []; + lastInitialActiveIndex = undefined; }; jest.mock('../../../component-library/components-temp/Tabs', () => { @@ -42,10 +45,11 @@ jest.mock('../../../component-library/components-temp/Tabs', () => { ( props: { children?: React.ReactElement[]; + initialActiveIndex?: number; onChangeTab?: (params: { i: number }) => void; [key: string]: unknown; }, - ref: React.Ref<{ goToTabIndex: (index: number) => void }>, + _ref: React.Ref<{ goToTabIndex: (index: number) => void }>, ) => { const children = Array.isArray(props.children) ? props.children : []; @@ -62,13 +66,8 @@ jest.mock('../../../component-library/components-temp/Tabs', () => { }); // Update module-level variable for test assertions renderedTabs = tabKeys; - }, [children]); - - ReactActual.useImperativeHandle(ref, () => ({ - goToTabIndex: (index: number) => { - props.onChangeTab?.({ i: index }); - }, - })); + lastInitialActiveIndex = props.initialActiveIndex; + }, [children, props.initialActiveIndex]); return ReactActual.createElement( View, @@ -100,6 +99,7 @@ const Stack = createStackNavigator(); const mockNavigation = { navigate: jest.fn(), + setParams: jest.fn(), setOptions: jest.fn(), goBack: jest.fn(), canGoBack: jest.fn(() => true), @@ -264,6 +264,7 @@ describe('ActivityView', () => { mockPerpsEnabled = false; mockPredictEnabled = false; clearRenderedTabs(); + mockRoute.params = {}; }); it('matches snapshot', () => { @@ -472,9 +473,10 @@ describe('ActivityView', () => { mockPerpsEnabled = true; mockIsEvmSelected = true; - const { getByTestId } = renderComponent(mockInitialState); + const { getByTestId, queryByTestId } = renderComponent(mockInitialState); expect(getByTestId('tab-perps')).toBeTruthy(); + expect(queryByTestId('perps-transactions-view')).toBeNull(); expect(getRenderedTabs()).toContain('perps'); }); @@ -495,6 +497,45 @@ describe('ActivityView', () => { expect(getRenderedTabs()).not.toContain('perps'); }); + + it('uses Perps as initial tab when redirected to Perps transactions', () => { + mockPerpsEnabled = true; + mockIsEvmSelected = true; + mockRoute.params = { + redirectToPerpsTransactions: true, + showBackButton: true, + }; + + const { getByTestId } = renderComponent(mockInitialState); + + expect(getByTestId('perps-transactions-view')).toBeTruthy(); + expect(getLastInitialActiveIndex()).toBe(2); + expect(mockNavigation.setParams).toHaveBeenCalledWith({ + redirectToPerpsTransactions: false, + }); + }); + }); + + describe('Orders tab', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRoute.params = {}; + }); + + it('renders orders list and clears redirect param when redirected to orders', () => { + mockRoute.params = { + redirectToOrders: true, + showBackButton: true, + }; + + const { getByTestId } = renderComponent(mockInitialState); + + expect(getByTestId('ramp-orders-list')).toBeTruthy(); + expect(getLastInitialActiveIndex()).toBe(1); + expect(mockNavigation.setParams).toHaveBeenCalledWith({ + redirectToOrders: false, + }); + }); }); describe('Predict tab', () => { diff --git a/app/components/Views/AddAsset/components/AddCustomCollectible/AddCustomCollectible.test.tsx b/app/components/Views/AddAsset/components/AddCustomCollectible/AddCustomCollectible.test.tsx index 1dc3281648c..f5975d19fff 100644 --- a/app/components/Views/AddAsset/components/AddCustomCollectible/AddCustomCollectible.test.tsx +++ b/app/components/Views/AddAsset/components/AddCustomCollectible/AddCustomCollectible.test.tsx @@ -4,7 +4,7 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import AddCustomCollectible from './AddCustomCollectible'; import Engine from '../../../../../core/Engine'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as utilsTransactions from '../../../../../util/transactions'; // --- Mock variables (hoisted by Jest) --- diff --git a/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.test.tsx b/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.test.tsx index e5493627b4e..1b0ee47fa10 100644 --- a/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.test.tsx +++ b/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.test.tsx @@ -149,7 +149,7 @@ describe('AddCustomToken', () => { const { getByTestId } = renderComponent(); const nextButton = getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON); - expect(nextButton.props.disabled || nextButton.props.isDisabled).toBe(true); + expect(nextButton).toBeDisabled(); }); it('navigates to ConfirmAddAsset when form is valid and Next is pressed', async () => { diff --git a/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx b/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx index 086d1ffdb42..4ab6912e006 100644 --- a/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx +++ b/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx @@ -9,7 +9,14 @@ import { TextInput, Platform, ActivityIndicator } from 'react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; +import { + Box, + Text, + TextVariant, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import type { Hex } from '@metamask/utils'; import { useNavigation, type ParamListBase } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; @@ -32,11 +39,6 @@ import { } from '../../../../../util/networks'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { formatIconUrlWithProxy } from '@metamask/assets-controllers'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import Icon, { IconName, IconSize, @@ -547,14 +549,15 @@ const AddCustomToken = ({ ); diff --git a/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.test.tsx b/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.test.tsx index 55481524ade..e6cf6dbd8c2 100644 --- a/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.test.tsx +++ b/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.test.tsx @@ -307,7 +307,7 @@ describe('SearchTokenAutocomplete', () => { it('next button is disabled when no tokens are selected', () => { const { getByTestId } = renderComponent(); const nextButton = getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON); - expect(nextButton).toHaveProp('disabled', true); + expect(nextButton).toBeDisabled(); }); it('enables Next button after selecting a token', () => { @@ -320,7 +320,7 @@ describe('SearchTokenAutocomplete', () => { fireEvent.press(tokenResult); const nextButton = getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON); - expect(nextButton).toHaveProp('disabled', false); + expect(nextButton).toBeEnabled(); }); it('shows clear button when search has text and clears on press', () => { @@ -354,16 +354,10 @@ describe('SearchTokenAutocomplete', () => { ); fireEvent.press(tokenResult); - expect(getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON)).toHaveProp( - 'disabled', - false, - ); + expect(getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON)).toBeEnabled(); fireEvent.press(tokenResult); - expect(getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON)).toHaveProp( - 'disabled', - true, - ); + expect(getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON)).toBeDisabled(); }); it('navigates to ConfirmAddAsset with correct params and tracks analytics', () => { diff --git a/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.tsx b/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.tsx index 2ea8d87a8e0..0e325625a7b 100644 --- a/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.tsx +++ b/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.tsx @@ -22,13 +22,10 @@ import { getDecimalChainId } from '../../../../../util/networks'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import Routes from '../../../../../constants/navigation/Routes'; import SearchTokenResults from '../SearchTokenResults/SearchTokenResults'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; -import { ImportTokenViewSelectorsIDs } from '../../ImportAssetView.testIds'; import { + Button, + ButtonVariant, + ButtonSize, Box, BoxFlexDirection, BoxAlignItems, @@ -38,6 +35,7 @@ import { IconSize, IconColor, } from '@metamask/design-system-react-native'; +import { ImportTokenViewSelectorsIDs } from '../../ImportAssetView.testIds'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import Logger from '../../../../../util/Logger'; import { CaipAssetType, Hex } from '@metamask/utils'; @@ -451,14 +449,15 @@ const SearchTokenAutocomplete = ({ navigation, selectedChainId }: Props) => { ); diff --git a/app/components/Views/AssetDetails/AssetDetails.view.test.tsx b/app/components/Views/AssetDetails/AssetDetails.view.test.tsx index e906f02aae7..e5d530ceba3 100644 --- a/app/components/Views/AssetDetails/AssetDetails.view.test.tsx +++ b/app/components/Views/AssetDetails/AssetDetails.view.test.tsx @@ -1,6 +1,6 @@ import '../../../../tests/component-view/mocks'; import { renderAssetDetailsView } from '../../../../tests/component-view/renderers/assetDetails'; -import { describeForPlatforms } from '../../../util/test/platform'; +import { describeForPlatforms } from '../../../../tests/component-view/platform'; // addresses Regression: #25100 – Token Details page shows wrong network diff --git a/app/components/Views/AssetDetails/AssetDetailsActions/__snapshots__/AssetDetailsActions.test.tsx.snap b/app/components/Views/AssetDetails/AssetDetailsActions/__snapshots__/AssetDetailsActions.test.tsx.snap index e94f41fa81a..8bc4691a3c8 100644 --- a/app/components/Views/AssetDetails/AssetDetailsActions/__snapshots__/AssetDetailsActions.test.tsx.snap +++ b/app/components/Views/AssetDetails/AssetDetailsActions/__snapshots__/AssetDetailsActions.test.tsx.snap @@ -70,7 +70,7 @@ exports[`AssetDetailsActions should render correctly 1`] = ` "justifyContent": "center", "opacity": 1, "paddingHorizontal": 4, - "paddingVertical": 16, + "paddingVertical": 12, }, false, ] @@ -183,7 +183,7 @@ exports[`AssetDetailsActions should render correctly 1`] = ` "justifyContent": "center", "opacity": 0.5, "paddingHorizontal": 4, - "paddingVertical": 16, + "paddingVertical": 12, }, false, ] @@ -296,7 +296,7 @@ exports[`AssetDetailsActions should render correctly 1`] = ` "justifyContent": "center", "opacity": 0.5, "paddingHorizontal": 4, - "paddingVertical": 16, + "paddingVertical": 12, }, false, ] @@ -409,7 +409,7 @@ exports[`AssetDetailsActions should render correctly 1`] = ` "justifyContent": "center", "opacity": 1, "paddingHorizontal": 4, - "paddingVertical": 16, + "paddingVertical": 12, }, false, ] diff --git a/app/components/Views/CashTokensFullView/CashTokensFullView.tsx b/app/components/Views/CashTokensFullView/CashTokensFullView.tsx index 32d523ac65c..6ae12e618bd 100644 --- a/app/components/Views/CashTokensFullView/CashTokensFullView.tsx +++ b/app/components/Views/CashTokensFullView/CashTokensFullView.tsx @@ -48,7 +48,7 @@ const CashTokensFullView = () => { {!hasMusdBalanceOnAnyChain ? ( - + ) : ( diff --git a/app/components/Views/ConnectQRHardware/Instruction/index.tsx b/app/components/Views/ConnectQRHardware/Instruction/index.tsx index b37ab8c2af4..937e32cc18d 100644 --- a/app/components/Views/ConnectQRHardware/Instruction/index.tsx +++ b/app/components/Views/ConnectQRHardware/Instruction/index.tsx @@ -31,7 +31,7 @@ interface IConnectQRInstructionProps { renderAlert: () => React.JSX.Element; } -// eslint-disable-next-line import/no-commonjs +// eslint-disable-next-line import-x/no-commonjs const ConnectQRInstruction = (props: IConnectQRInstructionProps) => { const { onConnect, renderAlert, navigation } = props; diff --git a/app/components/Views/Homepage/Homepage.tsx b/app/components/Views/Homepage/Homepage.tsx index 576b529571a..e1a7798241f 100644 --- a/app/components/Views/Homepage/Homepage.tsx +++ b/app/components/Views/Homepage/Homepage.tsx @@ -100,9 +100,9 @@ const Homepage = forwardRef((_, ref) => { return ( { expect(mockInitiateCustomConversion).not.toHaveBeenCalled(); }); - it('tracks MUSD_CONVERSION_CTA_CLICKED with home_cash_section when Get mUSD is pressed', () => { + it('tracks MUSD_CONVERSION_CTA_CLICKED with home_cash_section when Get mUSD is pressed on homepage', () => { renderWithProvider(); fireEvent.press(screen.getByTestId(CashGetMusdEmptyStateSelectors.BUTTON)); @@ -144,6 +144,24 @@ describe('CashGetMusdEmptyState', () => { expect(mockTrackEvent).toHaveBeenCalled(); }); + it('tracks MUSD_CONVERSION_CTA_CLICKED with mobile-token-list-page when Get mUSD is pressed on full view', () => { + renderWithProvider(); + + fireEvent.press(screen.getByTestId(CashGetMusdEmptyStateSelectors.BUTTON)); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.MUSD_CONVERSION_CTA_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.MOBILE_TOKEN_LIST_PAGE, + cta_type: MUSD_EVENTS_CONSTANTS.MUSD_CTA_TYPES.PRIMARY, + cta_text: 'Get mUSD', + }), + ); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + it('hides Get mUSD button when no convertible tokens and mUSD is not buyable', () => { mockUseMusdConversionFlowData.hasConvertibleTokens = false; mockUseMusdConversionFlowData.isMusdBuyableOnAnyChain = false; diff --git a/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx b/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx index 5ee0c74f1a1..bb8f9b75e48 100644 --- a/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx +++ b/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx @@ -44,12 +44,18 @@ import { getIntlNumberFormatter } from '../../../../../util/intl'; import { CashGetMusdEmptyStateSelectors } from './CashGetMusdEmptyState.testIds'; import { MUSD_MAINNET_ASSET_FOR_DETAILS } from './CashGetMusdEmptyState.constants'; +interface CashGetMusdEmptyStateProps { + isFullView?: boolean; +} + /** * Empty state for the Cash (mUSD) full view when the user has no mUSD. * Shows a "Get mUSD" card: token row (navigates to Mainnet mUSD Asset Details) + Get mUSD button. * Button routes to Buy flow (empty wallet + mUSD buyable) or Convert flow (non-empty + has convertible tokens). */ -const CashGetMusdEmptyState = () => { +const CashGetMusdEmptyState = ({ + isFullView = false, +}: CashGetMusdEmptyStateProps) => { const tw = useTailwind(); const { goToBuy } = useRampNavigation(); const { @@ -117,7 +123,9 @@ const CashGetMusdEmptyState = () => { trackEvent( createEventBuilder(MetaMetricsEvents.MUSD_CONVERSION_CTA_CLICKED) .addProperties({ - location: EVENT_LOCATIONS.HOME_CASH_SECTION, + location: isFullView + ? EVENT_LOCATIONS.MOBILE_TOKEN_LIST_PAGE + : EVENT_LOCATIONS.HOME_CASH_SECTION, redirects_to: getRedirectLocation(), cta_type: MUSD_CTA_TYPES.PRIMARY, cta_text: strings('earn.musd_conversion.get_musd'), @@ -166,6 +174,7 @@ const CashGetMusdEmptyState = () => { isMusdBuyableOnAnyChain, hasConvertibleTokens, hasSeenConversionEducationScreen, + isFullView, isQuickConvertEnabled, getPaymentTokenForSelectedNetwork, goToBuy, @@ -201,12 +210,12 @@ const CashGetMusdEmptyState = () => { > {MUSD_TOKEN.name} - + - {musdPriceFormatted} + {musdPriceFormatted} {'\u2022'} ({ addProperties: jest.fn().mockReturnThis(), build: jest.fn(), })); +jest.mock('../../../../../core/NavigationService', () => ({ + __esModule: true, + default: { + navigation: { + navigate: jest.fn(), + }, + }, +})); +const mockUseMusdBalance = jest.fn(() => ({ + tokenBalanceAggregated: '1800.5', + fiatBalanceAggregatedFormatted: '$1,800.50', + hasMusdBalanceOnAnyChain: false, +})); jest.mock('../../../../UI/Earn/hooks/useMusdBalance', () => ({ - useMusdBalance: () => ({ - tokenBalanceAggregated: '1800.5', - fiatBalanceAggregatedFormatted: '$1,800.50', - }), + useMusdBalance: () => mockUseMusdBalance(), })); const mockUseMerklBonusClaim = jest.fn(() => ({ @@ -48,6 +61,11 @@ jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ describe('MusdAggregatedRow', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseMusdBalance.mockReturnValue({ + tokenBalanceAggregated: '1800.5', + fiatBalanceAggregatedFormatted: '$1,800.50', + hasMusdBalanceOnAnyChain: false, + }); mockUseMerklBonusClaim.mockReturnValue({ claimableReward: '10', hasPendingClaim: false, @@ -110,6 +128,37 @@ describe('MusdAggregatedRow', () => { expect(screen.getByText('3% bonus')).toBeOnTheScreen(); }); + describe('handleTokenRowPress', () => { + it('navigates to CASH_TOKENS_FULL_VIEW when user has mUSD balance on any chain', () => { + mockUseMusdBalance.mockReturnValueOnce({ + tokenBalanceAggregated: '1800.5', + fiatBalanceAggregatedFormatted: '$1,800.50', + hasMusdBalanceOnAnyChain: true, + }); + + renderWithProvider(); + + fireEvent.press(screen.getByTestId('cash-section-musd-row')); + + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + Routes.WALLET.CASH_TOKENS_FULL_VIEW, + ); + }); + + it('navigates to mUSD mainnet Asset details when user has no mUSD balance on any chain', () => { + renderWithProvider(); + + fireEvent.press(screen.getByTestId('cash-section-musd-row')); + + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + 'Asset', + expect.objectContaining({ + source: TokenDetailsSource.MobileTokenListPage, + }), + ); + }); + }); + describe('claimable bonus threshold (min $0.01)', () => { it('hides Claim bonus when claimable reward is "< 0.01"', () => { mockUseMerklBonusClaim.mockReturnValue({ diff --git a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx index 56a02f2dfbb..53ebb26656b 100644 --- a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx +++ b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx @@ -42,6 +42,7 @@ import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { MUSD_MAINNET_ASSET_FOR_DETAILS } from './CashGetMusdEmptyState.constants'; import NavigationService from '../../../../../core/NavigationService'; import { TokenDetailsSource } from '../../../../UI/TokenDetails/constants/constants'; +import Routes from '../../../../../constants/navigation/Routes'; /** * Minimal mUSD asset for useMerklBonusClaim (claim runs on Linea). @@ -62,8 +63,11 @@ const LINEA_MUSD_ASSET: TokenI = { const MusdAggregatedRow = () => { const tw = useTailwind(); const privacyMode = useSelector(selectPrivacyMode); - const { tokenBalanceAggregated, fiatBalanceAggregatedFormatted } = - useMusdBalance(); + const { + tokenBalanceAggregated, + fiatBalanceAggregatedFormatted, + hasMusdBalanceOnAnyChain, + } = useMusdBalance(); const { claimableReward, hasPendingClaim, claimRewards, isClaiming } = useMerklBonusClaim( LINEA_MUSD_ASSET, @@ -91,6 +95,13 @@ const MusdAggregatedRow = () => { }, [trackEvent, createEventBuilder, networkName, claimRewards]); const handleTokenRowPress = useCallback(() => { + if (hasMusdBalanceOnAnyChain) { + NavigationService.navigation.navigate( + Routes.WALLET.CASH_TOKENS_FULL_VIEW as never, + ); + return; + } + NavigationService.navigation.navigate( 'Asset' as never, { @@ -98,7 +109,7 @@ const MusdAggregatedRow = () => { source: TokenDetailsSource.MobileTokenListPage, } as never, ); - }, []); + }, [hasMusdBalanceOnAnyChain]); const tokenBalanceDisplay = `${getIntlNumberFormatter(I18n.locale, { minimumFractionDigits: 0, diff --git a/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.test.tsx b/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.test.tsx index 0ca9d144e83..f5d182a12d3 100644 --- a/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.test.tsx +++ b/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.test.tsx @@ -83,8 +83,8 @@ describe('PopularTokenRow', () => { renderWithProvider(); - // Price should be formatted with currency symbol - expect(screen.getByText('$1,234.56')).toBeOnTheScreen(); + // Price is rendered with dot separator when percentage exists (e.g. "$1,234.56 • +5.25%") + expect(screen.getByText(/\$1,234\.56/)).toBeOnTheScreen(); }); it('renders dash when price is undefined', () => { @@ -92,7 +92,8 @@ describe('PopularTokenRow', () => { renderWithProvider(); - expect(screen.getByText('—')).toBeOnTheScreen(); + // Dash is rendered; with default percentage we get "— • +5.25%" + expect(screen.getByText(/—/)).toBeOnTheScreen(); }); it('renders positive percentage change with plus sign', () => { @@ -124,8 +125,23 @@ describe('PopularTokenRow', () => { renderWithProvider(); - // Use regex to match any text containing a percentage value (e.g. +5.25%, -3.50%) + // No percentage should be shown expect(screen.queryByText(/[+-]?\d+\.\d+%/)).toBeNull(); + // Price only, no trailing bullet + expect(screen.getByText('$100.50')).toBeOnTheScreen(); + }); + + it('does not render trailing bullet when percentage change is undefined', () => { + const token = createMockToken({ + price: 99.99, + priceChange1d: undefined, + }); + + renderWithProvider(); + + // Price without trailing bullet (no "•" after it) + expect(screen.getByText('$99.99')).toBeOnTheScreen(); + expect(screen.queryByText(/\$\d+\.\d+\s+•\s*$/)).toBeNull(); }); it('renders description instead of price when provided', () => { @@ -138,8 +154,8 @@ describe('PopularTokenRow', () => { renderWithProvider(); expect(screen.getByText('Earn 3% bonus')).toBeOnTheScreen(); - // Price should not be rendered when description is present - expect(screen.queryByText('$100.00')).not.toBeOnTheScreen(); + // Price and percentage should not be rendered when description is present + expect(screen.queryByText(/\$100\.00/)).toBeNull(); }); it('renders Buy button', () => { @@ -252,9 +268,12 @@ describe('PopularTokenRow', () => { fireEvent.press(screen.getByText('Buy')); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: 'eip155:1/erc20:0x1234567890abcdef1234567890abcdef12345678', - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { + assetId: 'eip155:1/erc20:0x1234567890abcdef1234567890abcdef12345678', + }, + { buyFlowOrigin: 'homeTokenList' }, + ); }); it('fires Ramps Button Clicked analytics event when Buy is pressed', () => { @@ -293,8 +312,9 @@ describe('PopularTokenRow', () => { renderWithProvider(); - // Should not render percentage for Infinity - expect(screen.queryByText('Infinity%')).not.toBeOnTheScreen(); + // Should not render percentage for Infinity; price only, no trailing bullet + expect(screen.queryByText('Infinity%')).toBeNull(); + expect(screen.getByText('$100.50')).toBeOnTheScreen(); }); it('handles NaN price change gracefully', () => { @@ -302,8 +322,9 @@ describe('PopularTokenRow', () => { renderWithProvider(); - // Should not render percentage for NaN - expect(screen.queryByText('NaN%')).not.toBeOnTheScreen(); + // Should not render percentage for NaN; price only, no trailing bullet + expect(screen.queryByText('NaN%')).toBeNull(); + expect(screen.getByText('$100.50')).toBeOnTheScreen(); }); }); }); diff --git a/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.tsx b/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.tsx index 94bcc00ff0e..e06443b290a 100644 --- a/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.tsx +++ b/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.tsx @@ -168,7 +168,7 @@ const PopularTokenRow: React.FC = ({ token }) => { const handleBuy = useCallback(() => { trackBuyButtonClicked(); - goToBuy({ assetId: token.assetId }); + goToBuy({ assetId: token.assetId }, { buyFlowOrigin: 'homeTokenList' }); }, [trackBuyButtonClicked, goToBuy, token.assetId]); const priceDisplay = useMemo(() => { @@ -222,16 +222,15 @@ const PopularTokenRow: React.FC = ({ token }) => { color={TextColor.Alternative} > {priceDisplay} + {percentageChange.text ? ' \u2022 ' : ''} {percentageChange.text ? ( - - - {percentageChange.text} - - + + {percentageChange.text} + ) : null} )} diff --git a/app/components/Views/ImportPrivateKey/styles.ts b/app/components/Views/ImportPrivateKey/styles.ts index 62e34ce9d9a..46c33f0a975 100644 --- a/app/components/Views/ImportPrivateKey/styles.ts +++ b/app/components/Views/ImportPrivateKey/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { Platform, StyleSheet, TextStyle } from 'react-native'; import { Theme } from '../../../util/theme/models'; import { getFontFamily } from '../../../component-library/components/Texts/Text'; diff --git a/app/components/Views/LockScreen/index.tsx b/app/components/Views/LockScreen/index.tsx index 1687e45e27f..fd474485d54 100644 --- a/app/components/Views/LockScreen/index.tsx +++ b/app/components/Views/LockScreen/index.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ import React from 'react'; import FoxLoader from '../../UI/FoxLoader'; diff --git a/app/components/Views/Login/__snapshots__/index.test.tsx.snap b/app/components/Views/Login/__snapshots__/index.test.tsx.snap index 8cb6dae376c..dda2f8d32a2 100644 --- a/app/components/Views/Login/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Login/__snapshots__/index.test.tsx.snap @@ -3,23 +3,34 @@ exports[`Login renders matching snapshot 1`] = ` @@ -28,26 +39,34 @@ exports[`Login renders matching snapshot 1`] = ` resizeMode="contain" source={1} style={ - { - "alignSelf": "center", - "height": 80, - "marginBottom": 60, - "marginTop": 60, - "tintColor": "#131416", - "width": 160, - } + [ + { + "alignSelf": "center", + "height": 80, + "marginBottom": 60, + "marginTop": 60, + "width": 160, + }, + { + "tintColor": "#131416", + }, + ] } /> - Unlock - + @@ -404,26 +482,34 @@ exports[`Login renders matching snapshot when password input is focused 1`] = ` resizeMode="contain" source={1} style={ - { - "alignSelf": "center", - "height": 80, - "marginBottom": 60, - "marginTop": 60, - "tintColor": "#131416", - "width": 160, - } + [ + { + "alignSelf": "center", + "height": 80, + "marginBottom": 60, + "marginTop": 60, + "width": 160, + }, + { + "tintColor": "#131416", + }, + ] } /> - Unlock - + ; jest.mock('../../../core/Authentication/hooks/useAuthentication', () => ({ __esModule: true, @@ -72,6 +85,7 @@ jest.mock('../../../core/Authentication/hooks/useAuthentication', () => ({ revealSRP: mockRevealSRP, revealPrivateKey: mockRevealPrivateKey, checkIsSeedlessPasswordOutdated: mockCheckIsSeedlessPasswordOutdated, + updateAuthPreference: mockUpdateAuthPreference, }), })); @@ -79,9 +93,9 @@ const defaultCapabilities: AuthCapabilities = { authType: AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION, isBiometricsAvailable: true, passcodeAvailable: true, - authLabel: 'Device Authentication', + authLabel: 'Face ID', authDescription: - 'Use your device’s biometrics or passcode to unlock MetaMask.', + "Use your device's biometrics or passcode to unlock MetaMask.", authIcon: IconName.Lock, osAuthEnabled: true, allowLoginWithRememberMe: false, @@ -131,12 +145,10 @@ jest .spyOn(InteractionManager, 'runAfterInteractions') .mockImplementation(mockRunAfterInteractions); -// Mock password requirements jest.mock('../../../util/password', () => ({ passwordRequirementsMet: jest.fn(), })); -// Mock react-native Keyboard jest.mock('react-native', () => { const RN = jest.requireActual('react-native'); return { @@ -147,9 +159,8 @@ jest.mock('react-native', () => { }; }); -// Mock StorageWrapper jest.mock('../../../store/storage-wrapper', () => ({ - getItem: jest.fn(), + getItem: jest.fn().mockResolvedValue(null), setItem: jest.fn(), })); @@ -180,11 +191,6 @@ jest.mock('../../../actions/security', () => ({ }, })); -jest.mock('../../../store/storage-wrapper', () => ({ - getItem: jest.fn().mockResolvedValue(null), - setItem: jest.fn(), -})); - jest.mock('../../../util/authentication', () => ({ passcodeType: jest.fn(), updateAuthTypeStorageFlags: jest.fn(), @@ -194,9 +200,11 @@ jest.mock('../../../core/BackupVault', () => ({ getVaultFromBackup: jest.fn(), })); -// Mock animation components -jest.mock('../../UI/OnboardingAnimation/OnboardingAnimation'); +jest.mock('../../../util/validators', () => ({ + parseVaultValue: jest.fn(), +})); +jest.mock('../../UI/OnboardingAnimation/OnboardingAnimation'); jest.mock('../../UI/FoxAnimation/FoxAnimation'); jest.mock('../../../util/test/utils', () => ({ @@ -204,7 +212,6 @@ jest.mock('../../../util/test/utils', () => ({ isE2E: false, })); -// Mock Rive animations jest.mock('rive-react-native', () => ({ __esModule: true, default: () => null, @@ -216,7 +223,6 @@ jest.mock('../../UI/ScreenshotDeterrent', () => ({ ScreenshotDeterrent: () => null, })); -// Mock safe area context jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -243,10 +249,6 @@ jest.mock('react-native-keyboard-controller', () => ({ }, })); -jest.mock('../../../util/validators', () => ({ - parseVaultValue: jest.fn(), -})); - jest.mock('../../../core/OAuthService/OAuthService', () => ({ resetOauthState: jest.fn(), })); @@ -267,7 +269,6 @@ jest.mock('../../../util/trace', () => { } return 'mockTraceContext'; }); - // Expose ref so tests can await the trace callback promise inside act() Object.assign(traceFn, { __traceCallbackPromiseRef: traceCallbackPromiseRef, }); @@ -278,7 +279,6 @@ jest.mock('../../../util/trace', () => { }; }); -// Mock useNetInfo jest.mock('@react-native-community/netinfo', () => ({ useNetInfo: jest.fn(() => ({ isConnected: true, @@ -312,9 +312,46 @@ jest.mock('../../../core/redux', () => ({ }, })); +jest.mock('../../../core/Engine', () => ({ + context: { + KeyringController: { + verifyPassword: jest.fn(), + submitPassword: jest.fn(), + }, + MultichainAccountService: { + init: jest.fn().mockResolvedValue(undefined), + }, + }, +})); + +jest.mock('../../../util/mnemonic', () => ({ + uint8ArrayToMnemonic: jest.fn(), +})); + +jest.mock('../../../multichain-accounts/AccountTreeInitService', () => ({ + initializeAccountTree: jest.fn().mockResolvedValue(undefined), +})); + const mockBackHandlerAddEventListener = jest.fn(); const mockBackHandlerRemoveEventListener = jest.fn(); +const createMockReduxStore = (stateOverrides?: RecursivePartial) => { + const defaultState = { + user: { existingUser: false }, + security: { allowLoginWithRememberMe: false }, + settings: { lockTime: -1 }, + ...(stateOverrides || {}), + } as RecursivePartial; + + return { + dispatch: jest.fn(), + getState: jest.fn(() => defaultState), + subscribe: jest.fn(), + replaceReducer: jest.fn(), + [Symbol.observable]: jest.fn(), + } as unknown as ReduxStore; +}; + describe('Login', () => { const mockTrace = jest.mocked(trace); const mockEndTrace = jest.mocked(endTrace); @@ -326,6 +363,7 @@ describe('Login', () => { const mockTrackVaultCorruption = jest.mocked(trackVaultCorruption); const mockDownloadStateLogs = jest.mocked(downloadStateLogs); const mockGetVaultFromBackup = jest.mocked(getVaultFromBackup); + const mockParseVaultValue = jest.mocked(parseVaultValue); const mockAlertAlert = jest.fn(); const originalAlert = Alert.alert; @@ -346,7 +384,6 @@ describe('Login', () => { BackHandler.addEventListener = mockBackHandlerAddEventListener; BackHandler.removeEventListener = mockBackHandlerRemoveEventListener; - // (Authentication.rehydrateSeedPhrase as jest.Mock).mockResolvedValue(true); mockUnlockWallet.mockResolvedValue(true); (passwordRequirementsMet as jest.Mock).mockReturnValue(true); mockComponentAuthenticationType.mockResolvedValue({ @@ -360,18 +397,19 @@ describe('Login', () => { capabilities: defaultCapabilities, isLoading: false, }); + mockCheckIsSeedlessPasswordOutdated.mockResolvedValue(false); (StorageWrapper.getItem as jest.Mock).mockResolvedValue(null); - mockBackHandlerAddEventListener.mockClear(); - mockBackHandlerRemoveEventListener.mockClear(); - BackHandler.addEventListener = mockBackHandlerAddEventListener; - BackHandler.removeEventListener = mockBackHandlerRemoveEventListener; + const mockStore = createMockReduxStore(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore); }); afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); Alert.alert = originalAlert; + const mockStore = createMockReduxStore(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore); }); it('renders matching snapshot', () => { @@ -381,7 +419,6 @@ describe('Login', () => { it('renders matching snapshot when password input is focused', () => { const { getByTestId, toJSON } = renderWithProvider(); - fireEvent.changeText( getByTestId(LoginViewSelectors.PASSWORD_INPUT), 'password', @@ -389,86 +426,60 @@ describe('Login', () => { expect(toJSON()).toMatchSnapshot(); }); - it('calls trace function for AuthenticateUser during non-OAuth login', async () => { - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - expect(mockTrace).toHaveBeenCalledTimes(2); - expect(mockTrace).toHaveBeenNthCalledWith(1, { - name: TraceName.LoginUserInteraction, - op: TraceOperation.Login, - }); - expect(mockTrace).toHaveBeenNthCalledWith( - 2, - { - name: TraceName.AuthenticateUser, - op: TraceOperation.Login, - }, - expect.any(Function), - ); - }); - - describe('Forgot Password', () => { - it('shows forgot password modal when reset wallet pressed', () => { - // Arrange + describe('Rendering', () => { + it('renders core login elements', () => { + mockRoute.mockReturnValue({ + params: { locked: false, oauthLoginSuccess: false }, + }); const { getByTestId } = renderWithProvider(); + expect(getByTestId(LoginViewSelectors.CONTAINER)).toBeOnTheScreen(); + expect(getByTestId(LoginViewSelectors.PASSWORD_INPUT)).toBeOnTheScreen(); + expect(getByTestId(LoginViewSelectors.LOGIN_BUTTON_ID)).toBeOnTheScreen(); + }); - // Act - fireEvent.press(getByTestId(LoginViewSelectors.RESET_WALLET)); - - // Assert - expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.MODAL.DELETE_WALLET, + it('renders MetaMask logo and fox animation', () => { + mockRoute.mockReturnValue({ + params: { locked: false, oauthLoginSuccess: false }, }); + const { getByTestId, queryByTestId, UNSAFE_root } = renderWithProvider( + , + ); + expect(getByTestId('fox-animation-mock')).toBeOnTheScreen(); + expect(getByTestId(LoginViewSelectors.RESET_WALLET)).toBeOnTheScreen(); + expect(queryByTestId(LoginViewSelectors.TITLE_ID)).toBeNull(); + expect(queryByTestId(LoginViewSelectors.OTHER_METHODS_BUTTON)).toBeNull(); + const images = UNSAFE_root.findAllByType(Image); + const hasMetaMaskLogo = images.some( + (img) => img.props.source === METAMASK_NAME, + ); + expect(hasMetaMaskLogo).toBe(true); }); - }); - describe('onLogin', () => { - it('login button exists and can be pressed', () => { - // Arrange + it('disables login button when password is empty', () => { const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - const loginButton = getByTestId(LoginViewSelectors.LOGIN_BUTTON_ID); - - // Act - fireEvent.changeText(passwordInput, 'testpassword123'); - fireEvent.press(loginButton); - - // Assert - expect(loginButton).toBeOnTheScreen(); + expect(getByTestId(LoginViewSelectors.LOGIN_BUTTON_ID)).toBeDisabled(); }); - it('password input accepts text', () => { - // Arrange + it('enables login button when password is entered', () => { const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - // Act - fireEvent.changeText(passwordInput, 'testpassword123'); - - // Assert - expect(passwordInput).toBeOnTheScreen(); + fireEvent.changeText( + getByTestId(LoginViewSelectors.PASSWORD_INPUT), + 'some-password', + ); + expect( + getByTestId(LoginViewSelectors.LOGIN_BUTTON_ID), + ).not.toBeDisabled(); }); }); - describe('Device authentication button visibility', () => { + describe('Device authentication', () => { beforeEach(() => { mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, + params: { locked: false, oauthLoginSuccess: false }, }); }); - it('renders device authentication button when capabilities allow device auth', async () => { + it('shows button when capabilities allow DEVICE_AUTHENTICATION', async () => { mockUseAuthCapabilities.mockReturnValue({ capabilities: { ...defaultCapabilities, @@ -476,38 +487,45 @@ describe('Login', () => { }, isLoading: false, }); - const { getByTestId } = renderWithProvider(); - await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); - expect( getByTestId(LoginViewSelectors.DEVICE_AUTHENTICATION_ICON), ).toBeOnTheScreen(); }); - it('hides device authentication button when device is locked', async () => { - mockRoute.mockReturnValue({ - params: { - locked: true, - oauthLoginSuccess: false, + it('shows button when capabilities allow BIOMETRIC', async () => { + mockUseAuthCapabilities.mockReturnValue({ + capabilities: { + ...defaultCapabilities, + authType: AUTHENTICATION_TYPE.BIOMETRIC, }, + isLoading: false, + }); + const { getByTestId } = renderWithProvider(); + await waitFor(() => { + expect( + getByTestId(LoginViewSelectors.DEVICE_AUTHENTICATION_ICON), + ).toBeOnTheScreen(); }); + }); + it('hides button when device is locked', async () => { + mockRoute.mockReturnValue({ + params: { locked: true, oauthLoginSuccess: false }, + }); const { queryByTestId } = renderWithProvider(); - await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); - expect( queryByTestId(LoginViewSelectors.DEVICE_AUTHENTICATION_ICON), ).toBeNull(); }); - it('hides device authentication button when capabilities do not support device auth', async () => { + it('hides button when capabilities do not support device auth', async () => { mockUseAuthCapabilities.mockReturnValue({ capabilities: { ...defaultCapabilities, @@ -515,45 +533,36 @@ describe('Login', () => { }, isLoading: false, }); - const { queryByTestId } = renderWithProvider(); - await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); - expect( queryByTestId(LoginViewSelectors.DEVICE_AUTHENTICATION_ICON), ).toBeNull(); }); }); - describe('Password Error Handling', () => { + describe('Password error handling', () => { beforeEach(() => { mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, + params: { locked: false, oauthLoginSuccess: false }, }); - mockTrackOnboarding.mockClear(); - }); - - afterEach(() => { - jest.clearAllMocks(); }); - it('displays invalid password error when decryption fails', async () => { - // Arrange - mockComponentAuthenticationType.mockResolvedValue({ - currentAuthType: 'password', - }); - mockUnlockWallet.mockRejectedValue(new Error('Decrypt failed')); - + it.each([ + ['Decrypt failed', 'generic decryption failure'], + [ + 'error:1e000065:Cipher functions:OPENSSL_internal:BAD_DECRYPT', + 'Android BAD_DECRYPT', + ], + ['error in DoCipher, status: 2', 'Android DoCipher'], + ['Password is incorrect, try again.', 'incorrect password'], + ])('displays invalid password error for %s', async (errorMessage) => { + mockUnlockWallet.mockRejectedValueOnce(new Error(errorMessage)); const { getByTestId } = renderWithProvider(); const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - // Act await act(async () => { fireEvent.changeText(passwordInput, 'valid-password123'); }); @@ -561,7 +570,6 @@ describe('Login', () => { fireEvent(passwordInput, 'submitEditing'); }); - // Assert const errorElement = getByTestId(LoginViewSelectors.PASSWORD_ERROR); expect(errorElement).toBeOnTheScreen(); expect(errorElement.props.children).toEqual( @@ -569,16 +577,10 @@ describe('Login', () => { ); }); - it('displays invalid password error for Android BAD_DECRYPT error', async () => { - mockComponentAuthenticationType.mockResolvedValue({ - currentAuthType: 'password', - }); - mockUnlockWallet.mockRejectedValue( - new Error( - 'error:1e000065:Cipher functions:OPENSSL_internal:BAD_DECRYPT', - ), + it('displays generic error message for unexpected errors', async () => { + mockUnlockWallet.mockRejectedValueOnce( + new Error('Some unexpected error'), ); - const { getByTestId } = renderWithProvider(); const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); @@ -591,50 +593,86 @@ describe('Login', () => { const errorElement = getByTestId(LoginViewSelectors.PASSWORD_ERROR); expect(errorElement).toBeOnTheScreen(); - expect(errorElement.props.children).toEqual( - strings('login.invalid_password'), - ); + expect(errorElement.props.children).toEqual('Some unexpected error'); }); - it('displays invalid password error for Android DoCipher error', async () => { - mockComponentAuthenticationType.mockResolvedValue({ - currentAuthType: 'password', - }); - mockUnlockWallet.mockRejectedValue( - new Error('error in DoCipher, status: 2'), - ); - - const { getByTestId } = renderWithProvider(); + it('clears error when user types after an error', async () => { + mockUnlockWallet.mockRejectedValueOnce(new Error('Decrypt failed')); + const { getByTestId, queryByTestId } = renderWithProvider(); const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); + fireEvent.changeText(passwordInput, 'wrong-password'); }); await act(async () => { fireEvent(passwordInput, 'submitEditing'); }); + expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeOnTheScreen(); - const errorElement = getByTestId(LoginViewSelectors.PASSWORD_ERROR); - expect(errorElement).toBeOnTheScreen(); - expect(errorElement.props.children).toEqual( - strings('login.invalid_password'), - ); + await act(async () => { + fireEvent.changeText(passwordInput, 'new-attempt'); + }); + expect( + queryByTestId(LoginViewSelectors.PASSWORD_ERROR), + ).not.toBeOnTheScreen(); }); + }); - it('displays invalid password error when password is incorrect', async () => { + describe('Vault corruption recovery', () => { + beforeEach(() => { mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: true, - }, + params: { locked: false, oauthLoginSuccess: false }, }); - mockComponentAuthenticationType.mockResolvedValue({ - currentAuthType: 'password', - oauth2Login: true, + mockGetAuthType.mockResolvedValueOnce({ + currentAuthType: AUTHENTICATION_TYPE.PASSCODE, }); - mockUnlockWallet.mockRejectedValue( - new Error('Password is incorrect, try again.'), - ); + }); + + it('navigates to restore wallet on valid vault backup', async () => { + jest.useRealTimers(); + try { + mockGetVaultFromBackup.mockResolvedValueOnce({ + success: true, + vault: 'mock-vault', + }); + mockParseVaultValue.mockResolvedValueOnce('mock-seed'); + mockUnlockWallet.mockRejectedValueOnce(new Error(VAULT_ERROR)); + mockComponentAuthenticationType.mockResolvedValueOnce({ + currentAuthType: AUTHENTICATION_TYPE.PASSCODE, + }); + + const { getByTestId } = renderWithProvider(); + const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); + + fireEvent.changeText(passwordInput, 'valid-password123'); + fireEvent(passwordInput, 'submitEditing'); + + await waitFor( + () => { + expect(mockReplace).toHaveBeenCalledWith( + Routes.VAULT_RECOVERY.RESTORE_WALLET, + expect.objectContaining({ + params: { + previousScreen: Routes.ONBOARDING.LOGIN, + }, + screen: Routes.VAULT_RECOVERY.RESTORE_WALLET, + }), + ); + }, + { timeout: 10000, interval: 100 }, + ); + } finally { + jest.useFakeTimers(); + } + }, 15000); + + it('shows error when vault seed cannot be parsed', async () => { + mockGetVaultFromBackup.mockResolvedValueOnce({ + success: true, + vault: 'mock-vault', + }); + mockParseVaultValue.mockResolvedValueOnce(undefined); + mockUnlockWallet.mockRejectedValueOnce(new Error(VAULT_ERROR)); const { getByTestId } = renderWithProvider(); const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); @@ -646,15 +684,31 @@ describe('Login', () => { fireEvent(passwordInput, 'submitEditing'); }); - const errorElement = getByTestId(LoginViewSelectors.PASSWORD_ERROR); - expect(errorElement).toBeOnTheScreen(); - expect(errorElement.props.children).toEqual( - strings('login.invalid_password'), - ); + expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); }); - it('displays generic error message for unexpected errors', async () => { - mockUnlockWallet.mockRejectedValue(new Error('Some unexpected error')); + it('shows error when password requirements are not met', async () => { + mockUnlockWallet.mockRejectedValueOnce(new Error(VAULT_ERROR)); + + const { getByTestId } = renderWithProvider(); + const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); + + await act(async () => { + fireEvent.changeText(passwordInput, '123'); + }); + await act(async () => { + fireEvent(passwordInput, 'submitEditing'); + }); + + expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); + }); + + it('shows error when backup has error', async () => { + mockGetVaultFromBackup.mockResolvedValueOnce({ + success: false, + error: 'Backup error', + }); + mockUnlockWallet.mockRejectedValueOnce(new Error(VAULT_ERROR)); const { getByTestId } = renderWithProvider(); const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); @@ -666,42 +720,69 @@ describe('Login', () => { fireEvent(passwordInput, 'submitEditing'); }); - const errorElement = getByTestId(LoginViewSelectors.PASSWORD_ERROR); - expect(errorElement).toBeOnTheScreen(); - expect(errorElement.props.children).toEqual('Some unexpected error'); + expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); }); - it('navigates to rehydrate screen when seedless onboarding error is detected', async () => { - mockUnlockWallet.mockRejectedValue( - new SeedlessOnboardingControllerError( - SeedlessOnboardingControllerErrorType.PasswordRecentlyUpdated, - 'Password was recently updated', - ), + it('triggers vault corruption flow on JSON parse error', async () => { + mockUnlockWallet.mockRejectedValueOnce( + new Error(JSON_PARSE_ERROR_UNEXPECTED_TOKEN), ); + mockGetVaultFromBackup.mockResolvedValueOnce({ + success: false, + error: 'corrupted', + }); const { getByTestId } = renderWithProvider(); const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); + fireEvent.changeText(passwordInput, 'some-password'); }); await act(async () => { fireEvent(passwordInput, 'submitEditing'); }); - expect(mockReplace).toHaveBeenCalledWith(Routes.ONBOARDING.REHYDRATE, { - isSeedlessPasswordOutdated: true, + expect(mockTrackVaultCorruption).toHaveBeenCalledWith( + JSON_PARSE_ERROR_UNEXPECTED_TOKEN, + expect.objectContaining({ + error_type: 'json_parse_error', + context: 'login_authentication', + }), + ); + expect(mockGetVaultFromBackup).toHaveBeenCalled(); + }); + + it('tracks vault corruption analytics at start of recovery', async () => { + mockUnlockWallet.mockRejectedValueOnce(new Error(VAULT_ERROR)); + mockGetVaultFromBackup.mockResolvedValueOnce({ + success: false, + error: 'no backup', + }); + + const { getByTestId } = renderWithProvider(); + const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); + + await act(async () => { + fireEvent.changeText(passwordInput, 'some-password'); }); + await act(async () => { + fireEvent(passwordInput, 'submitEditing'); + }); + + expect(mockTrackVaultCorruption).toHaveBeenCalledWith( + VAULT_ERROR, + expect.objectContaining({ + error_type: 'vault_corruption_handling', + context: 'vault_corruption_recovery_attempt', + }), + ); }); }); - describe('tryBiometric', () => { + describe('Biometric authentication', () => { beforeEach(() => { mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, + params: { locked: false, oauthLoginSuccess: false }, }); (passcodeType as jest.Mock).mockReturnValue('TouchID'); mockGetAuthType.mockResolvedValue({ @@ -711,11 +792,7 @@ describe('Login', () => { (StorageWrapper.getItem as jest.Mock).mockReset(); }); - afterEach(() => { - jest.clearAllMocks(); - }); - - it('authenticates with biometrics and navigates to home', async () => { + it('authenticates with biometrics successfully', async () => { mockUnlockWallet.mockResolvedValueOnce(true); (StorageWrapper.getItem as jest.Mock).mockReturnValueOnce(null); (passcodeType as jest.Mock).mockReturnValueOnce('device_passcode'); @@ -726,7 +803,6 @@ describe('Login', () => { mockUnlockWallet.mockResolvedValueOnce(true); const { getByTestId } = renderWithProvider(); - await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); @@ -734,7 +810,6 @@ describe('Login', () => { const biometryButton = getByTestId( LoginViewSelectors.DEVICE_AUTHENTICATION_ICON, ); - await act(async () => { fireEvent.press(biometryButton); }); @@ -742,18 +817,17 @@ describe('Login', () => { expect(mockUnlockWallet).toHaveBeenCalled(); }); - it('does not navigate when biometric authentication fails', async () => { - // Arrange + it('does not navigate when biometric auth fails', async () => { (passcodeType as jest.Mock).mockReturnValueOnce('device_passcode'); mockGetAuthType.mockResolvedValueOnce({ currentAuthType: AUTHENTICATION_TYPE.PASSCODE, availableBiometryType: 'TouchID', }); - const biometricError = new Error('Biometric authentication failed'); - mockUnlockWallet.mockRejectedValueOnce(biometricError); + mockUnlockWallet.mockRejectedValueOnce( + new Error('Biometric authentication failed'), + ); const { getByTestId } = renderWithProvider(); - await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); @@ -761,126 +835,170 @@ describe('Login', () => { const biometryButton = getByTestId( LoginViewSelectors.DEVICE_AUTHENTICATION_ICON, ); - - // Act await act(async () => { fireEvent.press(biometryButton); }); - // Assert expect(mockUnlockWallet).toHaveBeenCalled(); expect(mockReplace).not.toHaveBeenCalled(); }); - }); - describe('handleBackPress', () => { - it('registers and deregisters back handler on mount and unmount', () => { - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, - }); + it('keeps biometric button visible after failure', async () => { + jest.useRealTimers(); + try { + mockUnlockWallet.mockRejectedValueOnce( + new Error('Biometric auth failed'), + ); - const { unmount } = renderWithProvider(); - unmount(); + const { getByTestId } = renderWithProvider(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); - expect(mockBackHandlerAddEventListener).toHaveBeenCalledWith( - 'hardwareBackPress', - expect.any(Function), - ); - expect(mockBackHandlerRemoveEventListener).toHaveBeenCalledWith( - 'hardwareBackPress', - expect.any(Function), - ); + const biometryButton = getByTestId( + LoginViewSelectors.DEVICE_AUTHENTICATION_ICON, + ); + await act(async () => { + fireEvent.press(biometryButton); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + + expect( + getByTestId(LoginViewSelectors.DEVICE_AUTHENTICATION_ICON), + ).toBeOnTheScreen(); + } finally { + jest.useFakeTimers(); + } }); - it('locks app when back button is pressed', () => { - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, - }); + it('silently cancels DENY_PIN_ERROR_ANDROID without error UI', async () => { + jest.useRealTimers(); + try { + mockGetAuthType.mockReset(); + mockGetAuthType.mockResolvedValue({ + currentAuthType: 'password', + availableBiometryType: null, + }); + mockUseAuthCapabilities.mockReturnValue({ + capabilities: { + ...defaultCapabilities, + authType: AUTHENTICATION_TYPE.PASSWORD, + }, + isLoading: false, + }); + mockUnlockWallet.mockRejectedValueOnce( + new Error(DENY_PIN_ERROR_ANDROID), + ); - renderWithProvider(); + const { getByTestId, queryByTestId } = renderWithProvider(); - const handleBackPress = mockBackHandlerAddEventListener.mock.calls[0][1]; - const result = handleBackPress(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); - expect(mockLockApp).toHaveBeenCalled(); - expect(mockGoBack).not.toHaveBeenCalled(); - expect(result).toBe(false); + const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); + + fireEvent.changeText(passwordInput, 'some-password'); + fireEvent(passwordInput, 'submitEditing'); + + await waitFor(() => { + expect( + queryByTestId(LoginViewSelectors.PASSWORD_ERROR), + ).not.toBeOnTheScreen(); + }); + } finally { + jest.useFakeTimers(); + } }); - }); - describe('Conditional Rendering Based on OAuth Status', () => { - describe('Regular Login', () => { - beforeEach(() => { - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, + it('does not log error on Android biometric cancellation', async () => { + jest.useRealTimers(); + try { + mockGetAuthType.mockReset(); + mockGetAuthType.mockResolvedValue({ + currentAuthType: 'password', + availableBiometryType: null, + }); + mockUseAuthCapabilities.mockReturnValue({ + capabilities: { + ...defaultCapabilities, + authType: AUTHENTICATION_TYPE.PASSWORD, }, + isLoading: false, }); - }); - - it('renders static MetaMask logo and fox animation', () => { - // Arrange & Act - const { getByTestId, queryByTestId, UNSAFE_root } = renderWithProvider( - , + mockUnlockWallet.mockRejectedValueOnce( + new Error(DENY_PIN_ERROR_ANDROID), ); - // Assert - Fox animation is rendered - expect(getByTestId('fox-animation-mock')).toBeDefined(); + const { getByTestId } = renderWithProvider(); - // Assert - Regular login elements - expect(getByTestId(LoginViewSelectors.RESET_WALLET)).toBeDefined(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + mockLogger.error.mockClear(); - // Assert - OAuth elements are hidden - expect(queryByTestId(LoginViewSelectors.TITLE_ID)).toBeNull(); - expect( - queryByTestId(LoginViewSelectors.OTHER_METHODS_BUTTON), - ).toBeNull(); + const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); + fireEvent.changeText(passwordInput, 'valid-password123'); + fireEvent(passwordInput, 'submitEditing'); - // Assert - metaMask logo is rendered - const images = UNSAFE_root.findAllByType(Image); - const hasMetaMaskLogo = images.some( - (img) => img.props.source === METAMASK_NAME, - ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); - expect(hasMetaMaskLogo).toBe(true); - }); + expect(mockLogger.error).not.toHaveBeenCalled(); + } finally { + jest.useFakeTimers(); + } }); - describe('Common Elements', () => { - it('renders core login elements', () => { - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, + it('does not log error on iOS biometric cancellation', async () => { + jest.useRealTimers(); + try { + mockGetAuthType.mockReset(); + mockGetAuthType.mockResolvedValue({ + currentAuthType: 'password', + availableBiometryType: null, + }); + mockUseAuthCapabilities.mockReturnValue({ + capabilities: { + ...defaultCapabilities, + authType: AUTHENTICATION_TYPE.PASSWORD, }, + isLoading: false, }); + mockUnlockWallet.mockRejectedValueOnce( + new Error(UNLOCK_WALLET_ERROR_MESSAGES.IOS_USER_CANCELLED_BIOMETRICS), + ); const { getByTestId } = renderWithProvider(); - expect(getByTestId(LoginViewSelectors.CONTAINER)).toBeDefined(); - expect(getByTestId(LoginViewSelectors.PASSWORD_INPUT)).toBeDefined(); - expect(getByTestId(LoginViewSelectors.LOGIN_BUTTON_ID)).toBeDefined(); - }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + mockLogger.error.mockClear(); + + const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); + fireEvent.changeText(passwordInput, 'valid-password123'); + fireEvent(passwordInput, 'submitEditing'); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + + expect(mockLogger.error).not.toHaveBeenCalled(); + } finally { + jest.useFakeTimers(); + } }); }); - describe('Biometric fallback alert after seedless password sync', () => { + describe('Seedless password sync (biometric fallback alert)', () => { beforeEach(() => { mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, + params: { locked: false, oauthLoginSuccess: false }, }); - // Isolate auth mocks so previous tests cannot leave stale implementations - // (e.g. mockRejectedValue or mockResolvedValueOnce from other describe blocks). mockUnlockWallet.mockReset(); mockUnlockWallet.mockResolvedValue(true); mockGetAuthType.mockReset(); @@ -893,7 +1011,6 @@ describe('Login', () => { }); it('checks seedless password status and calls getAuthType when outdated', async () => { - // Arrange - device supports biometrics but auth fell back to PASSWORD mockCheckIsSeedlessPasswordOutdated.mockResolvedValue(true); mockGetAuthType.mockResolvedValue({ currentAuthType: 'password', @@ -903,8 +1020,6 @@ describe('Login', () => { const { getByTestId } = renderWithProvider(); const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - // Act - commit password, then submit and wait for the trace callback inside act - // so the async unlock flow (checkIsSeedlessPasswordOutdated -> unlockWallet -> getAuthType) completes before we assert. await act(async () => { fireEvent.changeText(passwordInput, 'valid-password123'); }); @@ -921,7 +1036,6 @@ describe('Login', () => { } }); - // Assert expect(mockCheckIsSeedlessPasswordOutdated).toHaveBeenCalledWith(false); expect(mockUnlockWallet).toHaveBeenCalledWith({ password: 'valid-password123', @@ -929,38 +1043,30 @@ describe('Login', () => { expect(mockGetAuthType).toHaveBeenCalled(); }); - it('does not call getAuthType after unlock when seedless password is not outdated', async () => { - // Arrange + it('does not call getAuthType when seedless password is not outdated', async () => { mockCheckIsSeedlessPasswordOutdated.mockResolvedValue(false); const { getByTestId } = renderWithProvider(); - - // Wait for mount effects that call getAuthType await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); const getAuthTypeCallCountAfterMount = mockGetAuthType.mock.calls.length; const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - // Act fireEvent.changeText(passwordInput, 'valid-password123'); await act(async () => { fireEvent(passwordInput, 'submitEditing'); }); - // Assert await waitFor(() => { expect(mockUnlockWallet).toHaveBeenCalled(); }); - // getAuthType should NOT be called extra times after unlock expect(mockGetAuthType.mock.calls.length).toBe( getAuthTypeCallCountAfterMount, ); }); - it('does not enter alert branch when auth type is BIOMETRIC even if seedless password is outdated', async () => { - // Arrange + it('skips alert branch for BIOMETRIC auth type', async () => { mockCheckIsSeedlessPasswordOutdated.mockResolvedValue(true); mockGetAuthType.mockResolvedValue({ currentAuthType: 'biometrics', @@ -969,14 +1075,11 @@ describe('Login', () => { const { getByTestId } = renderWithProvider(); const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - // Act fireEvent.changeText(passwordInput, 'valid-password123'); await act(async () => { fireEvent(passwordInput, 'submitEditing'); }); - // Assert - flow reaches getAuthType but BIOMETRIC type skips the alert branch await waitFor(() => { expect(mockUnlockWallet).toHaveBeenCalled(); }); @@ -985,8 +1088,7 @@ describe('Login', () => { }); }); - it('does not enter alert branch when device has no biometry support', async () => { - // Arrange - auth type is PASSWORD but device doesn't support biometrics + it('skips alert branch when device has no biometry support', async () => { mockCheckIsSeedlessPasswordOutdated.mockResolvedValue(true); mockGetAuthType.mockResolvedValue({ currentAuthType: 'password', @@ -995,14 +1097,11 @@ describe('Login', () => { const { getByTestId } = renderWithProvider(); const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - // Act fireEvent.changeText(passwordInput, 'valid-password123'); await act(async () => { fireEvent(passwordInput, 'submitEditing'); }); - // Assert - flow completes normally (no alert needed since no biometry) await waitFor(() => { expect(mockUnlockWallet).toHaveBeenCalled(); }); @@ -1012,60 +1111,92 @@ describe('Login', () => { }); }); - describe('KeyboardAwareScrollView Configuration', () => { - let originalPlatform: string; - - beforeEach(() => { - originalPlatform = Platform.OS; + describe('Navigation and lifecycle', () => { + it('navigates to forgot password modal when reset wallet is pressed', () => { + const { getByTestId } = renderWithProvider(); + fireEvent.press(getByTestId(LoginViewSelectors.RESET_WALLET)); + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.MODAL.DELETE_WALLET, + }); }); - afterEach(() => { - Object.defineProperty(Platform, 'OS', { - value: originalPlatform, - writable: true, + it('navigates to rehydrate on seedless onboarding error', async () => { + mockUnlockWallet.mockRejectedValueOnce( + new SeedlessOnboardingControllerError( + SeedlessOnboardingControllerErrorType.PasswordRecentlyUpdated, + 'Password was recently updated', + ), + ); + + const { getByTestId } = renderWithProvider(); + const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); + + await act(async () => { + fireEvent.changeText(passwordInput, 'valid-password123'); + }); + await act(async () => { + fireEvent(passwordInput, 'submitEditing'); }); - }); - it('sets extraScrollHeight to 50 on Android', () => { - Object.defineProperty(Platform, 'OS', { - value: 'android', - writable: true, + expect(mockReplace).toHaveBeenCalledWith(Routes.ONBOARDING.REHYDRATE, { + isSeedlessPasswordOutdated: true, }); + }); + + it('registers and deregisters back handler on mount/unmount', () => { mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, + params: { locked: false, oauthLoginSuccess: false }, }); + const { unmount } = renderWithProvider(); + unmount(); - const { UNSAFE_root } = renderWithProvider(); - - const scrollView = UNSAFE_root.findByProps({ extraScrollHeight: 50 }); - expect(scrollView).toBeDefined(); - expect(scrollView.props.extraScrollHeight).toBe(50); + expect(mockBackHandlerAddEventListener).toHaveBeenCalledWith( + 'hardwareBackPress', + expect.any(Function), + ); + expect(mockBackHandlerRemoveEventListener).toHaveBeenCalledWith( + 'hardwareBackPress', + expect.any(Function), + ); }); - it('sets extraScrollHeight to 0 on iOS', () => { - Object.defineProperty(Platform, 'OS', { value: 'ios', writable: true }); + it('locks app on back button press', () => { mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, + params: { locked: false, oauthLoginSuccess: false }, }); + renderWithProvider(); - const { UNSAFE_root } = renderWithProvider(); + const handleBackPress = mockBackHandlerAddEventListener.mock.calls[0][1]; + const result = handleBackPress(); - const scrollView = UNSAFE_root.findByProps({ extraScrollHeight: 0 }); - expect(scrollView).toBeDefined(); - expect(scrollView.props.extraScrollHeight).toBe(0); + expect(mockLockApp).toHaveBeenCalled(); + expect(mockGoBack).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it('shows security alert when passcode is not set', async () => { + mockUnlockWallet.mockRejectedValueOnce(new Error(PASSCODE_NOT_SET_ERROR)); + + const { getByTestId } = renderWithProvider(); + const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); + + await act(async () => { + fireEvent.changeText(passwordInput, 'some-password'); + }); + await act(async () => { + fireEvent(passwordInput, 'submitEditing'); + }); + + expect(mockAlertAlert).toHaveBeenCalledWith( + strings('login.security_alert_title'), + strings('login.security_alert_desc'), + ); }); }); - describe('Analytics Tracking', () => { + describe('Analytics tracking', () => { it('tracks LOGIN_SCREEN_VIEWED on mount', () => { renderWithProvider(); - expect(mockTrackOnboarding).toHaveBeenCalledWith( MetaMetricsEvents.LOGIN_SCREEN_VIEWED, expect.any(Function), @@ -1074,24 +1205,20 @@ describe('Login', () => { it('tracks FORGOT_PASSWORD_CLICKED when reset wallet is pressed', () => { const { getByTestId } = renderWithProvider(); - fireEvent.press(getByTestId(LoginViewSelectors.RESET_WALLET)); - expect(mockTrackOnboarding).toHaveBeenCalledWith( MetaMetricsEvents.FORGOT_PASSWORD_CLICKED, expect.any(Function), ); }); - it('tracks LOGIN_DOWNLOAD_LOGS and calls downloadStateLogs on long press', () => { + it('tracks LOGIN_DOWNLOAD_LOGS on long press', () => { const { getByTestId } = renderWithProvider(); const foxAnimationMock = getByTestId('fox-animation-mock'); const foxWrapper = foxAnimationMock.parent; - if (!foxWrapper) { throw new Error('Fox animation wrapper not found'); } - fireEvent(foxWrapper, 'longPress'); expect(mockTrackOnboarding).toHaveBeenCalledWith( @@ -1106,7 +1233,7 @@ describe('Login', () => { it('calls trackErrorAsAnalytics on wrong password error', async () => { const errorMsg = 'Decrypt failed'; - mockUnlockWallet.mockReset().mockRejectedValue(new Error(errorMsg)); + mockUnlockWallet.mockReset().mockRejectedValueOnce(new Error(errorMsg)); const { getByTestId } = renderWithProvider(); const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); @@ -1125,197 +1252,71 @@ describe('Login', () => { }); }); - describe('PASSCODE_NOT_SET_ERROR', () => { - it('shows security alert when passcode is not set', async () => { - mockUnlockWallet.mockRejectedValue(new Error(PASSCODE_NOT_SET_ERROR)); - + describe('Trace integration', () => { + it('calls trace for AuthenticateUser during login', async () => { const { getByTestId } = renderWithProvider(); const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); await act(async () => { - fireEvent.changeText(passwordInput, 'some-password'); + fireEvent.changeText(passwordInput, 'valid-password123'); }); await act(async () => { fireEvent(passwordInput, 'submitEditing'); }); - expect(mockAlertAlert).toHaveBeenCalledWith( - strings('login.security_alert_title'), - strings('login.security_alert_desc'), - ); - }); - }); - - describe('JSON_PARSE_ERROR vault corruption', () => { - it('triggers vault corruption flow on JSON parse error', async () => { - mockUnlockWallet.mockRejectedValue( - new Error(JSON_PARSE_ERROR_UNEXPECTED_TOKEN), - ); - mockGetVaultFromBackup.mockResolvedValueOnce({ - success: false, - error: 'corrupted', - }); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, 'some-password'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); + expect(mockTrace).toHaveBeenCalledTimes(2); + expect(mockTrace).toHaveBeenNthCalledWith(1, { + name: TraceName.LoginUserInteraction, + op: TraceOperation.Login, }); - - expect(mockTrackVaultCorruption).toHaveBeenCalledWith( - JSON_PARSE_ERROR_UNEXPECTED_TOKEN, - expect.objectContaining({ - error_type: 'json_parse_error', - context: 'login_authentication', - }), + expect(mockTrace).toHaveBeenNthCalledWith( + 2, + { + name: TraceName.AuthenticateUser, + op: TraceOperation.Login, + }, + expect.any(Function), ); - expect(mockGetVaultFromBackup).toHaveBeenCalled(); }); }); - describe('trackVaultCorruption in handleVaultCorruption', () => { - it('tracks vault corruption at the start of recovery', async () => { - mockUnlockWallet.mockRejectedValue(new Error(VAULT_ERROR)); - mockGetVaultFromBackup.mockResolvedValueOnce({ - success: false, - error: 'no backup', - }); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, 'some-password'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); + describe('Platform configuration', () => { + let originalPlatform: string; - expect(mockTrackVaultCorruption).toHaveBeenCalledWith( - VAULT_ERROR, - expect.objectContaining({ - error_type: 'vault_corruption_handling', - context: 'vault_corruption_recovery_attempt', - }), - ); + beforeEach(() => { + originalPlatform = Platform.OS; }); - }); - - describe('Password change clears error', () => { - it('clears error message when user types after an error', async () => { - mockUnlockWallet.mockRejectedValue(new Error('Decrypt failed')); - - const { getByTestId, queryByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, 'wrong-password'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeOnTheScreen(); - await act(async () => { - fireEvent.changeText(passwordInput, 'new-attempt'); + afterEach(() => { + Object.defineProperty(Platform, 'OS', { + value: originalPlatform, + writable: true, }); - - expect( - queryByTestId(LoginViewSelectors.PASSWORD_ERROR), - ).not.toBeOnTheScreen(); }); - }); - - describe('DENY_PIN_ERROR_ANDROID cancellation', () => { - it('silently cancels without displaying an error', async () => { - mockUnlockWallet.mockRejectedValue(new Error(DENY_PIN_ERROR_ANDROID)); - - const { getByTestId, queryByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - await act(async () => { - fireEvent.changeText(passwordInput, 'some-password'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); + it('sets extraScrollHeight to 50 on Android', () => { + Object.defineProperty(Platform, 'OS', { + value: 'android', + writable: true, }); - - expect( - queryByTestId(LoginViewSelectors.PASSWORD_ERROR), - ).not.toBeOnTheScreen(); - }); - }); - - describe('Login button disabled state', () => { - it('renders login button as disabled when password is empty', () => { - const { getByTestId } = renderWithProvider(); - const loginButton = getByTestId(LoginViewSelectors.LOGIN_BUTTON_ID); - - expect(loginButton).toHaveProp('disabled', true); - }); - - it('renders login button as enabled when password is entered', () => { - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - fireEvent.changeText(passwordInput, 'some-password'); - - const loginButton = getByTestId(LoginViewSelectors.LOGIN_BUTTON_ID); - expect(loginButton).toHaveProp('disabled', false); - }); - }); - - describe('Biometric error re-enables credentials', () => { - beforeEach(() => { mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, + params: { locked: false, oauthLoginSuccess: false }, }); - (passcodeType as jest.Mock).mockReturnValue('TouchID'); - mockGetAuthType.mockResolvedValue({ - currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC, - availableBiometryType: 'TouchID', - }); - (StorageWrapper.getItem as jest.Mock).mockReset(); + const { UNSAFE_root } = renderWithProvider(); + const scrollView = UNSAFE_root.findByProps({ extraScrollHeight: 50 }); + expect(scrollView).toBeDefined(); + expect(scrollView.props.extraScrollHeight).toBe(50); }); - it('keeps biometric button visible after biometric unlock fails', async () => { - mockUnlockWallet.mockRejectedValueOnce( - new Error('Biometric auth failed'), - ); - - const { getByTestId } = renderWithProvider(); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - const biometryButton = getByTestId( - LoginViewSelectors.DEVICE_AUTHENTICATION_ICON, - ); - - await act(async () => { - fireEvent.press(biometryButton); + it('sets extraScrollHeight to 0 on iOS', () => { + Object.defineProperty(Platform, 'OS', { value: 'ios', writable: true }); + mockRoute.mockReturnValue({ + params: { locked: false, oauthLoginSuccess: false }, }); - - expect( - getByTestId(LoginViewSelectors.DEVICE_AUTHENTICATION_ICON), - ).toBeOnTheScreen(); + const { UNSAFE_root } = renderWithProvider(); + const scrollView = UNSAFE_root.findByProps({ extraScrollHeight: 0 }); + expect(scrollView).toBeDefined(); + expect(scrollView.props.extraScrollHeight).toBe(0); }); }); }); - -// it('should navigate back and reset OAuth state when using other methods', async () => { -// mockRoute.mockReturnValue({ -// params: { -// locked: false, -// oauthLoginSuccess: true, -// }, -// }); diff --git a/app/components/Views/Login/index.tsx b/app/components/Views/Login/index.tsx index 4f1f15abb6b..369adc8ec51 100644 --- a/app/components/Views/Login/index.tsx +++ b/app/components/Views/Login/index.tsx @@ -1,32 +1,43 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; +import React, { + useEffect, + useRef, + useState, + useCallback, + useContext, +} from 'react'; import { Alert, - View, SafeAreaView, BackHandler, TouchableOpacity, - TextInput, Platform, Image, + StatusBar, } from 'react-native'; import METAMASK_NAME from '../../../images/branding/metamask-name.png'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { - Text, - TextVariant as DSTextVariant, - TextColor as DSTextColor, + Box, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + TextVariant, FontWeight, + TextField, + TextFieldSize, + Button, + ButtonSize, + ButtonVariant, + Text, + TextColor, } from '@metamask/design-system-react-native'; -import { TextVariant } from '../../../component-library/components/Texts/Text'; +import { ThemeContext } from '../../../util/theme'; +import { TextVariant as DSTextVariant } from '../../../component-library/components/Texts/Text'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; import { KeyboardController, AndroidSoftInputModes, } from 'react-native-keyboard-controller'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../component-library/components/Buttons/Button'; import { strings } from '../../../../locales/i18n'; import FadeOutOverlay from '../../UI/FadeOutOverlay'; import { @@ -39,7 +50,6 @@ import { DeviceAuthenticationButton } from '../../UI/DeviceAuthenticationButton' import Logger from '../../../util/Logger'; import Routes from '../../../constants/navigation/Routes'; import ErrorBoundary from '../ErrorBoundary'; - import { createRestoreWalletNavDetailsNested } from '../RestoreWallet/RestoreWallet'; import { parseVaultValue } from '../../../util/validators'; import { getVaultFromBackup } from '../../../core/BackupVault'; @@ -55,7 +65,6 @@ import { TraceOperation, endTrace, } from '../../../util/trace'; -import TextField from '../../../component-library/components/Form/TextField'; import HelpText, { HelpTextSeverity, } from '../../../component-library/components/Form/HelpText'; @@ -75,8 +84,6 @@ import { useNavigation, useRoute, } from '@react-navigation/native'; -import { useStyles } from '../../../component-library/hooks/useStyles'; -import stylesheet from './styles'; import ReduxService from '../../../core/redux'; import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding'; import type { AnalyticsTrackingEvent } from '../../../util/analytics/AnalyticsEventBuilder'; @@ -88,10 +95,6 @@ import { SeedlessOnboardingControllerError } from '../../../core/Engine/controll import useAuthCapabilities from '../../../core/Authentication/hooks/useAuthCapabilities'; import AUTHENTICATION_TYPE from '../../../constants/userProperties'; -// In android, having {} will cause the styles to update state -// using a constant will prevent this -const EmptyRecordConstant = {}; - interface LoginRouteParams { locked: boolean; } @@ -104,7 +107,7 @@ interface LoginProps { * View where returning users can authenticate */ const Login: React.FC = ({ saveOnboardingEvent }) => { - const fieldRef = useRef(null); + const fieldRef = useRef | null>(null); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); @@ -115,10 +118,8 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { const navigation = useNavigation(); const route = useRoute>(); - const { - styles, - theme: { themeAppearance }, - } = useStyles(stylesheet, EmptyRecordConstant); + const tw = useTailwind(); + const { colors, themeAppearance } = useContext(ThemeContext); const { unlockWallet, @@ -354,8 +355,8 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { await unlockWallet(); }, ); - } catch (error) { - await handleLoginError(error as Error); + } catch (loginerror) { + await handleLoginError(loginerror as Error); } finally { setLoading(false); } @@ -393,26 +394,50 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { return ( - + - + - + = ({ saveOnboardingEvent }) => { keyboardAppearance={themeAppearance} isError={!!error} isDisabled={loading} + size={TextFieldSize.Lg} /> - - - + + + {!!error && ( {error} )} - - - + + + + - {strings('login.forgot_password')} - - } - isDisabled={loading} - size={ButtonSize.Lg} - /> - - + onPress={toggleWarningModal} + disabled={loading} + style={tw.style('my-0 self-center pt-4')} + > + + {strings('login.forgot_password')} + + + + {!isE2E && ( ; - -const mockGetAuthType = jest.fn(); -const mockComponentAuthenticationType = jest.fn(); -const mockUnlockWallet = jest.fn(); -const mockLockApp = jest.fn(); -const mockReauthenticate = jest.fn(); -const mockRevealSRP = jest.fn(); -const mockRevealPrivateKey = jest.fn(); -const mockCheckIsSeedlessPasswordOutdated = jest.fn(); -const mockUpdateAuthPreference = jest.fn(); - -jest.mock('../../../core/Authentication/hooks/useAuthentication', () => ({ - __esModule: true, - default: () => ({ - getAuthType: mockGetAuthType, - componentAuthenticationType: mockComponentAuthenticationType, - unlockWallet: mockUnlockWallet, - lockApp: mockLockApp, - reauthenticate: mockReauthenticate, - revealSRP: mockRevealSRP, - revealPrivateKey: mockRevealPrivateKey, - checkIsSeedlessPasswordOutdated: mockCheckIsSeedlessPasswordOutdated, - updateAuthPreference: mockUpdateAuthPreference, - }), -})); - -const defaultCapabilities: AuthCapabilities = { - authType: AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION, - isBiometricsAvailable: true, - passcodeAvailable: true, - authLabel: 'Face ID', - authDescription: - 'Use your device’s biometrics or passcode to unlock MetaMask.', - authIcon: IconName.FaceId, - osAuthEnabled: false, - allowLoginWithRememberMe: false, - deviceAuthRequiresSettings: false, -}; - -const mockUseAuthCapabilities = jest.fn(() => ({ - capabilities: defaultCapabilities, - isLoading: false, -})); - -jest.mock('../../../core/Authentication/hooks/useAuthCapabilities', () => ({ - __esModule: true, - default: () => mockUseAuthCapabilities(), -})); - -const mockNavigate = jest.fn(); -const mockReplace = jest.fn(); -const mockReset = jest.fn(); -const mockGoBack = jest.fn(); - -const mockRoute = jest.fn(); - -jest.mock('../../../core/Engine', () => ({ - context: { - KeyringController: { - verifyPassword: jest.fn(), - submitPassword: jest.fn(), - }, - MultichainAccountService: { - init: jest.fn().mockResolvedValue(undefined), - }, - }, -})); - -jest.mock('../../../util/mnemonic', () => ({ - uint8ArrayToMnemonic: jest.fn(), -})); - -jest.mock('@react-navigation/native', () => { - const actualNav = jest.requireActual('@react-navigation/native'); - return { - ...actualNav, - useNavigation: () => ({ - navigate: mockNavigate, - replace: mockReplace, - reset: mockReset, - goBack: mockGoBack, - dispatch: jest.fn((action) => { - if (action.type === 'REPLACE') { - mockReplace(action.payload.name, action.payload.params); - } - }), - }), - useRoute: () => mockRoute(), - }; -}); -jest.mock('../../../util/authentication', () => ({ - updateAuthTypeStorageFlags: jest.fn(), - passcodeType: jest.fn().mockReturnValue('passcode_ios'), -})); - -jest.mock('../../../util/validators', () => ({ - parseVaultValue: jest.fn(), -})); - -jest.mock('../../../core/BackupVault', () => ({ - getVaultFromBackup: jest.fn(), -})); - -const mockGetVaultFromBackup = getVaultFromBackup as jest.Mock; - -const mockParseVaultValue = parseVaultValue as jest.Mock; - -const mockEndTrace = jest.fn(); - -jest.mock('../../../util/trace', () => { - const actualTrace = jest.requireActual('../../../util/trace'); - return { - ...actualTrace, - endTrace: (request: EndTraceRequest) => mockEndTrace(request), - }; -}); - -jest.mock('../../../multichain-accounts/AccountTreeInitService', () => ({ - initializeAccountTree: jest.fn().mockResolvedValue(undefined), -})); - -// Mock useNetInfo hook -jest.mock('@react-native-community/netinfo', () => ({ - useNetInfo: jest.fn(() => ({ - isConnected: true, - isInternetReachable: true, - type: 'wifi', - details: { - isConnectionExpensive: false, - }, - })), -})); - -jest.mock('../../UI/ScreenshotDeterrent', () => ({ - ScreenshotDeterrent: () => null, -})); - -describe('Login test suite 2', () => { - const createMockReduxStore = ( - stateOverrides?: RecursivePartial, - ) => { - const defaultState = { - user: { - existingUser: false, - }, - security: { - allowLoginWithRememberMe: false, - }, - settings: { - lockTime: -1, - }, - ...(stateOverrides || {}), - } as RecursivePartial; - - return { - dispatch: jest.fn(), - getState: jest.fn(() => defaultState), - subscribe: jest.fn(), - replaceReducer: jest.fn(), - [Symbol.observable]: jest.fn(), - } as unknown as ReduxStore; - }; - - beforeAll(() => { - jest.useFakeTimers(); - }); - - beforeEach(() => { - // Mock Redux store for all tests - const mockStore = createMockReduxStore(); - jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore); - // Default mock for checkIsSeedlessPasswordOutdated - returns false (password not outdated) - mockCheckIsSeedlessPasswordOutdated.mockResolvedValue(false); - mockRoute.mockReturnValue({ - params: { locked: false, oauthLoginSuccess: false }, - }); - mockUseAuthCapabilities.mockReturnValue({ - capabilities: defaultCapabilities, - isLoading: false, - }); - }); - - afterEach(() => { - act(() => { - jest.runOnlyPendingTimers(); - }); - jest.clearAllTimers(); - jest.clearAllMocks(); - // Restore Redux store mock after clearing mocks - const mockStore = createMockReduxStore(); - jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore); - }); - - afterAll(() => { - jest.clearAllTimers(); - jest.useRealTimers(); - }); - - describe('handleVaultCorruption', () => { - beforeEach(() => { - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, - }); - mockGetAuthType.mockResolvedValueOnce({ - currentAuthType: AUTHENTICATION_TYPE.PASSCODE, - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('navigates to restore wallet screen when vault is corrupted and password is valid', async () => { - mockGetVaultFromBackup.mockResolvedValueOnce({ - success: true, - vault: 'mock-vault', - }); - mockParseVaultValue.mockResolvedValueOnce('mock-seed'); - - mockUnlockWallet.mockRejectedValue(new Error(VAULT_ERROR)); - - mockComponentAuthenticationType.mockResolvedValueOnce({ - currentAuthType: AUTHENTICATION_TYPE.PASSCODE, - }); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - expect(mockReplace).toHaveBeenCalledWith( - Routes.VAULT_RECOVERY.RESTORE_WALLET, - expect.objectContaining({ - params: { - previousScreen: Routes.ONBOARDING.LOGIN, - }, - screen: Routes.VAULT_RECOVERY.RESTORE_WALLET, - }), - ); - }); - - it('show error for invalid password during vault corruption', async () => { - mockGetVaultFromBackup.mockResolvedValueOnce({ - success: true, - vault: 'mock-vault', - }); - mockParseVaultValue.mockResolvedValueOnce(undefined); - - mockUnlockWallet.mockRejectedValue(new Error(VAULT_ERROR)); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, 'invalid-password'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); - }); - - it('handle vault corruption when password requirements are not met', async () => { - mockUnlockWallet.mockRejectedValue(new Error(VAULT_ERROR)); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, '123'); // Too short password - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); - }); - - it('handle vault corruption when backup has error', async () => { - mockGetVaultFromBackup.mockResolvedValueOnce({ - success: false, - error: 'Backup error', - }); - mockUnlockWallet.mockRejectedValue(new Error(VAULT_ERROR)); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); - }); - - it('handle vault corruption when storePassword fails', async () => { - mockUnlockWallet.mockRejectedValue(new Error(VAULT_ERROR)); - - // Mock getVaultFromBackup to return an error to trigger error handling - mockGetVaultFromBackup.mockResolvedValueOnce({ - success: false, - error: 'Store password failed', - }); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - await waitFor(() => { - expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); - }); - }); - - it('handle vault corruption when vault seed cannot be parsed', async () => { - mockGetVaultFromBackup.mockResolvedValueOnce({ - success: true, - vault: 'mock-vault', - }); - mockParseVaultValue.mockResolvedValueOnce(undefined); - - mockUnlockWallet.mockRejectedValue(new Error(VAULT_ERROR)); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); - }); - }); - - describe('Global Password changed', () => { - afterEach(() => { - jest.clearAllTimers(); - }); - - it('shows device authentication button when capabilities allow device auth', async () => { - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, - }); - mockUseAuthCapabilities.mockReturnValue({ - capabilities: { - ...defaultCapabilities, - authType: AUTHENTICATION_TYPE.BIOMETRIC, - }, - isLoading: false, - }); - - const { getByTestId } = renderWithProvider(); - - await waitFor(() => { - expect( - getByTestId(LoginViewSelectors.DEVICE_AUTHENTICATION_ICON), - ).toBeOnTheScreen(); - }); - }); - }); - - describe('biometric cancellation', () => { - it('does not log error when Android biometric auth is cancelled', async () => { - // Arrange - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, - }); - mockUnlockWallet.mockRejectedValue(new Error('Cancel')); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - // Act - await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - // Assert - expect(mockLogger.error).not.toHaveBeenCalled(); - }); - - it('does not log error when iOS biometric auth is cancelled', async () => { - // Arrange - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, - }); - mockUnlockWallet.mockRejectedValue( - new Error(UNLOCK_WALLET_ERROR_MESSAGES.IOS_USER_CANCELLED_BIOMETRICS), - ); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - // Act - await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - // Assert - expect(mockLogger.error).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/app/components/Views/Login/styles.ts b/app/components/Views/Login/styles.ts deleted file mode 100644 index 50f1b1f3d1a..00000000000 --- a/app/components/Views/Login/styles.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { Theme } from '../../../util/theme/models'; -import { Platform, StatusBar, StyleSheet } from 'react-native'; -import Device from '../../../util/device'; -import { fontStyles } from '../../../styles/common'; -const deviceHeight = Device.getDeviceHeight(); -const breakPoint = deviceHeight < 700; - -const styleSheet = (params: { theme: Theme }) => { - const { - theme: { colors }, - } = params; - - return StyleSheet.create({ - mainWrapper: { - paddingTop: Platform.select({ - android: StatusBar.currentHeight ?? 0, - default: 0, - }), - flex: 1, - }, - wrapper: { - flex: 1, - }, - container: { - flex: 1, - justifyContent: 'flex-start', - alignItems: 'center', - flexDirection: 'column', - width: '100%', - paddingHorizontal: 24, - paddingTop: 80, - }, - scrollContentContainer: { - flex: 1, - }, - foxAnimationWrapper: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - height: 200, - }, - foxWrapper: { - justifyContent: 'center', - alignSelf: 'center', - width: Device.isIos() ? 175 : 150, - height: Device.isIos() ? 175 : 150, - marginTop: 48, - }, - image: { - alignSelf: 'center', - width: Device.isIos() ? 175 : 150, - height: Device.isIos() ? 175 : 150, - }, - title: { - textAlign: 'center', - marginVertical: 24, - }, - field: { - flexDirection: 'column', - width: '100%', - rowGap: 8, - marginTop: 80, - justifyContent: 'flex-start', - marginBottom: 8, - }, - ctaWrapper: { - width: '100%', - flexDirection: 'column', - alignItems: 'center', - }, - - footer: { - marginTop: 32, - alignItems: 'center', - }, - unlockButton: { - marginTop: 4, - }, - metamaskName: { - width: 160, - height: 80, - alignSelf: 'center', - marginBottom: 60, - marginTop: 60, - tintColor: colors.icon.default, - }, - goBack: { - marginVertical: 0, - alignSelf: 'center', - paddingTop: 16, - }, - biometrics: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 20, - marginBottom: 30, - }, - biometryLabel: { - flex: 1, - fontSize: 16, - color: colors.text.default, - ...fontStyles.normal, - }, - biometrySwitch: { - flex: 0, - }, - cant: { - width: 280, - alignSelf: 'center', - justifyContent: 'center', - textAlign: 'center', - }, - areYouSure: { - width: '100%', - padding: breakPoint ? 16 : 24, - justifyContent: 'center', - alignSelf: 'center', - }, - heading: { - marginHorizontal: 6, - color: colors.text.default, - ...fontStyles.bold, - fontSize: 20, - textAlign: 'center', - lineHeight: breakPoint ? 24 : 26, - }, - red: { - marginHorizontal: 24, - color: colors.error.default, - }, - warningText: { - ...fontStyles.normal, - textAlign: 'center', - fontSize: 14, - lineHeight: breakPoint ? 18 : 22, - color: colors.text.default, - marginTop: 20, - }, - warningIcon: { - alignSelf: 'center', - color: colors.error.default, - marginVertical: 10, - }, - bold: { - ...fontStyles.bold, - }, - delete: { - marginBottom: 20, - }, - deleteWarningMsg: { - ...fontStyles.normal, - fontSize: 16, - lineHeight: 20, - marginTop: 10, - color: colors.error.default, - }, - oauthContentWrapper: { - width: '100%', - alignItems: 'center', - marginTop: Platform.select({ - ios: -200, - android: -180, - }), - }, - input: { - width: '100%', - }, - labelContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - hintText: { - textAlign: 'left', - }, - helperTextContainer: { - flexDirection: 'row', - alignItems: 'flex-start', - justifyContent: 'flex-start', - rowGap: 2, - alignSelf: 'flex-start', - }, - }); -}; - -export default styleSheet; diff --git a/app/components/Views/ManualBackupStep1/styles.ts b/app/components/Views/ManualBackupStep1/styles.ts index 99c44e77683..ed8c5efae03 100644 --- a/app/components/Views/ManualBackupStep1/styles.ts +++ b/app/components/Views/ManualBackupStep1/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { Platform, StyleSheet } from 'react-native'; import { fontStyles } from '../../../styles/common'; diff --git a/app/components/Views/ManualBackupStep2/index.js b/app/components/Views/ManualBackupStep2/index.js index 1b7c6ad126a..11b40a79fd0 100644 --- a/app/components/Views/ManualBackupStep2/index.js +++ b/app/components/Views/ManualBackupStep2/index.js @@ -1,36 +1,40 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { - InteractionManager, Alert, TouchableOpacity, - View, FlatList, - Dimensions, + Platform, + useWindowDimensions, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import PropTypes from 'prop-types'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxFlexDirection, + BoxJustifyContent, + FontWeight, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { connect } from 'react-redux'; import ActionView from '../../UI/ActionView'; import { ScreenshotDeterrent } from '../../UI/ScreenshotDeterrent'; import { strings } from '../../../../locales/i18n'; -import { connect } from 'react-redux'; import { seedphraseBackedUp } from '../../../actions/user'; import { saveOnboardingEvent as saveEvent } from '../../../actions/onboarding'; import { getOnboardingNavbarOptions } from '../../UI/Navbar'; import { compareMnemonics } from '../../../util/mnemonic'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { useTheme } from '../../../util/theme'; -import createStyles from './styles'; import { ManualBackUpStepsSelectorsIDs } from '../ManualBackupStep1/ManualBackUpSteps.testIds'; import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding'; import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; -import Icon, { - IconName, - IconSize, -} from '../../../component-library/components/Icons/Icon'; -import Text, { - TextVariant, - TextColor, -} from '../../../component-library/components/Texts/Text'; import Routes from '../../../constants/navigation/Routes'; import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; import { CommonActions } from '@react-navigation/native'; @@ -50,8 +54,9 @@ const ManualBackupStep2 = ({ const backupFlow = route?.params?.backupFlow; const settingsBackup = route?.params?.settingsBackup; + const tw = useTailwind(); const { colors } = useTheme(); - const styles = createStyles(colors); + const { width: innerWidth, height: windowHeight } = useWindowDimensions(); const [gridWords, setGridWords] = useState([]); const [emptySlots, setEmptySlots] = useState([]); @@ -69,23 +74,17 @@ const ManualBackupStep2 = ({ ), - [colors, navigation, styles.headerLeft], + [navigation, tw], ); + const updateNavBar = useCallback(() => { navigation.setOptions( - getOnboardingNavbarOptions( - route, - { - headerLeft, - }, - colors, - false, - ), + getOnboardingNavbarOptions(route, { headerLeft }, colors, false), ); }, [colors, navigation, route, headerLeft]); @@ -104,6 +103,7 @@ const ManualBackupStep2 = ({ }, [route.params?.words, gridWords]); const { isEnabled: isMetricsEnabled } = useAnalytics(); + const goNext = () => { if (validateWords()) { seedphraseBackedUp(); @@ -188,7 +188,7 @@ const ManualBackupStep2 = ({ setGridWords(tempGrid); setMissingWords(removed); setEmptySlots(emptySlotsIndexes); - const sortedIndexes = emptySlotsIndexes.sort((a, b) => a - b); + const sortedIndexes = [...emptySlotsIndexes].sort((a, b) => a - b); setSelectedSlot(sortedIndexes[0]); setUsedWordIndices(new Set()); setWordPositionMap({}); @@ -202,25 +202,21 @@ const ManualBackupStep2 = ({ (word, wordIndex) => { const updatedGrid = [...gridWords]; - // Check if this specific word index is already used if (usedWordIndices.has(wordIndex)) { - // This specific word instance is already placed, find and remove it const positionToRemove = Object.keys(wordPositionMap).find( (pos) => wordPositionMap[pos] === wordIndex, ); if (positionToRemove !== undefined) { const newGrid = [...updatedGrid]; - newGrid[parseInt(positionToRemove)] = ''; + newGrid[parseInt(positionToRemove, 10)] = ''; setGridWords(newGrid); - setSelectedSlot(parseInt(positionToRemove)); + setSelectedSlot(parseInt(positionToRemove, 10)); - // Remove this word index from used indices const newUsedIndices = new Set(usedWordIndices); newUsedIndices.delete(wordIndex); setUsedWordIndices(newUsedIndices); - // Remove from position map const newPositionMap = { ...wordPositionMap }; delete newPositionMap[positionToRemove]; setWordPositionMap(newPositionMap); @@ -228,24 +224,20 @@ const ManualBackupStep2 = ({ return; } - // Word must be one of the missing ones if (!missingWords.includes(word)) return; - // Get empty slots in order - const emptySlotsUpdated = emptySlots + const emptySlotsUpdated = [...emptySlots] .sort((a, b) => a - b) .filter((idx) => updatedGrid[idx] === ''); - // If user clicked a slot manually, use it let targetIndex = selectedSlot; - // FINAL GUARD: Always prefer ordered empty slot if ( - targetIndex === null || // no slot selected - updatedGrid[targetIndex] !== '' || // slot already filled - !emptySlotsUpdated.includes(targetIndex) // invalid slot + targetIndex === null || + updatedGrid[targetIndex] !== '' || + !emptySlotsUpdated.includes(targetIndex) ) { - targetIndex = emptySlotsUpdated[0]; // force first empty slot + targetIndex = emptySlotsUpdated[0]; } if (targetIndex === undefined) return; @@ -254,17 +246,14 @@ const ManualBackupStep2 = ({ newGrid[targetIndex] = word; setGridWords(newGrid); - // Add this word index to used indices const newUsedIndices = new Set(usedWordIndices); newUsedIndices.add(wordIndex); setUsedWordIndices(newUsedIndices); - // Track which word index is placed in which position const newPositionMap = { ...wordPositionMap }; newPositionMap[targetIndex] = wordIndex; setWordPositionMap(newPositionMap); - // Set focus to next empty slot in order const nextEmptySlot = emptySlotsUpdated.find((slot) => slot > targetIndex) || emptySlotsUpdated[0]; @@ -285,14 +274,12 @@ const ManualBackupStep2 = ({ if (!emptySlots.includes(index)) return; const isFilled = gridWords[index] !== ''; - const updated = [...gridWords]; if (isFilled) { updated[index] = ''; setGridWords(updated); - // Remove the word index from used indices const wordIndexToRemove = wordPositionMap[index]; if (wordIndexToRemove !== undefined) { const newUsedIndices = new Set(usedWordIndices); @@ -300,38 +287,39 @@ const ManualBackupStep2 = ({ setUsedWordIndices(newUsedIndices); } - // Remove from position map const newPositionMap = { ...wordPositionMap }; delete newPositionMap[index]; setWordPositionMap(newPositionMap); - setSelectedSlot(index); // reselect same slot + setSelectedSlot(index); } else { - setSelectedSlot(index); // highlight this for next word + setSelectedSlot(index); } }, [emptySlots, gridWords, wordPositionMap, usedWordIndices], ); - const innerWidth = Dimensions.get('window').width; - const renderGridItemText = useCallback( (item, index, isEmpty) => ( <> - + {index + 1}. {isEmpty ? item : '••••••'} ), - [styles.gridItemIndex, styles.gridItemText], + [], ); const renderGridItem = useCallback( @@ -347,14 +335,13 @@ const ManualBackupStep2 = ({ ? `${ManualBackUpStepsSelectorsIDs.GRID_ITEM_EMPTY}-${index}` : `${ManualBackUpStepsSelectorsIDs.GRID_ITEM}-${index}` } - style={[ - styles.gridItem, - isEmpty && styles.emptySlot, - isSelected && styles.selectedSlotBox, - { - width: innerWidth / 3.85, - }, - ]} + style={tw.style( + 'py-1 px-2 rounded-lg bg-default border border-muted flex-row items-center justify-start h-10 opacity-50', + Platform.OS === 'ios' ? 'gap-1 m-1' : 'gap-[3px] m-[3px]', + isEmpty && 'bg-default opacity-100 border-2 border-default', + isSelected && 'border-2 border-primary-default', + { width: innerWidth / 3.85 }, + )} onPress={() => handleSlotPress(index)} > {renderGridItemText(item, index, isEmpty)} @@ -367,45 +354,50 @@ const ManualBackupStep2 = ({ innerWidth, renderGridItemText, selectedSlot, - styles.emptySlot, - styles.gridItem, - styles.selectedSlotBox, + tw, ], ); const renderGrid = useCallback( () => ( - + index.toString()} renderItem={renderGridItem} /> - + ), - [styles.seedPhraseContainer, gridWords, renderGridItem], + [gridWords, renderGridItem], ); const renderMissingWords = useCallback( () => ( - + {missingWords.map((word, i) => { const isUsed = usedWordIndices.has(i); return ( handleWordSelect(word, i)} > @@ -414,17 +406,9 @@ const ManualBackupStep2 = ({ ); })} - + ), - [ - styles.missingWords, - styles.missingWord, - styles.selectedWord, - missingWords, - usedWordIndices, - innerWidth, - handleWordSelect, - ], + [missingWords, usedWordIndices, innerWidth, handleWordSelect, tw], ); const validateSeedPhrase = () => { @@ -465,8 +449,11 @@ const ManualBackupStep2 = ({ }; return ( - - + + - - - + + {strings('manual_backup_step_2.action')} - + {strings('manual_backup_step_2.info')} - + {renderGrid()} {renderMissingWords()} - - - + + + - + ); @@ -505,20 +506,19 @@ const ManualBackupStep2 = ({ ManualBackupStep2.propTypes = { /** - /* navigation object required to push and pop other views - */ + * Navigation object used for moving between screens. + */ navigation: PropTypes.object, /** - * The action to update the seedphrase backed up flag - * in the redux store + * Redux action that marks the SRP as backed up. */ seedphraseBackedUp: PropTypes.func, /** - * Object that represents the current route info like params passed to it + * Current route object with params. */ route: PropTypes.object, /** - * Action to save onboarding event + * Action to persist onboarding metrics events. */ saveOnboardingEvent: PropTypes.func, }; diff --git a/app/components/Views/ManualBackupStep2/index.test.tsx b/app/components/Views/ManualBackupStep2/index.test.tsx index cfb6ba16d91..5152742fccf 100644 --- a/app/components/Views/ManualBackupStep2/index.test.tsx +++ b/app/components/Views/ManualBackupStep2/index.test.tsx @@ -4,7 +4,7 @@ import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; import renderWithProvider from '../../../util/test/renderWithProvider'; import { CommonActions, useNavigation } from '@react-navigation/native'; -import { fireEvent, waitFor } from '@testing-library/react-native'; +import { act, fireEvent, waitFor } from '@testing-library/react-native'; import { ManualBackUpStepsSelectorsIDs } from '../ManualBackupStep1/ManualBackUpSteps.testIds'; import { strings } from '../../../../locales/i18n'; import Routes from '../../../constants/navigation/Routes'; @@ -99,6 +99,27 @@ describe('ManualBackupStep2', () => { 'cinnamon', ]; + const defaultRouteParams = { + words: mockWords, + backupFlow: false, + settingsBackup: false, + steps: ['one', 'two', 'three'], + }; + + const createMockNavigationProps = ( + overrides: Record = {}, + ) => ({ + navigate: jest.fn(), + goBack: jest.fn(), + setOptions: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + isFocused: jest.fn(), + reset: jest.fn(), + dispatch: jest.fn(), + ...overrides, + }); + beforeEach(() => { jest.clearAllMocks(); global.Math = mockMath; @@ -114,49 +135,32 @@ describe('ManualBackupStep2', () => { }); const mockRoute = jest.fn().mockReturnValue({ - params: { - words: mockWords, - backupFlow: false, - settingsBackup: false, - steps: ['one', 'two', 'three'], - }, + params: { ...defaultRouteParams }, }); const setupTest = () => { const mockNavigate = jest.fn(); const mockNavigationDispatch = jest.fn(); - const mockGoBack = jest.fn(); const mockSetOptions = jest.fn(); const mockDispatch = jest.fn(); store.dispatch = mockDispatch; - const mockNavigation = (useNavigation as jest.Mock).mockReturnValue({ + const navProps = createMockNavigationProps({ navigate: mockNavigate, goBack: mockGoBack, setOptions: mockSetOptions, - addListener: jest.fn(), - removeListener: jest.fn(), - isFocused: jest.fn(), - reset: jest.fn(), dispatch: mockNavigationDispatch, }); + const mockNavigation = (useNavigation as jest.Mock).mockReturnValue( + navProps, + ); + const wrapper = renderWithProvider( - + , ); @@ -235,50 +239,40 @@ describe('ManualBackupStep2', () => { }; }; - it('render and handle word selection in grid', () => { + it('updates grid item style when a word is selected on Android', () => { Platform.OS = 'android'; const { wrapper, mockNavigation } = setupTest(); - const gridItems = wrapper.getByTestId( + + const gridItem = wrapper.getByTestId( `${ManualBackUpStepsSelectorsIDs.GRID_ITEM}-0`, ); + fireEvent.press(gridItem); - // Select a word - fireEvent.press(gridItems); - expect(gridItems).toHaveStyle({ backgroundColor: expect.any(String) }); + expect(gridItem).toHaveStyle({ backgroundColor: expect.any(String) }); mockNavigation.mockRestore(); Platform.OS = 'ios'; }); - it('render SuccessErrorSheet with type error when seed phrase is invalid', () => { + it('opens error sheet when seed phrase words are selected in wrong order', () => { const { wrapper, mockNavigate, mockNavigation } = setupTest(); + const getMissingWord = (index: number) => + wrapper.getByTestId( + `${ManualBackUpStepsSelectorsIDs.MISSING_WORDS}-${index}`, + ); - const missingWordOne = wrapper.getByTestId( - `${ManualBackUpStepsSelectorsIDs.MISSING_WORDS}-0`, - ); - const missingWordTwo = wrapper.getByTestId( - `${ManualBackUpStepsSelectorsIDs.MISSING_WORDS}-1`, - ); - const missingWordThree = wrapper.getByTestId( - `${ManualBackUpStepsSelectorsIDs.MISSING_WORDS}-2`, - ); - - fireEvent.press(missingWordOne); - fireEvent.press(missingWordTwo); - fireEvent.press(missingWordThree); - - fireEvent.press(missingWordOne); - fireEvent.press(missingWordTwo); - fireEvent.press(missingWordThree); - - fireEvent.press(missingWordOne); - fireEvent.press(missingWordTwo); - fireEvent.press(missingWordThree); + fireEvent.press(getMissingWord(0)); + fireEvent.press(getMissingWord(1)); + fireEvent.press(getMissingWord(2)); + fireEvent.press(getMissingWord(0)); + fireEvent.press(getMissingWord(1)); + fireEvent.press(getMissingWord(2)); + fireEvent.press(getMissingWord(0)); + fireEvent.press(getMissingWord(1)); + fireEvent.press(getMissingWord(2)); - // Press continue button const continueButton = wrapper.getByTestId( ManualBackUpStepsSelectorsIDs.CONTINUE_BUTTON, ); - fireEvent.press(continueButton); expect(mockNavigate).toHaveBeenCalledWith('RootModalFlow', { @@ -297,7 +291,7 @@ describe('ManualBackupStep2', () => { mockNavigation.mockRestore(); }); - it('render SuccessErrorSheet with type success when seed phrase is valid and navigate to HomeNav', async () => { + it('opens success sheet and navigates to onboarding success when words match', async () => { const { wrapper, mockNavigate, mockNavigationDispatch } = setupTest(); const missingWordOne = wrapper.getByTestId( @@ -392,16 +386,8 @@ describe('ManualBackupStep2', () => { }); }); - it('navigate to Optin Metrics for onboarding flow', async () => { - // configure onboarding scenario - mockRoute.mockReturnValue({ - params: { - words: mockWords, - backupFlow: false, - settingsBackup: false, - steps: ['one', 'two', 'three'], - }, - }); + it('navigates to OptinMetrics when analytics is disabled during onboarding', async () => { + mockRoute.mockReturnValue({ params: { ...defaultRouteParams } }); mockMetricsIsEnabled.mockReturnValue(false); // setup test @@ -422,16 +408,8 @@ describe('ManualBackupStep2', () => { }); }); - it('navigate to Onboarding Success flow for onboarding backup flow', async () => { - // configure onboarding scenario - mockRoute.mockReturnValue({ - params: { - words: mockWords, - backupFlow: false, - settingsBackup: false, - steps: ['one', 'two', 'three'], - }, - }); + it('navigates to onboarding success flow when analytics is enabled', async () => { + mockRoute.mockReturnValue({ params: { ...defaultRouteParams } }); mockMetricsIsEnabled.mockReturnValue(true); // setup test @@ -466,14 +444,9 @@ describe('ManualBackupStep2', () => { }); }); - it('navigate to HomeNav for reminder backup flow', async () => { + it('navigates to onboarding success with reminder backup flow', async () => { mockRoute.mockReturnValue({ - params: { - words: mockWords, - backupFlow: true, - settingsBackup: false, - steps: ['one', 'two', 'three'], - }, + params: { ...defaultRouteParams, backupFlow: true }, }); mockMetricsIsEnabled.mockReturnValue(true); @@ -509,14 +482,9 @@ describe('ManualBackupStep2', () => { }); }); - it('navigate to Onboarding Success with settings backup flow', async () => { + it('navigates to onboarding success with settings backup flow', async () => { mockRoute.mockReturnValue({ - params: { - words: mockWords, - backupFlow: false, - settingsBackup: true, - steps: ['one', 'two', 'three'], - }, + params: { ...defaultRouteParams, settingsBackup: true }, }); mockMetricsIsEnabled.mockReturnValue(true); @@ -549,32 +517,27 @@ describe('ManualBackupStep2', () => { expect(mockNavigationDispatch).toHaveBeenCalledWith(resetAction); }); - it('on click of the missing word, the empty slot should be selected', async () => { + it('highlights missing word with blue border after selecting it for an empty slot', async () => { const { wrapper } = setupTest(); - const missingWordOne = wrapper.getByTestId( `${ManualBackUpStepsSelectorsIDs.MISSING_WORDS}-0`, ); - - // Get all empty slots using GRID_ITEM_EMPTY test ID const emptySlots: ReactTestInstance[] = []; const nonEmptySlots: ReactTestInstance[] = []; - - // Try to find both types of slots for each index for (let i = 0; i < 12; i++) { try { const emptySlot = wrapper.getByTestId( `${ManualBackUpStepsSelectorsIDs.GRID_ITEM_EMPTY}-${i}`, ); emptySlots.push(emptySlot); - } catch (emptyError) { + } catch { try { const nonEmptySlot = wrapper.getByTestId( `${ManualBackUpStepsSelectorsIDs.GRID_ITEM}-${i}`, ); nonEmptySlots.push(nonEmptySlot); - } catch (nonEmptyError) { - // Skip if neither type is found (shouldn't happen) + } catch { + // index not present } } } @@ -583,39 +546,30 @@ describe('ManualBackupStep2', () => { expect(nonEmptySlots).toHaveLength(9); fireEvent.press(missingWordOne); - // Press each empty slot fireEvent.press(emptySlots[0]); - fireEvent.press(missingWordOne); - // Verify we found exactly 3 empty slots and 9 non-empty slots - expect(missingWordOne).toHaveStyle({ - borderColor: '#4459ff', - }); + expect(missingWordOne).toHaveStyle({ borderColor: '#4459ff' }); }); - it('on click of the empty slot, slot should be selected', async () => { + it('highlights empty slot with blue border when pressed', async () => { const { wrapper } = setupTest(); - - // Get all empty slots using GRID_ITEM_EMPTY test ID const emptySlots: ReactTestInstance[] = []; const nonEmptySlots: ReactTestInstance[] = []; - - // Try to find both types of slots for each index for (let i = 0; i < 12; i++) { try { const emptySlot = wrapper.getByTestId( `${ManualBackUpStepsSelectorsIDs.GRID_ITEM_EMPTY}-${i}`, ); emptySlots.push(emptySlot); - } catch (emptyError) { + } catch { try { const nonEmptySlot = wrapper.getByTestId( `${ManualBackUpStepsSelectorsIDs.GRID_ITEM}-${i}`, ); nonEmptySlots.push(nonEmptySlot); - } catch (nonEmptyError) { - // Skip if neither type is found (shouldn't happen) + } catch { + // index not present } } } @@ -625,18 +579,13 @@ describe('ManualBackupStep2', () => { fireEvent.press(emptySlots[0]); - expect(emptySlots[0]).toHaveStyle({ - borderColor: '#4459ff', - }); + expect(emptySlots[0]).toHaveStyle({ borderColor: '#4459ff' }); }); }); describe('with empty mockWords', () => { - const mockRoute = { - params: { - words: [], - steps: ['one', 'two', 'three'], - }, + const emptyRoute = { + params: { ...defaultRouteParams, words: [] }, }; const setupTest = () => { @@ -647,29 +596,19 @@ describe('ManualBackupStep2', () => { store.dispatch = mockDispatch; - const mockNavigation = (useNavigation as jest.Mock).mockReturnValue({ + const navProps = createMockNavigationProps({ navigate: mockNavigate, goBack: mockGoBack, setOptions: mockSetOptions, - addListener: jest.fn(), - removeListener: jest.fn(), - isFocused: jest.fn(), - reset: jest.fn(), }); + const mockNavigation = (useNavigation as jest.Mock).mockReturnValue( + navProps, + ); + const wrapper = renderWithProvider( - + , ); @@ -683,28 +622,227 @@ describe('ManualBackupStep2', () => { }; }; - it('check when words have empty array', async () => { + it('renders continue button when words array is empty', async () => { const { wrapper, mockNavigation } = setupTest(); - // Press continue button const continueButton = wrapper.getByTestId( ManualBackUpStepsSelectorsIDs.CONTINUE_BUTTON, ); - fireEvent.press(continueButton); - expect(continueButton).toBeTruthy(); + expect(continueButton).toBeOnTheScreen(); mockNavigation.mockRestore(); }); - it('shows header with back button for onboarding flow', () => { + it('configures navigation header with headerLeft component', () => { const { mockSetOptions } = setupTest(); expect(mockSetOptions).toHaveBeenCalled(); const setOptionsCall = mockSetOptions.mock.calls[0][0]; - expect(setOptionsCall.headerShown).toBeUndefined(); - expect(setOptionsCall.headerLeft).toBeDefined(); + expect(setOptionsCall.headerLeft).toEqual(expect.any(Function)); + }); + }); + + describe('headerLeft back button', () => { + it('triggers goBack when headerLeft back button is pressed', () => { + const mockGoBack = jest.fn(); + const mockSetOptions = jest.fn(); + + const navProps = createMockNavigationProps({ + goBack: mockGoBack, + setOptions: mockSetOptions, + }); + + (useNavigation as jest.Mock).mockReturnValue(navProps); + + renderWithProvider( + + + , + ); + + const headerLeftComponent = mockSetOptions.mock.calls[0][0].headerLeft; + expect(headerLeftComponent).toEqual(expect.any(Function)); + + const backButton = renderWithProvider(headerLeftComponent()); + const backButtonElement = backButton.getByTestId( + ManualBackUpStepsSelectorsIDs.BACK_BUTTON, + ); + fireEvent.press(backButtonElement); + + expect(mockGoBack).toHaveBeenCalled(); + }); + }); + + describe('error sheet callbacks', () => { + beforeEach(() => { + Platform.OS = 'ios'; + global.Math = mockMath; + }); + + const setupErrorSheet = () => { + const mockNavigate = jest.fn(); + const mockSetOptions = jest.fn(); + + const navProps = createMockNavigationProps({ + navigate: mockNavigate, + setOptions: mockSetOptions, + }); + + (useNavigation as jest.Mock).mockReturnValue(navProps); + + const wrapper = renderWithProvider( + + + , + ); + + const getMissingWords = (index: number) => + wrapper.getByTestId( + `${ManualBackUpStepsSelectorsIDs.MISSING_WORDS}-${index}`, + ); + + fireEvent.press(getMissingWords(0)); + fireEvent.press(getMissingWords(1)); + fireEvent.press(getMissingWords(2)); + fireEvent.press(getMissingWords(0)); + fireEvent.press(getMissingWords(1)); + fireEvent.press(getMissingWords(2)); + fireEvent.press(getMissingWords(0)); + fireEvent.press(getMissingWords(1)); + fireEvent.press(getMissingWords(2)); + + const continueButton = wrapper.getByTestId( + ManualBackUpStepsSelectorsIDs.CONTINUE_BUTTON, + ); + fireEvent.press(continueButton); + + const errorCall = mockNavigate.mock.calls.find( + (call) => + call[0] === 'RootModalFlow' && call[1]?.params?.type === 'error', + ); + + return { wrapper, errorCall }; + }; + + it('regenerates grid with 3 empty slots when error sheet primary button is pressed', () => { + const { wrapper, errorCall } = setupErrorSheet(); + expect(errorCall).not.toBeUndefined(); + + act(() => { + errorCall[1].params.onPrimaryButtonPress(); + }); + + const emptySlots: ReactTestInstance[] = []; + for (let i = 0; i < 12; i++) { + try { + emptySlots.push( + wrapper.getByTestId( + `${ManualBackUpStepsSelectorsIDs.GRID_ITEM_EMPTY}-${i}`, + ), + ); + } catch { + // filled slot — skip + } + } + expect(emptySlots).toHaveLength(3); + }); + + it('regenerates grid with 3 empty slots when error sheet onClose is called', () => { + const { wrapper, errorCall } = setupErrorSheet(); + expect(errorCall).not.toBeUndefined(); + + act(() => { + errorCall[1].params.onClose(); + }); + + const emptySlots: ReactTestInstance[] = []; + for (let i = 0; i < 12; i++) { + try { + emptySlots.push( + wrapper.getByTestId( + `${ManualBackUpStepsSelectorsIDs.GRID_ITEM_EMPTY}-${i}`, + ), + ); + } catch { + // filled slot — skip + } + } + expect(emptySlots).toHaveLength(3); + }); + }); + + describe('success sheet onClose callback', () => { + beforeEach(() => { + Platform.OS = 'ios'; + global.Math = mockMath; + mockMetricsIsEnabled.mockReturnValue(true); + }); + + it('dispatches navigation reset when success sheet onClose is called', () => { + const mockNavigate = jest.fn(); + const mockNavigationDispatch = jest.fn(); + const mockSetOptions = jest.fn(); + + const navProps = createMockNavigationProps({ + navigate: mockNavigate, + setOptions: mockSetOptions, + dispatch: mockNavigationDispatch, + }); + + (useNavigation as jest.Mock).mockReturnValue(navProps); + + const wrapper = renderWithProvider( + + + , + ); + + const getMissingWords = (index: number) => + wrapper.getByTestId( + `${ManualBackUpStepsSelectorsIDs.MISSING_WORDS}-${index}`, + ); + const getWordItem = (index: number) => + wrapper.getByTestId( + `${ManualBackUpStepsSelectorsIDs.WORD_ITEM_MISSING}-${index}`, + ); + + const missingWordOrder = [ + { click: getMissingWords(0), text: getWordItem(0).props.children }, + { click: getMissingWords(1), text: getWordItem(1).props.children }, + { click: getMissingWords(2), text: getWordItem(2).props.children }, + ]; + + missingWordOrder + .sort((a, b) => mockWords.indexOf(a.text) - mockWords.indexOf(b.text)) + .forEach(({ click }) => fireEvent.press(click)); + + const continueButton = wrapper.getByTestId( + ManualBackUpStepsSelectorsIDs.CONTINUE_BUTTON, + ); + fireEvent.press(continueButton); + + const successCall = mockNavigate.mock.calls.find( + (call) => + call[0] === 'RootModalFlow' && call[1]?.params?.type === 'success', + ); + expect(successCall).not.toBeUndefined(); + + const { onClose } = successCall[1].params; + expect(onClose).toEqual(expect.any(Function)); + onClose(); + + expect(mockNavigationDispatch).toHaveBeenCalled(); }); }); }); diff --git a/app/components/Views/ManualBackupStep2/styles.ts b/app/components/Views/ManualBackupStep2/styles.ts deleted file mode 100644 index 4897cbe02e5..00000000000 --- a/app/components/Views/ManualBackupStep2/styles.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { StyleSheet, Dimensions, Platform } from 'react-native'; -import { fontStyles } from '../../../styles/common'; - -const { height } = Dimensions.get('window'); - -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const createStyles = (colors: any) => - StyleSheet.create({ - container: { - flex: 1, - paddingHorizontal: 16, - }, - mainWrapper: { - backgroundColor: colors.background.default, - flex: 1, - }, - wrapper: { - flex: 1, - flexDirection: 'column', - justifyContent: 'space-between', - height: '100%', - rowGap: 16, - }, - selectedWord: { - backgroundColor: colors.background.alternative, - borderWidth: 0, - }, - selectedWordText: { - color: colors.text.default, - }, - seedPhraseContainer: { - backgroundColor: colors.background.muted, - borderRadius: 10, - height: 'auto', - flexDirection: 'column', - marginBottom: 16, - padding: 16, - gap: 4, - }, - statusContainer: { - justifyContent: 'center', - alignItems: 'center', - flexDirection: 'column', - gap: 16, - padding: 16, - width: '100%', - }, - emptySlot: { - backgroundColor: colors.background.default, - opacity: 1, - borderColor: colors.border.default, - borderWidth: 2, - }, - selectedSlotBox: { - borderColor: colors.primary.default, - borderWidth: 2, - }, - missingWords: { - flexDirection: 'row', - justifyContent: 'center', - flexWrap: 'wrap', - }, - missingWord: { - paddingVertical: 4, - paddingHorizontal: 8, - margin: 8, - borderRadius: 8, - backgroundColor: colors.background.default, - borderWidth: 1, - borderColor: colors.primary.default, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - height: 40, - color: colors.primary.default, - }, - missingWordText: { - color: colors.primary.default, - }, - missingWordTextSelected: { - color: colors.text.default, - }, - gridItem: { - paddingVertical: 4, - paddingHorizontal: 8, - borderRadius: 8, - backgroundColor: colors.background.default, - borderWidth: 1, - borderColor: colors.border.muted, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-start', - gap: Platform.select({ - ios: 4, - macos: 4, - default: 3, - }), - height: 40, - fontSize: 14, - color: colors.text.default, - ...fontStyles.normal, - opacity: 0.5, - margin: Platform.select({ - ios: 4, - macos: 4, - default: 3, - }), - }, - gridContainer: { - flex: 1, - flexDirection: 'column', - gap: 4, - }, - gridItemIndex: { - color: colors.text.alternative, - ...fontStyles.normal, - fontSize: 14, - }, - gridItemText: { - width: '95%', - }, - content: { - flex: 1, - flexDirection: 'column', - justifyContent: 'space-between', - rowGap: 16, - height: height - 290, - }, - headerLeft: { - marginLeft: 16, - }, - statusButton: { - width: '100%', - }, - statusDescription: { - textAlign: 'left', - alignSelf: 'flex-start', - width: '100%', - }, - actionView: { - flex: 1, - }, - buttonContainer: { - paddingHorizontal: 0, - marginBottom: Platform.OS === 'android' ? 16 : 0, - }, - }); - -export default createStyles; diff --git a/app/components/Views/MultiRpcModal/MultiRpcModal.tsx b/app/components/Views/MultiRpcModal/MultiRpcModal.tsx index c78c7d56cb2..b04a8a5c86f 100644 --- a/app/components/Views/MultiRpcModal/MultiRpcModal.tsx +++ b/app/components/Views/MultiRpcModal/MultiRpcModal.tsx @@ -37,7 +37,7 @@ import { IconName } from '../../../component-library/components/Icons/Icon'; import { getNetworkImageSource } from '../../../util/networks'; import Routes from '../../../constants/navigation/Routes'; -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const networkImage = require('../../../images/networks1.png'); const MultiRpcModal = () => { diff --git a/app/components/Views/MultichainAccounts/AccountDetails/components/RemoveAccount/RemoveAccount.tsx b/app/components/Views/MultichainAccounts/AccountDetails/components/RemoveAccount/RemoveAccount.tsx index 4434cf909d1..0362fd848f8 100644 --- a/app/components/Views/MultichainAccounts/AccountDetails/components/RemoveAccount/RemoveAccount.tsx +++ b/app/components/Views/MultichainAccounts/AccountDetails/components/RemoveAccount/RemoveAccount.tsx @@ -1,14 +1,11 @@ import React, { useCallback } from 'react'; -import { TextVariant } from '../../../../../../component-library/components/Texts/Text'; import { useNavigation } from '@react-navigation/native'; import Routes from '../../../../../../constants/navigation/Routes'; import { InternalAccount } from '@metamask/keyring-internal-api'; import { strings } from '../../../../../../../locales/i18n'; import { useStyles } from '../../../../../hooks/useStyles'; import styleSheet from './RemoveAccount.styles'; -import Button, { - ButtonVariants, -} from '../../../../../../component-library/components/Buttons/Button'; +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; import { AccountDetailsIds } from '../../../AccountDetails.testIds'; interface RemoveAccountProps { @@ -31,10 +28,10 @@ export const RemoveAccount = ({ account }: RemoveAccountProps) => { testID={AccountDetailsIds.REMOVE_ACCOUNT_BUTTON} style={styles.button} isDanger - variant={ButtonVariants.Secondary} - labelTextVariant={TextVariant.BodyMDMedium} + variant={ButtonVariant.Secondary} onPress={handleRemoveAccountClick} - label={strings('multichain_accounts.delete_account.title')} - /> + > + {strings('multichain_accounts.delete_account.title')} + ); }; diff --git a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx index 62b5276462e..c33a8dab3c3 100644 --- a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx +++ b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx @@ -14,11 +14,11 @@ const mockDispatch = jest.fn(); // Mock the BottomSheet component const mockOnCloseBottomSheet = jest.fn(); -// eslint-disable-next-line import/no-commonjs +// eslint-disable-next-line import-x/no-commonjs jest.mock( '../../../../component-library/components/BottomSheets/BottomSheet', () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-commonjs, @typescript-eslint/no-var-requires + // eslint-disable-next-line @typescript-eslint/no-require-imports, import-x/no-commonjs, @typescript-eslint/no-var-requires const ReactMock = require('react'); return { __esModule: true, @@ -221,13 +221,13 @@ describe('LearnMoreBottomSheet', () => { ); // Initially checkbox should be unchecked and confirm button disabled - expect(confirmButton).toHaveProp('disabled', true); + expect(confirmButton).toBeDisabled(); // Press checkbox to check it fireEvent.press(checkbox); // Confirm button should now be enabled - expect(confirmButton).toHaveProp('disabled', false); + expect(confirmButton).toBeEnabled(); }); it('handles confirm button press when checkbox is checked', () => { diff --git a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx index f32b90a325a..0c33027f5d5 100644 --- a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx +++ b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx @@ -6,12 +6,10 @@ import { TextVariant, IconName, TextColor, + Button, + ButtonVariant, + ButtonBaseSize, } from '@metamask/design-system-react-native'; -import Button, { - ButtonVariants, - ButtonWidthTypes, - ButtonSize, -} from '../../../../component-library/components/Buttons/Button'; import Checkbox from '../../../../component-library/components/Checkbox'; import BottomSheet, { BottomSheetRef, @@ -111,14 +109,15 @@ const LearnMoreBottomSheet: React.FC = ({ diff --git a/app/components/Views/MultichainAccounts/IntroModal/MultichainAccountsIntroModal.tsx b/app/components/Views/MultichainAccounts/IntroModal/MultichainAccountsIntroModal.tsx index 3cfbd55aa4a..d910d35cfdf 100644 --- a/app/components/Views/MultichainAccounts/IntroModal/MultichainAccountsIntroModal.tsx +++ b/app/components/Views/MultichainAccounts/IntroModal/MultichainAccountsIntroModal.tsx @@ -6,12 +6,10 @@ import { TextVariant, IconName, TextColor, + Button, + ButtonVariant, + ButtonBaseSize, } from '@metamask/design-system-react-native'; -import Button, { - ButtonVariants, - ButtonWidthTypes, - ButtonSize, -} from '../../../../component-library/components/Buttons/Button'; import { useNavigation, useTheme } from '@react-navigation/native'; import { useDispatch } from 'react-redux'; import { createAccountSelectorNavDetails } from '../../AccountSelector'; @@ -201,24 +199,26 @@ const MultichainAccountsIntroModal = () => { diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.test.tsx b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.test.tsx index dc0555c8f25..4bad6e15d0f 100644 --- a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.test.tsx +++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.test.tsx @@ -306,7 +306,7 @@ describe('MultichainAccountConnectMultiSelector', () => { const updateButton = getByTestId( ConnectAccountBottomSheetSelectorsIDs.SELECT_MULTI_BUTTON, ); - expect(updateButton.props.disabled).toBe(true); + expect(updateButton).toBeDisabled(); }); }); diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx index f2761721abe..7101315a946 100644 --- a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx +++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx @@ -5,10 +5,11 @@ import { useSelector } from 'react-redux'; // External dependencies. import { strings } from '../../../../../../locales/i18n'; -import Button, { - ButtonSize, - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; +import { + Button, + ButtonVariant, + ButtonBaseSize, +} from '@metamask/design-system-react-native'; import SheetHeader from '../../../../../component-library/components/Sheet/SheetHeader'; import Text, { TextColor, @@ -109,17 +110,18 @@ const MultichainAccountConnectMultiSelector = ({ {areAnyAccountsSelected && ( )} {areNoAccountsSelected && showDisconnectAllButton && ( @@ -133,16 +135,17 @@ const MultichainAccountConnectMultiSelector = ({ )} diff --git a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx index 78fdc36f24f..e42361a5d11 100644 --- a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx +++ b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx @@ -25,10 +25,12 @@ import TextComponent, { TextVariant, } from '../../../../component-library/components/Texts/Text'; import AvatarGroup from '../../../../component-library/components/Avatars/AvatarGroup'; -import Button, { - ButtonSize, - ButtonVariants, -} from '../../../../component-library/components/Buttons/Button'; +import { + Button, + ButtonVariant, + ButtonBaseSize, + IconName as DesignSystemIconName, +} from '@metamask/design-system-react-native'; import { getHost } from '../../../../util/browser'; import WebsiteIcon from '../../../UI/WebsiteIcon'; import styleSheet from './MultichainPermissionsSummary.styles'; @@ -623,19 +625,20 @@ const MultichainPermissionsSummary = ({ {isAlreadyConnected && isDisconnectAllShown && ( )} {showActionButtons && !isNonDappNetworkSwitch && ( @@ -670,31 +673,33 @@ const MultichainPermissionsSummary = ({ )} diff --git a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx index 383617b5387..bdf63fb4847 100644 --- a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx +++ b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx @@ -38,10 +38,11 @@ import Text, { TextVariant, TextColor, } from '../../../../component-library/components/Texts/Text'; -import Button, { - ButtonSize, - ButtonVariants, -} from '../../../../component-library/components/Buttons/Button'; +import { + Button, + ButtonVariant, + ButtonBaseSize, +} from '@metamask/design-system-react-native'; import { useParams, createNavigationDetails, @@ -233,21 +234,23 @@ export const PrivateKeyList = () => { ), diff --git a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx index c816975e1b7..c00ac07efc5 100644 --- a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx +++ b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx @@ -13,11 +13,11 @@ import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; import { Box } from '../../../../UI/Box/Box'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; +import { + Button, + ButtonVariant, + ButtonBaseSize, +} from '@metamask/design-system-react-native'; import styleSheet from './EditMultichainAccountName.styles'; import { useStyles } from '../../../../hooks/useStyles'; import { useTheme } from '../../../../../util/theme'; @@ -144,15 +144,14 @@ export const EditMultichainAccountName = () => { diff --git a/app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal.tsx b/app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal.tsx index e7977473fbf..691a15ea2df 100644 --- a/app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal.tsx +++ b/app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-require-imports */ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ /* eslint-disable @typescript-eslint/no-var-requires */ import React, { useRef } from 'react'; import BottomSheet, { diff --git a/app/components/Views/NetworkSelector/NetworkSelector.test.tsx b/app/components/Views/NetworkSelector/NetworkSelector.test.tsx index 6a3cdfecf19..409a250cef7 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.test.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.test.tsx @@ -45,7 +45,7 @@ jest.mock('../../../core/Analytics', () => ({ }, })); -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as selectedNetworkControllerFcts from '../../../selectors/selectedNetworkController'; const mockEngine = Engine; diff --git a/app/components/Views/NetworkSelector/NetworkSelector.tsx b/app/components/Views/NetworkSelector/NetworkSelector.tsx index f1a34a6fed8..12351d22ead 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.tsx @@ -107,6 +107,8 @@ import { removeItemFromChainIdList } from '../../../util/metrics/MultichainAPI/n import { analytics } from '../../../util/analytics/analytics'; import { NETWORK_SELECTOR_SOURCES } from '../../../constants/networkSelector'; import { getGasFeesSponsoredNetworkEnabled } from '../../../selectors/featureFlagController/gasFeesSponsored'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; +import { isHardwareAccount } from '../../../util/address'; import TagColored, { TagColor, } from '../../../component-library/components-temp/TagColored'; @@ -137,6 +139,12 @@ const NetworkSelector = ({ route }: NetworkSelectorProps) => { const isGasFeesSponsoredNetworkEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, ); + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + const isHardwareWallet = Boolean( + selectedAddress && isHardwareAccount(selectedAddress), + ); const networkConfigurations = useSelector( selectEvmNetworkConfigurationsByChainId, @@ -559,7 +567,8 @@ const NetworkSelector = ({ route }: NetworkSelectorProps) => { {name} - {isGasFeesSponsoredNetworkEnabled(chainId) ? ( + {!isHardwareWallet && + isGasFeesSponsoredNetworkEnabled(chainId) ? ( { ) } tertiaryText={ - isSendFlow && isGasFeesSponsoredNetworkEnabled(chainId) + isSendFlow && + !isHardwareWallet && + isGasFeesSponsoredNetworkEnabled(chainId) ? strings('networks.no_network_fee') : undefined } diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.styles.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.styles.ts index bdd4d7b6751..939bae49686 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.styles.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.styles.ts @@ -15,6 +15,18 @@ import type { Theme } from '../../../../util/theme/models'; const createStyles = (params: { theme: Theme }) => { const { colors } = params.theme; + const baseInput = { + ...typography.sBodyMD, + fontWeight: typography.sBodyMD.fontWeight as '400', + fontFamily: getFontFamily(TextVariant.BodyMD), + borderRadius: 12, + borderWidth: 1, + padding: 10, + height: 48, + color: colors.text.default, + backgroundColor: colors.background.muted, + }; + return StyleSheet.create({ // ---- Modal content layout ------------------------------------------------ rpcTitleWrapper: { @@ -29,39 +41,16 @@ const createStyles = (params: { theme: Theme }) => { // ---- RpcUrlInput still uses these for the modal form --------------------- input: { - ...fontStyles.normal, - fontWeight: fontStyles.normal.fontWeight as '400', - borderColor: colors.border.default, - borderRadius: 12, - borderWidth: 1, - padding: 10, - height: 48, - color: colors.text.default, - backgroundColor: colors.background.muted, + ...baseInput, + borderColor: colors.border.muted, }, inputWithError: { - ...typography.sBodyMD, - fontWeight: typography.sBodyMD.fontWeight as '400', - fontFamily: getFontFamily(TextVariant.BodyMD), + ...baseInput, borderColor: colors.error.default, - borderRadius: 12, - borderWidth: 1, - padding: 10, - height: 48, - color: colors.text.default, - backgroundColor: colors.background.muted, }, inputWithFocus: { - ...typography.sBodyMD, - fontWeight: typography.sBodyMD.fontWeight as '400', - fontFamily: getFontFamily(TextVariant.BodyMD), - borderColor: colors.primary.default, - borderRadius: 12, - borderWidth: 1, - padding: 10, - height: 48, - color: colors.text.default, - backgroundColor: colors.background.muted, + ...baseInput, + borderColor: colors.border.default, }, warningText: { ...fontStyles.normal, diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx index ad7f94e5fd4..515769c6b31 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import { strings } from '../../../../../locales/i18n'; +import { IconColor } from '../../../../component-library/components/Icons/Icon'; import { NetworkDetailsViewSelectorsIDs } from './NetworkDetailsView.testIds'; import NetworkDetailsView from './NetworkDetailsView'; @@ -144,6 +145,7 @@ const createMockFormHook = (overrides: Record = {}) => ({ onSymbolFocused: jest.fn(), onSymbolBlur: jest.fn(), onRpcUrlFocused: jest.fn(), + onRpcUrlBlur: jest.fn(), onChainIdFocused: jest.fn(), onChainIdBlur: jest.fn(), jumpToRpcURL: jest.fn(), @@ -173,6 +175,7 @@ const createMockValidation = () => ({ validateName: jest.fn(), validateRpcAndChainId: jest.fn(), disabledByChainId: jest.fn(() => false), + disabledByName: jest.fn(() => false), disabledBySymbol: jest.fn(() => false), checkIfChainIdExists: jest.fn(() => false), checkIfNetworkExists: jest.fn().mockResolvedValue([]), @@ -451,6 +454,20 @@ describe('NetworkDetailsView', () => { expect(saveButton.props.disabled).toBe(true); }); + it('disables save button when validation disables network name', () => { + mockValidation.mockReturnValue({ + ...createMockValidation(), + disabledByName: jest.fn(() => true), + }); + + const { getByTestId } = render(); + + const saveButton = getByTestId( + NetworkDetailsViewSelectorsIDs.ADD_CUSTOM_NETWORK_BUTTON, + ); + expect(saveButton.props.disabled).toBe(true); + }); + it('shows warning modal when showWarningModal is true', () => { mockFormHook.mockReturnValue({ ...createMockFormHook(), @@ -690,6 +707,19 @@ describe('NetworkDetailsView', () => { expect(val.validateName).toHaveBeenCalled(); }); + it('triggers RPC focus cleanup on RPC URL blur', () => { + const formReturn = createMockFormHook(); + mockFormHook.mockReturnValue(formReturn); + + const { getByTestId } = render(); + + fireEvent( + getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT), + 'blur', + ); + expect(formReturn.onRpcUrlBlur).toHaveBeenCalled(); + }); + it('triggers handleValidateSymbol on symbol field blur', () => { const val = createMockValidation(); mockValidation.mockReturnValue(val); @@ -746,6 +776,25 @@ describe('NetworkDetailsView', () => { expect( getByText(strings('app_settings.network_delete')), ).toBeOnTheScreen(); + expect( + getByText( + `${strings('app_settings.delete')} TestNet ${strings( + 'app_settings.network', + )}`, + ), + ).toBeOnTheScreen(); + }); + + it('renders header trash icon using default icon color', () => { + mockFormHook.mockReturnValue(editForm()); + + const { getByTestId } = render(); + + const trashIcon = getByTestId( + NetworkDetailsViewSelectorsIDs.CONTAINER, + ).findAllByProps({ name: 'Trash' })[0]; + + expect(trashIcon.props.color).toBe(IconColor.Default); }); it('calls operations.removeNetwork on confirm delete', () => { diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx index d75b4e83141..b321f9d2b18 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx @@ -7,7 +7,10 @@ import React, { } from 'react'; import { ImageSourcePropType, Platform, Pressable } from 'react-native'; import { useRoute, RouteProp, useNavigation } from '@react-navigation/native'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; +import { + KeyboardAwareScrollView, + KeyboardProvider, +} from 'react-native-keyboard-controller'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -107,12 +110,14 @@ const NetworkDetailsView = () => { !formHook.enableAction || formHook.form.editable === false || validation.disabledByChainId(formHook.form) || + validation.disabledByName(formHook.form) || validation.disabledBySymbol(formHook.form); const handleSave = useCallback(async () => { await operations.saveNetwork(formHook.form, { enableAction: formHook.enableAction, disabledByChainId: validation.disabledByChainId(formHook.form), + disabledByName: validation.disabledByName(formHook.form), disabledBySymbol: validation.disabledBySymbol(formHook.form), isCustomMainnet, shouldNetworkSwitchPopToWallet, @@ -181,7 +186,7 @@ const NetworkDetailsView = () => { const placeholderTextColor = colors.text.muted; - return ( + const content = ( { ) : undefined @@ -234,10 +239,9 @@ const NetworkDetailsView = () => { {/* Network Name */} @@ -356,6 +360,8 @@ const NetworkDetailsView = () => { )} ); + + return {content}; }; export default NetworkDetailsView; diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/components/NetworkFormFields.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/components/NetworkFormFields.tsx index 021ed1fcb22..0ec4bf7e39b 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/components/NetworkFormFields.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/components/NetworkFormFields.tsx @@ -131,6 +131,8 @@ const NetworkNameField: React.FC = ({ } = formHook; const { warningName } = validation; + const isRequiredNameWarning = + warningName === strings('app_settings.required'); const handleNameBlur = useCallback(() => { onValidateName(); @@ -146,7 +148,7 @@ const NetworkNameField: React.FC = ({ value={nickname} isDisabled={isAnyModalVisible || editable === false} onChangeText={onNicknameChange} - placeholder={strings('app_settings.network_name_placeholder')} + placeholder={strings('app_settings.network_name_label')} placeholderTextColor={placeholderTextColor} onBlur={handleNameBlur} onFocus={onNameFocused} @@ -157,19 +159,33 @@ const NetworkNameField: React.FC = ({ /> {warningName ? ( - - {strings('wallet.incorrect_network_name_warning')} - - - {strings('wallet.suggested_name')}{' '} - autoFillNameField(warningName)} - > + {isRequiredNameWarning ? ( + {warningName} - + ) : ( + <> + + {strings('wallet.incorrect_network_name_warning')} + + + {strings('wallet.suggested_name')}{' '} + autoFillNameField(warningName)} + > + {warningName} + + + + )} ) : null} diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcEndpointSection.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcEndpointSection.tsx index 724f82ee84c..60cd2778455 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcEndpointSection.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcEndpointSection.tsx @@ -28,7 +28,7 @@ import Tag from '../../../../../component-library/components/Tags/Tag/Tag'; import { CellComponentSelectorsIDs } from '../../../../../component-library/components/Cells/Cell/CellComponent.testIds'; import { RpcEndpointType } from '@metamask/network-controller'; import { IconName } from '../../../../../component-library/components/Icons/Icon'; -import RpcUrlInput from './RpcUrlInput'; +import RpcFormFields from './RpcFormFields'; import SelectField from './SelectField'; import { NetworkDetailsViewSelectorsIDs } from '../NetworkDetailsView.testIds'; import { formatNetworkRpcUrl } from '../NetworkDetailsView.utils'; @@ -73,6 +73,7 @@ const RpcEndpointSection: React.FC = ({ onRpcUrlAdd, onRpcNameAdd, onRpcUrlFocused, + onRpcUrlBlur, jumpToChainId, focus: { isRpcUrlFieldFocused }, } = formHook; @@ -86,53 +87,26 @@ const RpcEndpointSection: React.FC = ({ if (addMode) { return ( - <> - - - - - - - - - + ); } @@ -290,6 +264,7 @@ const RpcEndpointModals: React.FC = ({ onRpcUrlChangeWithName, onRpcUrlDelete, onRpcUrlFocused, + onRpcUrlBlur, jumpToChainId, modals: { showMultiRpcAddModal, rpcModalShowForm: showForm }, setRpcModalShowForm: setShowForm, @@ -384,51 +359,26 @@ const RpcEndpointModals: React.FC = ({ pointerEvents={showForm ? 'auto' : 'none'} > - - - - - - - - + { + beforeEach(() => jest.clearAllMocks()); + + it('renders both RPC URL and RPC name inputs', () => { + const { getByTestId } = render(); + + expect( + getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT), + ).toBeOnTheScreen(); + expect( + getByTestId(NetworkDetailsViewSelectorsIDs.RPC_NAME_INPUT), + ).toBeOnTheScreen(); + }); + + it('applies base input style when not focused and no warning', () => { + const { getByTestId } = render(); + + const input = getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT); + const flatStyle = Array.isArray(input.props.style) + ? Object.assign({}, ...input.props.style.filter(Boolean)) + : input.props.style; + + expect(flatStyle.borderColor).toBe(mockTheme.colors.border.muted); + }); + + it('applies focus style when isRpcUrlFieldFocused is true', () => { + const { getByTestId } = render( + , + ); + + const input = getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT); + const flatStyle = Array.isArray(input.props.style) + ? Object.assign({}, ...input.props.style.filter(Boolean)) + : input.props.style; + + expect(flatStyle.borderColor).toBe(mockTheme.colors.border.default); + }); + + it('applies error style when not focused and warningRpcUrl is set', () => { + const { getByTestId } = render( + , + ); + + const input = getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT); + const flatStyle = Array.isArray(input.props.style) + ? Object.assign({}, ...input.props.style.filter(Boolean)) + : input.props.style; + + expect(flatStyle.borderColor).toBe(mockTheme.colors.error.default); + }); + + it('focus style takes precedence over error style', () => { + const { getByTestId } = render( + , + ); + + const input = getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT); + const flatStyle = Array.isArray(input.props.style) + ? Object.assign({}, ...input.props.style.filter(Boolean)) + : input.props.style; + + expect(flatStyle.borderColor).toBe(mockTheme.colors.border.default); + }); + + it('calls onRpcUrlFocused on focus and onRpcUrlBlur on blur', () => { + const { getByTestId } = render(); + + const input = getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT); + fireEvent(input, 'focus'); + expect(defaultProps.onRpcUrlFocused).toHaveBeenCalled(); + + fireEvent(input, 'blur'); + expect(defaultProps.onRpcUrlBlur).toHaveBeenCalled(); + }); + + it('calls onRpcNameAdd when RPC name text changes', () => { + const { getByTestId } = render(); + + fireEvent.changeText( + getByTestId(NetworkDetailsViewSelectorsIDs.RPC_NAME_INPUT), + 'My RPC', + ); + expect(defaultProps.onRpcNameAdd).toHaveBeenCalledWith('My RPC'); + }); + + it('displays pre-filled values', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT).props.value, + ).toBe('https://rpc.example.com'); + expect( + getByTestId(NetworkDetailsViewSelectorsIDs.RPC_NAME_INPUT).props.value, + ).toBe('Example RPC'); + }); +}); diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcFormFields.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcFormFields.tsx new file mode 100644 index 00000000000..2220f18f89c --- /dev/null +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcFormFields.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { TextInput } from 'react-native'; +import { Box, Label } from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import TextField from '../../../../../component-library/components/Form/TextField'; +import RpcUrlInput from './RpcUrlInput'; +import { NetworkDetailsViewSelectorsIDs } from '../NetworkDetailsView.testIds'; +import type { NetworkDetailsStyles } from '../NetworkDetailsView.styles'; + +interface RpcFormFieldsProps { + inputRpcURL: React.RefObject; + inputNameRpcURL: React.RefObject; + rpcUrlForm: string; + rpcNameForm: string; + isRpcUrlFieldFocused: boolean; + warningRpcUrl: string | undefined; + onRpcUrlAdd: (url: string) => void; + onRpcNameAdd: (name: string) => void; + onRpcUrlFocused: () => void; + onRpcUrlBlur: () => void; + jumpToChainId: () => void; + checkIfNetworkExists: (rpcUrl: string) => Promise<{ chainId: string }[]>; + checkIfRpcUrlExists: (rpcUrl: string) => Promise<{ chainId: string }[]>; + onValidationSuccess: () => void; + onRpcUrlValidationChange: (isValid: boolean) => void; + styles: NetworkDetailsStyles; + themeAppearance: 'light' | 'dark' | 'default'; + placeholderTextColor: string; +} + +const RpcFormFields: React.FC = ({ + inputRpcURL, + inputNameRpcURL, + rpcUrlForm, + rpcNameForm, + isRpcUrlFieldFocused, + warningRpcUrl, + onRpcUrlAdd, + onRpcNameAdd, + onRpcUrlFocused, + onRpcUrlBlur, + jumpToChainId, + checkIfNetworkExists, + checkIfRpcUrlExists, + onValidationSuccess, + onRpcUrlValidationChange, + styles, + themeAppearance, + placeholderTextColor, +}) => ( + <> + + + + + + + + + +); + +export default RpcFormFields; diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcUrlInput.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcUrlInput.tsx index 0563048837b..7dada7e2dd5 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcUrlInput.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcUrlInput.tsx @@ -91,7 +91,12 @@ const RpcUrlInput = forwardRef((props, ref) => { return ( <> - + {warningRpcUrl && ( {warningRpcUrl} diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.test.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.test.ts index 9a27aff5d83..65a3e8a4f49 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.test.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.test.ts @@ -30,11 +30,14 @@ describe('useFormFocus', () => { }, ); - it('sets rpc url focus on onRpcUrlFocused', () => { + it('toggles rpc url focus on focus/blur', () => { const { result } = renderHook(() => useFormFocus()); act(() => result.current.onRpcUrlFocused()); expect(result.current.focus.isRpcUrlFieldFocused).toBe(true); + + act(() => result.current.onRpcUrlBlur()); + expect(result.current.focus.isRpcUrlFieldFocused).toBe(false); }); it('creates refs for all input fields', () => { diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.ts index ff7b76ae1e1..47acec85693 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.ts @@ -16,6 +16,7 @@ export interface UseFormFocusReturn { onSymbolFocused: () => void; onSymbolBlur: () => void; onRpcUrlFocused: () => void; + onRpcUrlBlur: () => void; onChainIdFocused: () => void; onChainIdBlur: () => void; @@ -59,6 +60,10 @@ export const useFormFocus = (): UseFormFocusReturn => { () => setFocus((prev) => ({ ...prev, isRpcUrlFieldFocused: true })), [], ); + const onRpcUrlBlur = useCallback( + () => setFocus((prev) => ({ ...prev, isRpcUrlFieldFocused: false })), + [], + ); const onChainIdFocused = useCallback( () => setFocus((prev) => ({ ...prev, isChainIdFieldFocused: true })), [], @@ -88,6 +93,7 @@ export const useFormFocus = (): UseFormFocusReturn => { onSymbolFocused, onSymbolBlur, onRpcUrlFocused, + onRpcUrlBlur, onChainIdFocused, onChainIdBlur, jumpToRpcURL, diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts index 59d49dbf382..1aac1acb359 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts @@ -137,6 +137,7 @@ const baseForm: NetworkFormState = { const defaultSaveOpts = () => ({ enableAction: true, disabledByChainId: false, + disabledByName: false, disabledBySymbol: false, isCustomMainnet: false, shouldNetworkSwitchPopToWallet: true, @@ -314,6 +315,20 @@ describe('useNetworkOperations', () => { expect(mockUpdateNetwork).not.toHaveBeenCalled(); }); + it('does nothing when disabledByName is true', async () => { + const { result } = renderHook(() => useNetworkOperations()); + + await act(async () => { + await result.current.saveNetwork(baseForm, { + ...defaultSaveOpts(), + disabledByName: true, + }); + }); + + expect(mockAddNetwork).not.toHaveBeenCalled(); + expect(mockUpdateNetwork).not.toHaveBeenCalled(); + }); + it('does nothing when rpcUrl is missing', async () => { const { result } = renderHook(() => useNetworkOperations()); @@ -355,7 +370,7 @@ describe('useNetworkOperations', () => { expect(callArgs.nativeCurrency).toBe(''); }); - it('uses empty string when nickname is undefined', async () => { + it('does nothing when nickname is undefined', async () => { const { result } = renderHook(() => useNetworkOperations()); await act(async () => { @@ -365,8 +380,8 @@ describe('useNetworkOperations', () => { ); }); - const callArgs = mockAddNetwork.mock.calls[0][0]; - expect(callArgs.name).toBe(''); + expect(mockAddNetwork).not.toHaveBeenCalled(); + expect(mockUpdateNetwork).not.toHaveBeenCalled(); }); it('sets individual chain filter when isAllNetworks is false', async () => { diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts index 0080501a131..0bc35c9626d 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts @@ -45,6 +45,7 @@ export interface UseNetworkOperationsReturn { params: { enableAction: boolean; disabledByChainId: boolean; + disabledByName: boolean; disabledBySymbol: boolean; isCustomMainnet: boolean; shouldNetworkSwitchPopToWallet: boolean; @@ -197,6 +198,7 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { params: { enableAction: boolean; disabledByChainId: boolean; + disabledByName: boolean; disabledBySymbol: boolean; isCustomMainnet: boolean; shouldNetworkSwitchPopToWallet: boolean; @@ -211,6 +213,7 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { const { enableAction, disabledByChainId, + disabledByName, disabledBySymbol, isCustomMainnet, shouldNetworkSwitchPopToWallet, @@ -218,7 +221,14 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { validateChainIdOnSubmit, } = params; - if (!enableAction || disabledByChainId || disabledBySymbol) return; + if ( + !enableAction || + disabledByChainId || + disabledByName || + disabledBySymbol + ) { + return; + } const { rpcUrl, @@ -232,7 +242,7 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { const ticker = form.ticker ? form.ticker.toUpperCase() : undefined; - if (!stateChainId || !rpcUrl) return; + if (!stateChainId || !rpcUrl || !nickname?.trim()) return; // Check if network with this chainId already exists const isNetworkNew = addMode diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.test.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.test.ts index 5adec78d523..03f5e76355c 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.test.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.test.ts @@ -192,6 +192,20 @@ describe('useNetworkValidation', () => { }); }); + describe('disabledByName', () => { + it('returns true when network name is empty', () => { + const { result } = renderHook(() => useNetworkValidation()); + expect( + result.current.disabledByName({ ...baseForm, nickname: undefined }), + ).toBe(true); + }); + + it('returns false when network name is present', () => { + const { result } = renderHook(() => useNetworkValidation()); + expect(result.current.disabledByName(baseForm)).toBe(false); + }); + }); + describe('onRpcUrlValidationChange', () => { it('updates validatedRpcURL state', () => { const { result } = renderHook(() => useNetworkValidation()); @@ -333,6 +347,16 @@ describe('useNetworkValidation', () => { }); describe('validateName', () => { + it('sets required warning when network name is empty', () => { + const { result } = renderHook(() => useNetworkValidation()); + + act(() => { + result.current.validateName({ ...baseForm, nickname: '' }); + }); + + expect(result.current.warningName).toBeDefined(); + }); + it('does nothing when useSafeChainsListValidation is false', () => { mockUseSelector.mockImplementation((selector) => { if (selector.name?.includes('SafeChainsListValidation')) return false; diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.ts index e5749c03974..655373b47d5 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.ts @@ -40,6 +40,7 @@ export interface UseNetworkValidationReturn extends ValidationState { ) => void; validateRpcAndChainId: (form: NetworkFormState) => void; disabledByChainId: (form: NetworkFormState) => boolean; + disabledByName: (form: NetworkFormState) => boolean; disabledBySymbol: (form: NetworkFormState) => boolean; checkIfChainIdExists: (chainId: string) => boolean; checkIfNetworkExists: (rpcUrl: string) => Promise; @@ -302,7 +303,15 @@ export const useNetworkValidation = (): UseNetworkValidationReturn => { const validateName = useCallback( (form: NetworkFormState, chainToMatch: SafeChain | null = null) => { const { nickname, chainId } = form; - if (!useSafeChainsListValidation) return; + const trimmedNickname = nickname?.trim(); + if (!trimmedNickname) { + setWarningName(strings('app_settings.required')); + return; + } + if (!useSafeChainsListValidation) { + setWarningName(undefined); + return; + } const name = NETWORK_TO_NAME_MAP[chainId as keyof typeof NETWORK_TO_NAME_MAP] || @@ -310,7 +319,7 @@ export const useNetworkValidation = (): UseNetworkValidationReturn => { networkList?.name || null; - const nameToUse = isValidNetworkName(chainId ?? '', name, nickname ?? '') + const nameToUse = isValidNetworkName(chainId ?? '', name, trimmedNickname) ? undefined : name; @@ -343,6 +352,11 @@ export const useNetworkValidation = (): UseNetworkValidationReturn => { [validatedChainId, warningChainId], ); + const disabledByName = useCallback( + (form: NetworkFormState): boolean => !form.nickname?.trim(), + [], + ); + const disabledBySymbol = useCallback( (form: NetworkFormState): boolean => !form.ticker, [], @@ -362,6 +376,7 @@ export const useNetworkValidation = (): UseNetworkValidationReturn => { validateName, validateRpcAndChainId, disabledByChainId, + disabledByName, disabledBySymbol, checkIfChainIdExists, checkIfNetworkExists, diff --git a/app/components/Views/NetworksManagement/components/AdditionalNetworkItem.tsx b/app/components/Views/NetworksManagement/components/AdditionalNetworkItem.tsx index 09b9cda210d..c50e910c6a7 100644 --- a/app/components/Views/NetworksManagement/components/AdditionalNetworkItem.tsx +++ b/app/components/Views/NetworksManagement/components/AdditionalNetworkItem.tsx @@ -56,7 +56,7 @@ const AdditionalNetworkItem = ({ item, onAdd }: AdditionalNetworkItemProps) => { hitSlop={HIT_SLOP} style={({ pressed }) => tw.style( - 'w-7 h-7 items-center justify-center rounded-lg bg-background-muted mr-2.5', + 'w-7 h-7 items-center justify-center mr-2.5', pressed && 'opacity-70', ) } diff --git a/app/components/Views/NetworksManagement/components/DeleteNetworkModal.tsx b/app/components/Views/NetworksManagement/components/DeleteNetworkModal.tsx index 37a0007887d..5e3374df305 100644 --- a/app/components/Views/NetworksManagement/components/DeleteNetworkModal.tsx +++ b/app/components/Views/NetworksManagement/components/DeleteNetworkModal.tsx @@ -53,7 +53,7 @@ const DeleteNetworkModal = forwardRef( testID={NetworksManagementViewSelectorsIDs.DELETE_MODAL} > - {`${strings('app_settings.delete')} ${networkName} ${strings('asset_details.network')}`} + {`${strings('app_settings.delete')} ${networkName} ${strings('app_settings.network')}`} diff --git a/app/components/Views/Notifications/Details/Fields/TransactionField.test.tsx b/app/components/Views/Notifications/Details/Fields/TransactionField.test.tsx index a7fa5d4c712..8f134ee5d7b 100644 --- a/app/components/Views/Notifications/Details/Fields/TransactionField.test.tsx +++ b/app/components/Views/Notifications/Details/Fields/TransactionField.test.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/no-namespace */ +/* eslint-disable import-x/no-namespace */ import React from 'react'; import { fireEvent, render } from '@testing-library/react-native'; import TransactionField from './TransactionField'; diff --git a/app/components/Views/Notifications/Details/Footers/AnnouncementCtaFooter.tsx b/app/components/Views/Notifications/Details/Footers/AnnouncementCtaFooter.tsx index 70da1eb0ba6..072bf8316f4 100644 --- a/app/components/Views/Notifications/Details/Footers/AnnouncementCtaFooter.tsx +++ b/app/components/Views/Notifications/Details/Footers/AnnouncementCtaFooter.tsx @@ -1,9 +1,6 @@ import React from 'react'; import { Linking } from 'react-native'; -import Button, { - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; import { ModalFooterAnnouncementCta } from '../../../../../util/notifications/notification-states/types/NotificationModalDetails'; import useStyles from '../useStyles'; import SharedDeeplinkManager from '../../../../../core/DeeplinkManager/DeeplinkManager'; @@ -66,14 +63,15 @@ export default function AnnouncementCtaFooter( return ( ); } diff --git a/app/components/Views/Notifications/Details/Footers/BlockExplorerFooter.tsx b/app/components/Views/Notifications/Details/Footers/BlockExplorerFooter.tsx index 094c700cbda..71f4fb1dd1e 100644 --- a/app/components/Views/Notifications/Details/Footers/BlockExplorerFooter.tsx +++ b/app/components/Views/Notifications/Details/Footers/BlockExplorerFooter.tsx @@ -3,14 +3,15 @@ import React, { useMemo } from 'react'; import { Linking } from 'react-native'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; -import Button, { - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; +import { + Button, + ButtonVariant, + IconName as DesignSystemIconName, +} from '@metamask/design-system-react-native'; import { selectEvmNetworkConfigurationsByChainId } from '../../../../../selectors/networkController'; import { getNetworkDetailsFromNotifPayload } from '../../../../../util/notifications'; import { ModalFooterBlockExplorer } from '../../../../../util/notifications/notification-states/types/NotificationModalDetails'; import useStyles from '../useStyles'; -import { IconName } from '../../../../../component-library/components/Icons/Icon'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import onChainAnalyticProperties from '../../../../../util/notifications/methods/notification-analytics'; @@ -70,11 +71,12 @@ export default function BlockExplorerFooter(props: BlockExplorerFooterProps) { return ( ); } diff --git a/app/components/Views/Notifications/Details/index.test.tsx b/app/components/Views/Notifications/Details/index.test.tsx index 949cd6a1477..18b6fa5ad69 100644 --- a/app/components/Views/Notifications/Details/index.test.tsx +++ b/app/components/Views/Notifications/Details/index.test.tsx @@ -16,7 +16,7 @@ import MOCK_NOTIFICATIONS, { createMockNotificationEthReceived, createMockNotificationEthSent, } from '../../../../components/UI/Notification/__mocks__/mock_notifications'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as UseNotificationsModule from '../../../../util/notifications/hooks/useNotifications'; import { AvatarAccountType } from '../../../../component-library/components/Avatars/Avatar'; @@ -39,6 +39,9 @@ jest.mock('../../../../actions/alert', () => ({ })); jest.mock('@react-navigation/native'); +jest.mock('@react-navigation/compat', () => ({ + withNavigation: jest.fn((component) => component), +})); jest.mock('react-native-safe-area-context', () => { const inset = { top: 0, right: 0, bottom: 0, left: 0 }; const frame = { width: 0, height: 0, x: 0, y: 0 }; diff --git a/app/components/Views/Notifications/OptIn/OptIn.hooks.test.tsx b/app/components/Views/Notifications/OptIn/OptIn.hooks.test.tsx index 6ad6097ba77..8656921efe9 100644 --- a/app/components/Views/Notifications/OptIn/OptIn.hooks.test.tsx +++ b/app/components/Views/Notifications/OptIn/OptIn.hooks.test.tsx @@ -9,7 +9,7 @@ import Routes from '../../../../constants/navigation/Routes'; import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; import type { UseAnalyticsHook } from '../../../hooks/useAnalytics/useAnalytics.types'; import { MetaMetricsEvents } from '../../../../core/Analytics'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as Selectors from '../../../../selectors/identity'; describe('useOptimisticNavigationEffect', () => { diff --git a/app/components/Views/Notifications/OptIn/index.test.tsx b/app/components/Views/Notifications/OptIn/index.test.tsx index b67199d0ae1..9b273a8a396 100644 --- a/app/components/Views/Notifications/OptIn/index.test.tsx +++ b/app/components/Views/Notifications/OptIn/index.test.tsx @@ -7,9 +7,9 @@ import renderWithProvider, { DeepPartial, } from '../../../../util/test/renderWithProvider'; import { strings } from '../../../../../locales/i18n'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as OptInHooksModule from './OptIn.hooks'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as UseNotificationsModule from '../../../../util/notifications/hooks/useNotifications'; const mockedDispatch = jest.fn(); diff --git a/app/components/Views/Notifications/OptIn/index.tsx b/app/components/Views/Notifications/OptIn/index.tsx index bc78cd77191..0a27b8efd51 100644 --- a/app/components/Views/Notifications/OptIn/index.tsx +++ b/app/components/Views/Notifications/OptIn/index.tsx @@ -3,9 +3,7 @@ import { Image, View, Linking, ScrollView } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; -import Button, { - ButtonVariants, -} from '../../../../component-library/components/Buttons/Button'; +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; import { strings } from '../../../../../locales/i18n'; import Text, { TextColor, @@ -100,19 +98,21 @@ const OptIn = () => { {!isLoading && unreadCount > 0 && ( )} ) : ( diff --git a/app/components/Views/OfflineMode/index.js b/app/components/Views/OfflineMode/index.js index 4b8897a153b..5821dcd2d82 100644 --- a/app/components/Views/OfflineMode/index.js +++ b/app/components/Views/OfflineMode/index.js @@ -49,7 +49,7 @@ const createStyles = (colors) => }, }); -const astronautImage = require('../../../images/astronaut.png'); // eslint-disable-line import/no-commonjs +const astronautImage = require('../../../images/astronaut.png'); // eslint-disable-line import-x/no-commonjs const OfflineMode = ({ navigation, infuraBlocked }) => { const { colors } = useTheme(); diff --git a/app/components/Views/Onboarding/__snapshots__/index.test.tsx.snap b/app/components/Views/Onboarding/__snapshots__/index.test.tsx.snap index e91c320ae73..a2a308b2498 100644 --- a/app/components/Views/Onboarding/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Onboarding/__snapshots__/index.test.tsx.snap @@ -1,5 +1,1799 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Onboarding applies compact gap and medium button size on medium device 1`] = ` + + + + + + + + + Create a new wallet + + + + + Import using Secret Recovery Phrase + + + + + + + + + + + +`; + +exports[`Onboarding applies iPhoneX notification padding when on iPhoneX 1`] = ` + + + + + + + + + Create a new wallet + + + + + Import using Secret Recovery Phrase + + + + + + + + + + Loading... + + + + + + + + + + + + + + + + + +  + + + + + Success + + + You successfully reset your wallet! + + + + + + + + + + +`; + +exports[`Onboarding applies standard gap and large button size on non-medium device 1`] = ` + + + + + + + + + Create a new wallet + + + + + Import using Secret Recovery Phrase + + + + + + + + + + + +`; + +exports[`Onboarding applies standard notification padding when not on iPhoneX 1`] = ` + + + + + + + + + Create a new wallet + + + + + Import using Secret Recovery Phrase + + + + + + + + + + Loading... + + + + + + + + + + + + + + + + + +  + + + + + Success + + + You successfully reset your wallet! + + + + + + + + + + +`; + exports[`Onboarding renders correctly 1`] = ` - Create a new wallet - - + Import using Secret Recovery Phrase - + @@ -193,7 +2096,16 @@ exports[`Onboarding renders correctly 1`] = ` } testID="fox-animation-mock" /> - + `; @@ -208,51 +2120,66 @@ exports[`Onboarding renders correctly with android 1`] = ` } } style={ - [ - { - "flex": 1, - }, - { - "backgroundColor": "#FFF2EB", - }, - ] + { + "backgroundColor": "#FFF2EB", + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, + } } testID="onboarding-screen" > - Create a new wallet - - + Import using Secret Recovery Phrase - + @@ -390,7 +2411,16 @@ exports[`Onboarding renders correctly with android 1`] = ` } testID="fox-animation-mock" /> - + `; @@ -405,51 +2435,66 @@ exports[`Onboarding renders correctly with large device and iphoneX 1`] = ` } } style={ - [ - { - "flex": 1, - }, - { - "backgroundColor": "#FFF2EB", - }, - ] + { + "backgroundColor": "#FFF2EB", + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, + } } testID="onboarding-screen" > - Create a new wallet - - + Import using Secret Recovery Phrase - + @@ -587,7 +2726,16 @@ exports[`Onboarding renders correctly with large device and iphoneX 1`] = ` } testID="fox-animation-mock" /> - + `; @@ -602,51 +2750,66 @@ exports[`Onboarding renders correctly with medium device and android 1`] = ` } } style={ - [ - { - "flex": 1, - }, - { - "backgroundColor": "#FFF2EB", - }, - ] + { + "backgroundColor": "#FFF2EB", + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, + } } testID="onboarding-screen" > - Create a new wallet - - + Import using Secret Recovery Phrase - + @@ -784,6 +3041,15 @@ exports[`Onboarding renders correctly with medium device and android 1`] = ` } testID="fox-animation-mock" /> - + `; diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 7320bb02f5e..8f59eef7433 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -18,6 +18,19 @@ jest.mock('../../../core/BackupVault', () => ({ ), })); +let mockSkipLoadingUnset = false; +jest.mock('../../../actions/user', () => { + const actualUserActions = jest.requireActual('../../../actions/user'); + return { + ...actualUserActions, + loadingUnset: jest.fn(() => + mockSkipLoadingUnset + ? { type: 'UNIT_TEST_NOOP' } + : actualUserActions.loadingUnset(), + ), + }; +}); + // Mock animation components - using existing mocks jest.mock('../../UI/FoxAnimation/FoxAnimation'); jest.mock('../../UI/OnboardingAnimation/OnboardingAnimation'); @@ -28,6 +41,15 @@ jest.mock('react-native-elevated-view', () => ({ default: jest.requireActual('react-native').View, })); +const MOCK_APP_VERSION = '7.0.0'; +const MOCK_BUILD_NUMBER = '1234'; +const MOCK_ONBOARDING_VERSION = `${MOCK_APP_VERSION} (${MOCK_BUILD_NUMBER})`; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn(() => MOCK_APP_VERSION), + getBuildNumber: jest.fn(() => MOCK_BUILD_NUMBER), +})); + import React from 'react'; import { InteractionManager, @@ -49,9 +71,12 @@ import Routes from '../../../constants/navigation/Routes'; import { ONBOARDING, PREVIOUS_SCREEN } from '../../../constants/navigation'; import { strings } from '../../../../locales/i18n'; import { OAuthError, OAuthErrorType } from '../../../core/OAuthService/error'; +import { IconName } from '../../../component-library/components/Icons/Icon'; import { captureException } from '@sentry/react-native'; import Logger from '../../../util/Logger'; import { MIGRATION_ERROR_HAPPENED } from '../../../constants/storage'; +import { AccountType } from '../../../constants/onboarding'; +import { MetaMetricsEvents } from '../../../core/Analytics'; // Mock netinfo - using existing mock jest.mock('@react-native-community/netinfo'); @@ -68,9 +93,22 @@ jest.mock('../../../util/test/utils', () => ({ import { fetch as netInfoFetch } from '@react-native-community/netinfo'; const mockNetInfoFetch = netInfoFetch as jest.Mock; +const mockNavigate = jest.fn(); +const mockReplace = jest.fn(); +const mockGoBack = jest.fn(); // Helper to flush all pending promises const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); +const IOS_GOOGLE_WARNING_TITLE = strings('error_sheet.ios_need_update_title'); +const IOS_GOOGLE_WARNING_BUTTON = strings('error_sheet.ios_need_update_button'); + +const getIosGoogleWarningSheetCall = () => + mockNavigate.mock.calls.find( + ([route, params]) => + route === Routes.MODAL.ROOT_MODAL_FLOW && + params?.screen === Routes.SHEET.SUCCESS_ERROR_SHEET && + params?.params?.title === IOS_GOOGLE_WARNING_TITLE, + ); const mockInitialState = { engine: { @@ -103,13 +141,22 @@ const mockInitialStateWithExistingUserAndPassword = { }, }; -jest.mock('../../../util/device', () => ({ - isLargeDevice: jest.fn(), - isIphoneX: jest.fn(), - isAndroid: jest.fn(), - isIos: jest.fn(), - isMediumDevice: jest.fn(), -})); +jest.mock('../../../util/device', () => { + const mockDevice = { + isLargeDevice: jest.fn(), + isIphoneX: jest.fn(), + isAndroid: jest.fn(), + isIos: jest.fn(), + isMediumDevice: jest.fn(), + comparePlatformVersionTo: jest.fn().mockReturnValue(1), + }; + + return { + __esModule: true, + default: mockDevice, + ...mockDevice, + }; +}); // expo library are not supported in jest ( unless using jest-expo as preset ), so we need to mock them jest.mock('../../../core/OAuthService/OAuthLoginHandlers', () => ({ @@ -252,13 +299,12 @@ jest.mock('../../../core/OAuthService/OAuthLoginHandlers/constants', () => ({ }, })); -const mockNavigate = jest.fn(); -const mockReplace = jest.fn(); const mockNav = { navigate: mockNavigate, replace: mockReplace, reset: jest.fn(), setOptions: jest.fn(), + goBack: mockGoBack, dispatch: jest.fn((action) => { if (action.type === 'REPLACE') { mockReplace(action.payload.name, action.payload.params); @@ -388,6 +434,127 @@ describe('Onboarding', () => { expect(toJSON()).toMatchSnapshot(); }); + it('applies compact gap and medium button size on medium device', () => { + (Device.isMediumDevice as jest.Mock).mockReturnValue(true); + + const { toJSON } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('applies standard gap and large button size on non-medium device', () => { + (Device.isMediumDevice as jest.Mock).mockReturnValue(false); + + const { toJSON } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders loading overlay with loading message', async () => { + mockSkipLoadingUnset = true; + const loadingMessage = 'Creating your wallet...'; + const loadingState = { + ...mockInitialState, + user: { + ...mockInitialState.user, + loadingSet: true, + loadingMsg: loadingMessage, + }, + }; + mockRoute.params = { delete: true }; + + try { + const { getByText } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: loadingState, + }, + ); + + await waitFor(() => { + expect(getByText(loadingMessage)).toBeOnTheScreen(); + }); + } finally { + mockRoute.params = {}; + mockSkipLoadingUnset = false; + } + }); + + it('applies iPhoneX notification padding when on iPhoneX', async () => { + mockSkipLoadingUnset = true; + const loadingState = { + ...mockInitialState, + user: { + ...mockInitialState.user, + loadingSet: true, + loadingMsg: 'Loading...', + }, + }; + mockRoute.params = { delete: true }; + (Device.isIphoneX as jest.Mock).mockReturnValue(true); + + try { + const { toJSON } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: loadingState, + }, + ); + + await waitFor(() => { + expect(toJSON()).toMatchSnapshot(); + }); + } finally { + mockRoute.params = {}; + mockSkipLoadingUnset = false; + } + }); + + it('applies standard notification padding when not on iPhoneX', async () => { + mockSkipLoadingUnset = true; + const loadingState = { + ...mockInitialState, + user: { + ...mockInitialState.user, + loadingSet: true, + loadingMsg: 'Loading...', + }, + }; + mockRoute.params = { delete: true }; + (Device.isIphoneX as jest.Mock).mockReturnValue(false); + + try { + const { toJSON } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: loadingState, + }, + ); + + await waitFor(() => { + expect(toJSON()).toMatchSnapshot(); + }); + } finally { + mockRoute.params = {}; + mockSkipLoadingUnset = false; + } + }); + it('handles click on create wallet button', () => { (Device.isAndroid as jest.Mock).mockReturnValue(true); (Device.isIos as jest.Mock).mockReturnValue(false); @@ -502,6 +669,38 @@ describe('Onboarding', () => { ); }); + it('stores the onboarding version on the first create wallet press', async () => { + mockSeedlessOnboardingEnabled.mockReturnValue(false); + (StorageWrapper.getItem as jest.Mock).mockResolvedValue(null); + + const { getByTestId, store } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const createWalletButton = getByTestId( + OnboardingSelectorIDs.NEW_WALLET_BUTTON, + ); + + await act(async () => { + fireEvent.press(createWalletButton); + await flushPromises(); + await flushPromises(); + }); + + await waitFor(() => { + expect(store.getState().onboarding).toEqual( + expect.objectContaining({ + accountType: AccountType.Metamask, + onboardingVersion: MOCK_ONBOARDING_VERSION, + }), + ); + }); + }); + it('navigates to offline error sheet when there is no internet', async () => { mockSeedlessOnboardingEnabled.mockReturnValue(true); (StorageWrapper.getItem as jest.Mock).mockResolvedValue(null); @@ -779,10 +978,13 @@ describe('Onboarding', () => { beforeEach(() => { mockSeedlessOnboardingEnabled.mockReturnValue(true); (StorageWrapper.getItem as jest.Mock).mockResolvedValue(null); + (Device.isIos as jest.Mock).mockReturnValue(false); + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(1); }); afterEach(() => { jest.clearAllMocks(); + mockNavigate.mockReset(); mockSeedlessOnboardingEnabled.mockReset(); }); @@ -1086,6 +1288,174 @@ describe('Onboarding', () => { ); }); + it('shows iOS version warning sheet before Google login on iOS < 17.4', async () => { + Platform.OS = 'ios'; + (Device.isIos as jest.Mock).mockReturnValue(true); + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(-1); + (mockAnalytics.isEnabled as jest.Mock).mockReturnValue(true); + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockResolvedValue({ + type: 'success', + existingUser: false, + accountName: 'test@example.com', + }); + + const { getByTestId } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const createWalletButton = getByTestId( + OnboardingSelectorIDs.NEW_WALLET_BUTTON, + ); + await act(async () => { + fireEvent.press(createWalletButton); + }); + + const navCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET, + ); + + const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle; + + await act(async () => { + await googleOAuthFunction(true); + await flushPromises(); + await flushPromises(); + }); + + // Verify the warning sheet was shown with the iOS not-supported message. + const warningSheetCall = getIosGoogleWarningSheetCall(); + + expect(warningSheetCall).toEqual([ + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: expect.objectContaining({ + type: 'error', + icon: IconName.Warning, + isInteractable: false, + title: IOS_GOOGLE_WARNING_TITLE, + description: expect.anything(), + primaryButtonLabel: IOS_GOOGLE_WARNING_BUTTON, + onPrimaryButtonPress: expect.any(Function), + closeOnPrimaryButtonPress: true, + }), + }), + ]); + expect(warningSheetCall?.[1].params.onPrimaryButtonPress).toEqual( + expect.any(Function), + ); + expect(Device.comparePlatformVersionTo).toHaveBeenCalledWith('17.4'); + + await act(async () => { + await warningSheetCall?.[1].params.onPrimaryButtonPress?.(); + await flushPromises(); + await flushPromises(); + }); + + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Wallet Google Ios Warning Viewed', + properties: expect.objectContaining({ + account_type: AccountType.MetamaskGoogle, + }), + }), + ); + expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'google'); + expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( + 'mockGoogleHandler', + false, + ); + }); + + it('shows iOS version warning for Google login on iOS < 17.4 during import wallet flow', async () => { + Platform.OS = 'ios'; + (Device.isIos as jest.Mock).mockReturnValue(true); + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(-1); + (mockAnalytics.isEnabled as jest.Mock).mockReturnValue(true); + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockResolvedValue({ + type: 'success', + existingUser: false, + accountName: 'test@example.com', + }); + + const { getByTestId } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const importWalletButton = getByTestId( + OnboardingSelectorIDs.EXISTING_WALLET_BUTTON, + ); + await act(async () => { + fireEvent.press(importWalletButton); + }); + + const navCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET, + ); + + const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle; + + await act(async () => { + await googleOAuthFunction(false); + await flushPromises(); + await flushPromises(); + }); + + const warningSheetCall = getIosGoogleWarningSheetCall(); + + expect(warningSheetCall).toBeDefined(); + expect(warningSheetCall?.[1].params).toEqual( + expect.objectContaining({ + type: 'error', + icon: IconName.Warning, + title: IOS_GOOGLE_WARNING_TITLE, + description: expect.anything(), + primaryButtonLabel: IOS_GOOGLE_WARNING_BUTTON, + onPrimaryButtonPress: expect.any(Function), + closeOnPrimaryButtonPress: true, + isInteractable: false, + }), + ); + expect(warningSheetCall?.[1].params.onPrimaryButtonPress).toEqual( + expect.any(Function), + ); + expect(Device.comparePlatformVersionTo).toHaveBeenCalledWith('17.4'); + + await act(async () => { + await warningSheetCall?.[1].params.onPrimaryButtonPress?.(); + await flushPromises(); + await flushPromises(); + }); + + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Wallet Google Ios Warning Viewed', + properties: expect.objectContaining({ + account_type: AccountType.ImportedGoogle, + }), + }), + ); + expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'google'); + expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( + 'mockGoogleHandler', + true, + ); + }); + it('navigates to AccountAlreadyExists for existing user in create wallet flow', async () => { mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); mockOAuthService.handleOAuthLogin.mockResolvedValue({ @@ -1176,6 +1546,56 @@ describe('Onboarding', () => { ); }); + it('does not navigate when OAuth login result type is not success', async () => { + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockResolvedValue({ + type: 'error', + existingUser: false, + }); + + const { getByTestId } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const createWalletButton = getByTestId( + OnboardingSelectorIDs.NEW_WALLET_BUTTON, + ); + await act(async () => { + fireEvent.press(createWalletButton); + }); + + const navCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET, + ); + + const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle; + + mockNavigate.mockClear(); + + await act(async () => { + await googleOAuthFunction(true); + }); + + expect(mockNavigate).not.toHaveBeenCalledWith( + 'ChoosePassword', + expect.anything(), + ); + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.ONBOARDING.SOCIAL_LOGIN_SUCCESS_NEW_USER, + expect.anything(), + ); + expect(mockNavigate).not.toHaveBeenCalledWith( + 'AccountAlreadyExists', + expect.anything(), + ); + }); + it('attempts browser fallback when no credential is available in Android', async () => { Platform.OS = 'android'; const noCredentialError = new OAuthError( @@ -1764,6 +2184,13 @@ describe('Onboarding', () => { await waitFor(() => { expect(mockAnalytics.optIn).toHaveBeenCalled(); + expect( + mockCreateEventBuilder.mock.calls.some( + (call) => + (call[0] as { category: string }).category === + MetaMetricsEvents.METRICS_OPT_IN.category, + ), + ).toBe(true); }); }); }); diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx index 53abd5c3400..3c105f2a087 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, + useMemo, useRef, useCallback, useContext, @@ -8,7 +9,6 @@ import React, { import { ActivityIndicator, BackHandler, - View, ScrollView, InteractionManager, Animated, @@ -16,10 +16,7 @@ import { Platform, } from 'react-native'; import { captureException } from '@sentry/react-native'; -import Text, { - TextVariant, -} from '../../../component-library/components/Texts/Text'; -import { baseStyles, colors as importedColors } from '../../../styles/common'; +import { colors as importedColors } from '../../../styles/common'; import { strings } from '../../../../locales/i18n'; import { useSelector, useDispatch } from 'react-redux'; import FadeOutOverlay from '../../UI/FadeOutOverlay'; @@ -52,7 +49,7 @@ import { markMetricsOptInUISeen, resetMetricsOptInUISeen, } from '../../../util/metrics/metricsOptInUIUtils'; -import { ThemeContext, mockTheme } from '../../../util/theme'; +import { ThemeContext } from '../../../util/theme'; import { isE2E } from '../../../util/test/utils'; import { OnboardingSelectorIDs } from './Onboarding.testIds'; import Routes from '../../../constants/navigation/Routes'; @@ -83,16 +80,11 @@ import { ITrackingEvent, } from '../../../core/Analytics/MetaMetrics.types'; import { JsonMap } from '@segment/analytics-react-native'; -import Button, { - ButtonVariants, - ButtonWidthTypes, - ButtonSize, -} from '../../../component-library/components/Buttons/Button'; +import { SEEDLESS_ONBOARDING_ENABLED } from '../../../core/OAuthService/OAuthLoginHandlers/constants'; import OAuthLoginService from '../../../core/OAuthService/OAuthService'; import { OAuthError, OAuthErrorType } from '../../../core/OAuthService/error'; import { createLoginHandler } from '../../../core/OAuthService/OAuthLoginHandlers'; import { AuthConnection } from '../../../core/OAuthService/OAuthInterface'; -import { SEEDLESS_ONBOARDING_ENABLED } from '../../../core/OAuthService/OAuthLoginHandlers/constants'; import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; import { setupSentry } from '../../../util/sentry/utils'; import ErrorBoundary from '../ErrorBoundary'; @@ -100,8 +92,29 @@ import FastOnboarding from './FastOnboarding'; import { SafeAreaView } from 'react-native-safe-area-context'; import FoxAnimation from '../../UI/FoxAnimation/FoxAnimation'; import OnboardingAnimation from '../../UI/OnboardingAnimation/OnboardingAnimation'; -import { createStyles } from './styles'; +import { + Box, + BoxAlignItems, + BoxJustifyContent, + Button, + ButtonSize, + ButtonVariant, + Text, + TextButton, + TextVariant, +} from '@metamask/design-system-react-native'; +import { + Theme, + ThemeProvider, + useTailwind, +} from '@metamask/design-system-twrnc-preset'; +import { getBuildNumber, getVersion } from 'react-native-device-info'; +import { navigateToSuccessErrorSheetPromise } from '../SuccessErrorSheet/utils'; +import { + IconColor, + IconName, +} from '../../../component-library/components/Icons/Icon'; interface OnboardingState { warningModalVisible: boolean; loading: boolean; @@ -130,6 +143,11 @@ interface OnboardingRouteParams { const Onboarding = () => { const navigation = useNavigation(); + const onboardingVersion = useMemo( + () => `${getVersion()} (${getBuildNumber()})`, + [], + ); + const route = useRoute>(); const dispatch = useDispatch(); @@ -162,8 +180,7 @@ const Onboarding = () => { ); const themeContext = useContext(ThemeContext); - const colors = themeContext.colors || mockTheme.colors; - const styles = createStyles(colors); + const tw = useTailwind(); const [state, setState] = useState({ warningModalVisible: false, @@ -185,6 +202,7 @@ const Onboarding = () => { const mounted = useRef(false); const hasCheckedVaultBackup = useRef(false); const warningCallback = useRef<() => boolean>(() => true); + const notificationTimer = useRef | null>(null); const animatedTimingStart = useCallback( (animatedRef: Animated.Value, toValue: number): void => { @@ -205,10 +223,8 @@ const Onboarding = () => { }, []); const showNotification = useCallback((): void => { - // show notification animatedTimingStart(notificationAnimated, 0); - // hide notification - setTimeout(() => { + notificationTimer.current = setTimeout(() => { animatedTimingStart(notificationAnimated, 200); }, 4000); disableBackPress(); @@ -357,7 +373,12 @@ const Onboarding = () => { [PREVIOUS_SCREEN]: ONBOARDING, onboardingTraceCtx: onboardingTraceCtx.current, }); - dispatch(setAccountType(AccountType.Metamask)); + dispatch( + setAccountType({ + accountType: AccountType.Metamask, + onboardingVersion, + }), + ); track(MetaMetricsEvents.WALLET_SETUP_STARTED, { account_type: AccountType.Metamask, }); @@ -365,7 +386,14 @@ const Onboarding = () => { handleExistingUser(action); endTrace({ name: TraceName.OnboardingCreateWallet }); - }, [metrics, navigation, track, handleExistingUser, dispatch]); + }, [ + metrics, + navigation, + track, + handleExistingUser, + dispatch, + onboardingVersion, + ]); const onPressImport = useCallback(async (): Promise => { if (SEEDLESS_ONBOARDING_ENABLED) { @@ -392,13 +420,25 @@ const Onboarding = () => { onboardingTraceCtx: onboardingTraceCtx.current, }, ); - dispatch(setAccountType(AccountType.Imported)); + dispatch( + setAccountType({ + accountType: AccountType.Imported, + onboardingVersion, + }), + ); track(MetaMetricsEvents.WALLET_IMPORT_STARTED, { account_type: AccountType.Imported, }); }; handleExistingUser(action); - }, [metrics, navigation, track, handleExistingUser, dispatch]); + }, [ + metrics, + navigation, + track, + handleExistingUser, + dispatch, + onboardingVersion, + ]); const handlePostSocialLogin = useCallback( ( @@ -412,86 +452,88 @@ const Onboarding = () => { socialLoginTraceCtx.current = undefined; } - if (result.type === 'success') { - const accountType = getSocialAccountType(provider, result.existingUser); - dispatch(setAccountType(accountType)); + // Error case (result.type !== 'success') is not handled here because + // OAuthService.handleOAuthLogin() throws on failure, and the error is + // caught by the try/catch in onPressContinueWithSocialLogin, which calls + // handleLoginError → handleOAuthLoginError → captureException (Sentry). + if (result.type !== 'success') { + return; + } - track(MetaMetricsEvents.SOCIAL_LOGIN_COMPLETED, { - account_type: accountType, - }); - if (createWallet) { - if (result.existingUser) { - navigation.navigate('AccountAlreadyExists', { - accountName: result.accountName, - oauthLoginSuccess: true, - onboardingTraceCtx: onboardingTraceCtx.current, - provider, - }); - } else { - trace({ - name: TraceName.OnboardingNewSocialCreateWallet, - op: TraceOperation.OnboardingUserJourney, - tags: getTraceTags(store.getState()), - parentContext: onboardingTraceCtx.current, - }); + const accountType = getSocialAccountType(provider, result.existingUser); + dispatch(setAccountType({ accountType, onboardingVersion })); - if (isIOS) { - // Navigate to SocialLoginSuccess screen first, then ChoosePassword - navigation.navigate( - Routes.ONBOARDING.SOCIAL_LOGIN_SUCCESS_NEW_USER, - { - accountName: result.accountName, - oauthLoginSuccess: true, - onboardingTraceCtx: onboardingTraceCtx.current, - provider, - }, - ); - } else { - // Direct navigation to ChoosePassword for Android - navigation.navigate('ChoosePassword', { - [PREVIOUS_SCREEN]: ONBOARDING, + track(MetaMetricsEvents.SOCIAL_LOGIN_COMPLETED, { + account_type: accountType, + }); + if (createWallet) { + if (result.existingUser) { + navigation.navigate('AccountAlreadyExists', { + accountName: result.accountName, + oauthLoginSuccess: true, + onboardingTraceCtx: onboardingTraceCtx.current, + provider, + }); + } else { + trace({ + name: TraceName.OnboardingNewSocialCreateWallet, + op: TraceOperation.OnboardingUserJourney, + tags: getTraceTags(store.getState()), + parentContext: onboardingTraceCtx.current, + }); + + if (isIOS) { + // Navigate to SocialLoginSuccess screen first, then ChoosePassword + navigation.navigate( + Routes.ONBOARDING.SOCIAL_LOGIN_SUCCESS_NEW_USER, + { + accountName: result.accountName, oauthLoginSuccess: true, onboardingTraceCtx: onboardingTraceCtx.current, provider, - }); - } - } - } else if (!createWallet) { - if (result.existingUser) { - trace({ - name: TraceName.OnboardingExistingSocialLogin, - op: TraceOperation.OnboardingUserJourney, - tags: getTraceTags(store.getState()), - parentContext: onboardingTraceCtx.current, - }); - isIOS - ? navigation.navigate( - Routes.ONBOARDING.SOCIAL_LOGIN_SUCCESS_EXISTING_USER, - { - [PREVIOUS_SCREEN]: ONBOARDING, - oauthLoginSuccess: true, - onboardingTraceCtx: onboardingTraceCtx.current, - }, - ) - : navigation.navigate('Rehydrate', { - [PREVIOUS_SCREEN]: ONBOARDING, - oauthLoginSuccess: true, - onboardingTraceCtx: onboardingTraceCtx.current, - }); + }, + ); } else { - navigation.navigate('AccountNotFound', { - accountName: result.accountName, + // Direct navigation to ChoosePassword for Android + navigation.navigate('ChoosePassword', { + [PREVIOUS_SCREEN]: ONBOARDING, oauthLoginSuccess: true, onboardingTraceCtx: onboardingTraceCtx.current, provider, }); } } + } else if (result.existingUser) { + trace({ + name: TraceName.OnboardingExistingSocialLogin, + op: TraceOperation.OnboardingUserJourney, + tags: getTraceTags(store.getState()), + parentContext: onboardingTraceCtx.current, + }); + isIOS + ? navigation.navigate( + Routes.ONBOARDING.SOCIAL_LOGIN_SUCCESS_EXISTING_USER, + { + [PREVIOUS_SCREEN]: ONBOARDING, + oauthLoginSuccess: true, + onboardingTraceCtx: onboardingTraceCtx.current, + }, + ) + : navigation.navigate('Rehydrate', { + [PREVIOUS_SCREEN]: ONBOARDING, + oauthLoginSuccess: true, + onboardingTraceCtx: onboardingTraceCtx.current, + }); } else { - // handle error: show error message in the UI + navigation.navigate('AccountNotFound', { + accountName: result.accountName, + oauthLoginSuccess: true, + onboardingTraceCtx: onboardingTraceCtx.current, + provider, + }); } }, - [navigation, track, dispatch], + [navigation, track, dispatch, onboardingVersion], ); const handleOAuthLoginError = useCallback( @@ -696,6 +738,18 @@ const Onboarding = () => { discardBufferedTraces(); await setupSentry(); + const accountType = getSocialAccountType(provider, !createWallet); + metrics.trackEvent( + metrics + .createEventBuilder(MetaMetricsEvents.METRICS_OPT_IN) + .addProperties({ + updated_after_onboarding: false, + location: 'onboarding_social_login', + account_type: accountType, + }) + .build(), + ); + // use new trace instead of buffered trace for social login onboardingTraceCtx.current = trace({ name: TraceName.OnboardingJourneyOverall, @@ -703,7 +757,6 @@ const Onboarding = () => { tags: getTraceTags(store.getState()), }); - const accountType = getSocialAccountType(provider, !createWallet); if (createWallet) { track(MetaMetricsEvents.WALLET_SETUP_STARTED, { account_type: accountType, @@ -722,6 +775,41 @@ const Onboarding = () => { }); const action = async () => { + // prompt for ios google login not supported below iOS 17.4 + if ( + provider === AuthConnection.Google && + Device.isIos() && + Device.comparePlatformVersionTo('17.4') < 0 + ) { + const description = () => ( + <> + + {strings(`error_sheet.ios_need_update_description`)} + + {strings(`error_sheet.ios_need_update_description_version`)} + + {strings(`error_sheet.ios_need_update_description_end`)} + + + {strings(`error_sheet.ios_need_update_description2`)} + + + ); + + await navigateToSuccessErrorSheetPromise(navigation, { + type: 'error', + icon: IconName.Warning, + iconColor: IconColor.Warning, + title: strings(`error_sheet.ios_need_update_title`), + description: description(), + primaryButtonLabel: strings(`error_sheet.ios_need_update_button`), + closeOnPrimaryButtonPress: true, + isInteractable: false, + }); + track(MetaMetricsEvents.WALLET_GOOGLE_IOS_WARNING_VIEWED, { + account_type: accountType, + }); + } setLoading(); const loginHandler = createLoginHandler(Platform.OS, provider); try { @@ -751,6 +839,7 @@ const Onboarding = () => { handleExistingUser(action); }, [ + tw, navigation, metrics, track, @@ -809,66 +898,75 @@ const Onboarding = () => { const renderLoader = useCallback( (): React.ReactElement => ( - - + + - {loadingMsg} - - + + {loadingMsg} + + + ), - [styles, loadingMsg], + [loadingMsg, tw], ); const renderContent = useCallback( (): React.ReactElement => ( - + - + + + + - + ), - [ - styles, - state.startOnboardingAnimation, - setStartFoxAnimation, - handleCtaActions, - ], + [state.startOnboardingAnimation, setStartFoxAnimation, handleCtaActions], ); const handleSimpleNotification = @@ -891,11 +989,17 @@ const Onboarding = () => { return ( - + @@ -903,8 +1007,8 @@ const Onboarding = () => { }, [ route?.params?.delete, route?.params?.showErrorReportSentToast, - styles, notificationAnimated, + tw, ]); useEffect(() => { @@ -934,6 +1038,9 @@ const Onboarding = () => { return () => { mounted.current = false; + if (notificationTimer.current) { + clearTimeout(notificationTimer.current); + } unsetLoading(); InteractionManager.runAfterInteractions(PreventScreenshot.allow); }; @@ -970,51 +1077,51 @@ const Onboarding = () => { > - + {renderContent()} {loading && ( - {renderLoader()} - + )} - + {existingUser && !loading && ( - - + + + + {strings('onboarding.or')} - - - + + + + + {strings('onboarding.by_continuing')}{' '} {strings('onboarding.terms_of_use')} {' '} {strings('onboarding.and')}{' '} {strings('onboarding.privacy_notice')} - - + + ); }; diff --git a/app/components/Views/OnboardingSuccess/DefaultSettings/__snapshots__/index.test.tsx.snap b/app/components/Views/OnboardingSuccess/DefaultSettings/__snapshots__/index.test.tsx.snap index 77257546384..2e791440277 100644 --- a/app/components/Views/OnboardingSuccess/DefaultSettings/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/OnboardingSuccess/DefaultSettings/__snapshots__/index.test.tsx.snap @@ -4,28 +4,39 @@ exports[`DefaultSettings should render correctly 1`] = ` MetaMask uses default settings to best balance safety and ease of use. Change these settings to further increase your privacy. @@ -33,13 +44,17 @@ exports[`DefaultSettings should render correctly 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + undefined, + ] } > diff --git a/app/components/Views/OnboardingSuccess/DefaultSettings/index.styles.ts b/app/components/Views/OnboardingSuccess/DefaultSettings/index.styles.ts deleted file mode 100644 index e16667ff086..00000000000 --- a/app/components/Views/OnboardingSuccess/DefaultSettings/index.styles.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { StyleSheet } from 'react-native'; - -const styleSheet = () => - StyleSheet.create({ - root: { - flex: 1, - }, - scrollRoot: { - flex: 1, - paddingTop: 16, - paddingHorizontal: 16, - }, - textContainer: { - paddingHorizontal: 16, - }, - }); - -export default styleSheet; diff --git a/app/components/Views/OnboardingSuccess/DefaultSettings/index.tsx b/app/components/Views/OnboardingSuccess/DefaultSettings/index.tsx index 5133090bd6f..0d8bee410e4 100644 --- a/app/components/Views/OnboardingSuccess/DefaultSettings/index.tsx +++ b/app/components/Views/OnboardingSuccess/DefaultSettings/index.tsx @@ -1,21 +1,22 @@ import React from 'react'; -import { ScrollView, Linking, View } from 'react-native'; +import { ScrollView, Linking } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useOnboardingHeader } from '../../../hooks/useOnboardingHeader'; -import { useStyles } from '../../../../component-library/hooks'; -import Text, { - TextVariant, +import { + Box, + Text, TextColor, -} from '../../../../component-library/components/Texts/Text'; + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import Routes from '../../../../constants/navigation/Routes'; import { strings } from '../../../../../locales/i18n'; import AppConstants from '../../../../core/AppConstants'; import SettingsDrawer from '../../../UI/SettingsDrawer'; -import styleSheet from './index.styles'; const DefaultSettings = () => { useOnboardingHeader(strings('default_settings.default_settings')); - const { styles } = useStyles(styleSheet, {}); + const tw = useTailwind(); const navigation = useNavigation(); const handleLink = () => { @@ -23,16 +24,16 @@ const DefaultSettings = () => { }; return ( - - - + + + {strings('default_settings.description')} - + {' '} {strings('default_settings.learn_more_about_privacy')} - + diff --git a/app/components/Views/OnboardingSuccess/OnboardingAssetsSettings/index.styles.ts b/app/components/Views/OnboardingSuccess/OnboardingAssetsSettings/index.styles.ts deleted file mode 100644 index 59542844a68..00000000000 --- a/app/components/Views/OnboardingSuccess/OnboardingAssetsSettings/index.styles.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { StyleSheet } from 'react-native'; - -const styleSheet = () => - StyleSheet.create({ - root: { - flex: 1, - paddingHorizontal: 16, - paddingVertical: 8, - paddingBottom: 16, - }, - contentContainerStyle: { - paddingBottom: 75, - }, - }); - -export default styleSheet; diff --git a/app/components/Views/OnboardingSuccess/OnboardingAssetsSettings/index.tsx b/app/components/Views/OnboardingSuccess/OnboardingAssetsSettings/index.tsx index 135ba680880..1035e118ef8 100644 --- a/app/components/Views/OnboardingSuccess/OnboardingAssetsSettings/index.tsx +++ b/app/components/Views/OnboardingSuccess/OnboardingAssetsSettings/index.tsx @@ -2,22 +2,21 @@ import React from 'react'; import { ScrollView } from 'react-native'; import { useOnboardingHeader } from '../../../hooks/useOnboardingHeader'; import { strings } from '../../../../../locales/i18n'; -import { useStyles } from '../../../../component-library/hooks'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import AutoDetectTokensSettings from '../../Settings/AutoDetectTokensSettings'; import DisplayNFTMediaSettings from '../../Settings/DisplayNFTMediaSettings'; import AutoDetectNFTSettings from '../../Settings/AutoDetectNFTSettings'; import IPFSGatewaySettings from '../../Settings/IPFSGatewaySettings'; import BatchAccountBalanceSettings from '../../Settings/BatchAccountBalanceSettings'; -import styleSheet from './index.styles'; const AssetSettings = () => { useOnboardingHeader(strings('default_settings.drawer_assets_title')); - const { styles } = useStyles(styleSheet, {}); + const tw = useTailwind(); return ( diff --git a/app/components/Views/OnboardingSuccess/OnboardingGeneralSettings/__snapshots__/index.test.tsx.snap b/app/components/Views/OnboardingSuccess/OnboardingGeneralSettings/__snapshots__/index.test.tsx.snap index 797a634cb5b..808af6353da 100644 --- a/app/components/Views/OnboardingSuccess/OnboardingGeneralSettings/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/OnboardingSuccess/OnboardingGeneralSettings/__snapshots__/index.test.tsx.snap @@ -12,15 +12,20 @@ exports[`OnboardingGeneralSettings should render correctly 1`] = ` } style={ { - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > { useOnboardingHeader(strings('default_settings.drawer_general_title')); - const { styles } = useStyles(styleSheet, {}); + const tw = useTailwind(); const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useMetrics(); const isBasicFunctionalityEnabled = useSelector( @@ -56,8 +55,8 @@ const GeneralSettings = () => { }; return ( - - + + { - const { styles } = useStyles(styleSheet, {}); + const tw = useTailwind(); const { isEnabled } = useMetrics(); const analyticsEnabled = isEnabled(); @@ -25,7 +24,7 @@ const SecuritySettings = () => { useOnboardingHeader(strings('default_settings.drawer_security_title')); return ( - + {shouldShowSocialLoginFeatures && ( <> diff --git a/app/components/Views/OnboardingSuccess/OnboardingSuccessEndAnimation/index.styles.ts b/app/components/Views/OnboardingSuccess/OnboardingSuccessEndAnimation/index.styles.ts deleted file mode 100644 index cb18a7dba25..00000000000 --- a/app/components/Views/OnboardingSuccess/OnboardingSuccessEndAnimation/index.styles.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { getScreenDimensions } from '../../../../util/onboarding'; - -const createStyles = (dimensions: ReturnType) => - StyleSheet.create({ - animationContainer: { - height: dimensions.screenHeight * 0.5, - justifyContent: 'center', - alignItems: 'center', - }, - animationWrapper: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - riveAnimation: { - width: dimensions.screenWidth, - height: dimensions.animationHeight, - alignSelf: 'center', - }, - }); - -export default createStyles; diff --git a/app/components/Views/OnboardingSuccess/OnboardingSuccessEndAnimation/index.tsx b/app/components/Views/OnboardingSuccess/OnboardingSuccessEndAnimation/index.tsx index 4591e198fb3..e3a82b983f8 100644 --- a/app/components/Views/OnboardingSuccess/OnboardingSuccessEndAnimation/index.tsx +++ b/app/components/Views/OnboardingSuccess/OnboardingSuccessEndAnimation/index.tsx @@ -1,10 +1,14 @@ -import React, { useEffect, useMemo, useRef } from 'react'; -import { View } from 'react-native'; +import React, { useEffect, useRef } from 'react'; import Rive, { Fit, Alignment, RiveRef } from 'rive-react-native'; import { useTheme } from '../../../../util/theme'; -import createStyles from './index.styles'; import { getScreenDimensions } from '../../../../util/onboarding'; import { isE2E } from '../../../../util/test/utils'; +import { + Box, + BoxAlignItems, + BoxJustifyContent, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import onboardingLoaderEndAnimation from '../../../../animations/onboarding_loader.riv'; @@ -18,26 +22,20 @@ const OnboardingSuccessEndAnimation: React.FC< const riveRef = useRef(null); const { themeAppearance } = useTheme(); const isDarkMode = themeAppearance === 'dark'; + const tw = useTailwind(); - const screenDimensions = getScreenDimensions(); - - const styles = useMemo( - () => createStyles(screenDimensions), - [screenDimensions], - ); + const { screenWidth, screenHeight, animationHeight } = getScreenDimensions(); useEffect(() => { if (isE2E) return; const timeoutId = setTimeout(() => { if (riveRef.current) { try { - // Set dark mode input riveRef.current.setInputState( 'OnboardingLoader', 'Dark mode', isDarkMode, ); - // Fire the animation trigger riveRef.current.fireState('OnboardingLoader', 'Only_End'); } catch (error) { console.error('Error with Rive animation:', error); @@ -49,23 +47,32 @@ const OnboardingSuccessEndAnimation: React.FC< }, [isDarkMode]); return ( - - + {!isE2E && ( )} - - + + ); }; diff --git a/app/components/Views/OnboardingSuccess/__snapshots__/index.test.tsx.snap b/app/components/Views/OnboardingSuccess/__snapshots__/index.test.tsx.snap index e63ed5a0449..956ca196e32 100644 --- a/app/components/Views/OnboardingSuccess/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/OnboardingSuccess/__snapshots__/index.test.tsx.snap @@ -13,45 +13,71 @@ exports[`OnboardingSuccess route params handling uses default successFlow when r style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -91,56 +124,109 @@ exports[`OnboardingSuccess route params handling uses default successFlow when r - Done - + Manage default settings @@ -178,45 +268,71 @@ exports[`OnboardingSuccess route params handling uses default successFlow when s style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -256,56 +379,109 @@ exports[`OnboardingSuccess route params handling uses default successFlow when s - Done - + Manage default settings @@ -343,45 +523,71 @@ exports[`OnboardingSuccess route params successFlow is BACKED_UP_SRP renders mat style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -421,56 +634,109 @@ exports[`OnboardingSuccess route params successFlow is BACKED_UP_SRP renders mat - Done - + Manage default settings @@ -508,45 +778,71 @@ exports[`OnboardingSuccess route params successFlow is IMPORT_FROM_SEED_PHRASE f style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -586,56 +889,109 @@ exports[`OnboardingSuccess route params successFlow is IMPORT_FROM_SEED_PHRASE f - Done - + Manage default settings @@ -673,45 +1033,71 @@ exports[`OnboardingSuccess route params successFlow is IMPORT_FROM_SEED_PHRASE r style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -751,56 +1144,109 @@ exports[`OnboardingSuccess route params successFlow is IMPORT_FROM_SEED_PHRASE r - Done - + Manage default settings @@ -838,45 +1288,71 @@ exports[`OnboardingSuccess route params successFlow is NO_BACKED_UP_SRP renders style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -916,56 +1399,109 @@ exports[`OnboardingSuccess route params successFlow is NO_BACKED_UP_SRP renders - Done - + Manage default settings @@ -1003,45 +1543,71 @@ exports[`OnboardingSuccessComponent renders matching snapshot when successFlow i style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -1081,56 +1654,109 @@ exports[`OnboardingSuccessComponent renders matching snapshot when successFlow i - Done - + Manage default settings @@ -1168,45 +1798,71 @@ exports[`OnboardingSuccessComponent renders matching snapshot when successFlow i style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -1246,56 +1909,109 @@ exports[`OnboardingSuccessComponent renders matching snapshot when successFlow i - Done - + Manage default settings @@ -1333,45 +2053,71 @@ exports[`OnboardingSuccessComponent renders matching snapshot when successFlow i style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -1411,56 +2164,109 @@ exports[`OnboardingSuccessComponent renders matching snapshot when successFlow i - Done - + Manage default settings diff --git a/app/components/Views/OnboardingSuccess/index.styles.ts b/app/components/Views/OnboardingSuccess/index.styles.ts deleted file mode 100644 index 7445e3b02a9..00000000000 --- a/app/components/Views/OnboardingSuccess/index.styles.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { ThemeColors } from '@metamask/design-tokens'; - -const createStyles = (colors: ThemeColors) => - StyleSheet.create({ - root: { - flex: 1, - backgroundColor: colors.background.default, - }, - container: { - flex: 1, - paddingHorizontal: 16, - }, - animationSection: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - buttonSection: { - paddingBottom: 4, - alignItems: 'center', - rowGap: 12, - }, - textTitle: { - marginTop: 25, - marginBottom: 16, - marginHorizontal: 16, - textAlign: 'center', - fontFamily: 'MMSans-Regular', - }, - footerLink: { - paddingVertical: 8, - alignItems: 'center', - }, - }); - -export default createStyles; diff --git a/app/components/Views/OnboardingSuccess/index.test.tsx b/app/components/Views/OnboardingSuccess/index.test.tsx index 4ffa6fd7d1f..93e4e119ff2 100644 --- a/app/components/Views/OnboardingSuccess/index.test.tsx +++ b/app/components/Views/OnboardingSuccess/index.test.tsx @@ -17,7 +17,8 @@ import { useSelector } from 'react-redux'; import { TextColor, TextVariant, -} from '../../../component-library/components/Texts/Text/Text.types'; + FontWeight, +} from '@metamask/design-system-react-native'; import { ReactTestInstance } from 'react-test-renderer'; jest.mock('../../../core/Engine/Engine', () => ({ @@ -144,7 +145,7 @@ describe('OnboardingSuccessComponent', () => { />, ); const button = getByTestId(OnboardingSuccessSelectorIDs.DONE_BUTTON); - button.props.onPress(); + fireEvent.press(button); expect(mockDiscoverAccounts).toHaveBeenCalled(); }); @@ -213,8 +214,9 @@ describe('OnboardingSuccessComponent', () => { ); const footerText = footerButton.children[0] as ReactTestInstance; - expect(footerText.props.color).toBe(TextColor.Info); - expect(footerText.props.variant).toBe(TextVariant.BodyMDMedium); + expect(footerText.props.color).toBe(TextColor.InfoDefault); + expect(footerText.props.variant).toBe(TextVariant.BodyMd); + expect(footerText.props.fontWeight).toBe(FontWeight.Medium); }); it('hides manage default settings button for SETTINGS_BACKUP flow', () => { @@ -321,6 +323,14 @@ describe('OnboardingSuccess', () => { }); describe('route params handling', () => { + it('uses default successFlow when route is undefined', () => { + const { getByText } = renderWithProvider(); + + expect( + getByText(strings('onboarding_success.wallet_ready')), + ).toBeOnTheScreen(); + }); + it('uses default successFlow when route params are undefined', () => { const routeWithNoParams = { params: undefined, diff --git a/app/components/Views/OnboardingSuccess/index.tsx b/app/components/Views/OnboardingSuccess/index.tsx index 51f35b008e3..7bc4db23f20 100644 --- a/app/components/Views/OnboardingSuccess/index.tsx +++ b/app/components/Views/OnboardingSuccess/index.tsx @@ -1,32 +1,34 @@ -import React, { useCallback, useLayoutEffect, useMemo } from 'react'; -import { View, TouchableOpacity } from 'react-native'; +import React, { useCallback, useLayoutEffect } from 'react'; +import { TouchableOpacity } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../component-library/components/Buttons/Button'; -import Text from '../../../component-library/components/Texts/Text'; -import { - TextColor, - TextVariant, -} from '../../../component-library/components/Texts/Text/Text.types'; import { CommonActions, - useNavigation, RouteProp, + useNavigation, } from '@react-navigation/native'; import { strings } from '../../../../locales/i18n'; import Routes from '../../../constants/navigation/Routes'; -import { useTheme } from '../../../util/theme'; import { OnboardingSuccessSelectorIDs } from './OnboardingSuccess.testIds'; -import createStyles from './index.styles'; import OnboardingSuccessEndAnimation from './OnboardingSuccessEndAnimation/index'; import { ONBOARDING_SUCCESS_FLOW } from '../../../constants/onboarding'; import Engine from '../../../core/Engine/Engine'; import { discoverAccounts } from '../../../multichain-accounts/discovery'; +import { + Box, + BoxAlignItems, + BoxJustifyContent, + Button, + ButtonSize, + ButtonVariant, + FontFamily, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; export const ResetNavigationToHome = CommonActions.reset({ index: 0, @@ -43,7 +45,7 @@ interface OnboardingSuccessParamList { } interface OnboardingSuccessScreenProps { - route: RouteProp; + route?: RouteProp; } interface OnboardingSuccessProps { @@ -57,8 +59,7 @@ export const OnboardingSuccessComponent: React.FC = ({ }) => { const navigation = useNavigation(); - const { colors } = useTheme(); - const styles = useMemo(() => createStyles(colors), [colors]); + const tw = useTailwind(); useLayoutEffect(() => { navigation.setOptions({ @@ -95,14 +96,20 @@ export const OnboardingSuccessComponent: React.FC = ({ // No-op: Animation completion not needed in success mode }} /> - + {getTitleString()} ); const renderFooter = () => { - // Hide default settings for settings backup flow if (successFlow === ONBOARDING_SUCCESS_FLOW.SETTINGS_BACKUP) { return null; } @@ -111,9 +118,13 @@ export const OnboardingSuccessComponent: React.FC = ({ - + {strings('onboarding_success.manage_default_settings')} @@ -121,25 +132,35 @@ export const OnboardingSuccessComponent: React.FC = ({ }; return ( - - + - {renderContent()} - - + + {renderContent()} + + + {renderFooter()} - - + + ); }; diff --git a/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.test.tsx b/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.test.tsx index e297d9c708e..acfb229596a 100644 --- a/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.test.tsx +++ b/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.test.tsx @@ -7,6 +7,7 @@ import Routes from '../../../constants/navigation/Routes'; import { Linking } from 'react-native'; import { createStackNavigator } from '@react-navigation/stack'; import { MetaMetricsEvents } from '../../../core/Analytics'; +import Engine from '../../../core/Engine'; const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); @@ -53,6 +54,38 @@ jest.mock('react-native', () => ({ }, })); +jest.mock('../../../core/Engine', () => ({ + controllerMessenger: { + call: jest.fn(), + }, +})); + +jest.mock( + '../../../component-library/components/BottomSheets/BottomSheet', + () => { + const { forwardRef, useImperativeHandle } = + jest.requireActual('react'); + const { View } = + jest.requireActual('react-native'); + + const MockBottomSheet = forwardRef< + { onCloseBottomSheet: () => void }, + { children: React.ReactNode; onClose?: () => void } + >(({ children, onClose }, ref) => { + useImperativeHandle(ref, () => ({ + onCloseBottomSheet: () => onClose?.(), + })); + return {children}; + }); + MockBottomSheet.displayName = 'MockBottomSheet'; + + return { + __esModule: true, + default: MockBottomSheet, + }; + }, +); + const Stack = createStackNavigator(); const renderComponent = (state = {}) => @@ -206,4 +239,52 @@ describe('Pna25BottomSheet', () => { expect(mockDispatch).toHaveBeenCalled(); }); + + it('skips initial delay when confirm button is pressed', () => { + const { getByText } = renderComponent(); + const confirmButton = getByText( + strings('privacy_policy.pna25_confirm_button'), + ); + + fireEvent.press(confirmButton); + + expect(Engine.controllerMessenger.call).toHaveBeenCalledWith( + 'ProfileMetricsController:skipInitialDelay', + ); + }); + + it('does not skip initial delay on view', () => { + renderComponent(); + + expect(Engine.controllerMessenger.call).not.toHaveBeenCalled(); + }); + + it('does not skip initial delay when open settings button is pressed', () => { + const { getByText } = renderComponent(); + const openSettingsButton = getByText( + strings('privacy_policy.pna25_open_settings_button'), + ); + + fireEvent.press(openSettingsButton); + + expect(Engine.controllerMessenger.call).not.toHaveBeenCalledWith( + 'ProfileMetricsController:skipInitialDelay', + ); + }); + + it('calls skipInitialDelay exactly once when confirm button triggers both accept and close actions', () => { + const { getByText } = renderComponent(); + const confirmButton = getByText( + strings('privacy_policy.pna25_confirm_button'), + ); + + fireEvent.press(confirmButton); + + const skipDelayCalls = jest + .mocked(Engine.controllerMessenger.call) + .mock.calls.filter( + ([action]) => action === 'ProfileMetricsController:skipInitialDelay', + ); + expect(skipDelayCalls).toHaveLength(1); + }); }); diff --git a/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.tsx b/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.tsx index 1ab946c218b..e0868e1e765 100644 --- a/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.tsx +++ b/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.tsx @@ -24,6 +24,7 @@ import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../core/Analytics'; import Routes from '../../../constants/navigation/Routes'; import { storePna25Acknowledged } from '../../../actions/legalNotices'; +import Engine from '../../../core/Engine'; export enum Pna25BottomSheetAction { VIEWED = 'viewed', @@ -38,6 +39,7 @@ const Pna25BottomSheet = () => { const navigation = useNavigation(); const tw = useTailwind(); const sheetRef = useRef(null); + const hasSkippedDelay = useRef(false); const { trackEvent, createEventBuilder } = useAnalytics(); const handleAction = useCallback( @@ -46,6 +48,19 @@ const Pna25BottomSheet = () => { dispatch(storePna25Acknowledged()); } + const shouldSkipDelay = [ + Pna25BottomSheetAction.ACCEPT_AND_CLOSE, + Pna25BottomSheetAction.CLOSED, + Pna25BottomSheetAction.LEAVE, + ].includes(action); + + if (shouldSkipDelay && !hasSkippedDelay.current) { + hasSkippedDelay.current = true; + Engine.controllerMessenger.call( + 'ProfileMetricsController:skipInitialDelay', + ); + } + // Don't emit events for the default close action to avoid double tracking if (action === Pna25BottomSheetAction.LEAVE) { return; diff --git a/app/components/Views/QRScanner/index.tsx b/app/components/Views/QRScanner/index.tsx index 9b99a481f76..c653c58ae84 100644 --- a/app/components/Views/QRScanner/index.tsx +++ b/app/components/Views/QRScanner/index.tsx @@ -47,7 +47,7 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import { QRType, QRScannerEventProperties, ScanResult } from './constants'; import { getQRType } from './utils'; -const frameImage = require('../../../images/frame.png'); // eslint-disable-line import/no-commonjs +const frameImage = require('../../../images/frame.png'); // eslint-disable-line import-x/no-commonjs /** * View that wraps the QR code scanner screen diff --git a/app/components/Views/Quiz/QuizContent/index.ts b/app/components/Views/Quiz/QuizContent/index.ts index 9c8890dc52b..0e1f2098f01 100644 --- a/app/components/Views/Quiz/QuizContent/index.ts +++ b/app/components/Views/Quiz/QuizContent/index.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import QuizContent from './QuizContent'; export { QuizContent }; diff --git a/app/components/Views/Quiz/SRPQuiz/SRPQuiz.tsx b/app/components/Views/Quiz/SRPQuiz/SRPQuiz.tsx index ca5a7cd6b85..ea55630c975 100644 --- a/app/components/Views/Quiz/SRPQuiz/SRPQuiz.tsx +++ b/app/components/Views/Quiz/SRPQuiz/SRPQuiz.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, import/no-commonjs */ +/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, import-x/no-commonjs */ import React, { useState, useCallback, useEffect, useRef } from 'react'; import { View, Linking, AppState } from 'react-native'; import { useNavigation } from '@react-navigation/native'; diff --git a/app/components/Views/Quiz/SRPQuiz/index.ts b/app/components/Views/Quiz/SRPQuiz/index.ts index ef957ff7e1b..ac22e96ef1f 100644 --- a/app/components/Views/Quiz/SRPQuiz/index.ts +++ b/app/components/Views/Quiz/SRPQuiz/index.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import SRPQuiz from './SRPQuiz'; export { SRPQuiz }; diff --git a/app/components/Views/Quiz/index.ts b/app/components/Views/Quiz/index.ts index 0d75946c457..fc7c5e9cb3c 100644 --- a/app/components/Views/Quiz/index.ts +++ b/app/components/Views/Quiz/index.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { SRPQuiz } from './SRPQuiz'; export { SRPQuiz }; diff --git a/app/components/Views/RestoreWallet/RestoreWallet.test.tsx b/app/components/Views/RestoreWallet/RestoreWallet.test.tsx index 3cc8931a8f5..0c68a222d44 100644 --- a/app/components/Views/RestoreWallet/RestoreWallet.test.tsx +++ b/app/components/Views/RestoreWallet/RestoreWallet.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Image, ActivityIndicator } from 'react-native'; +import { Image } from 'react-native'; import { fireEvent, waitFor } from '@testing-library/react-native'; import RestoreWallet from './RestoreWallet'; import Routes from '../../../constants/navigation/Routes'; @@ -93,6 +93,12 @@ describe('RestoreWallet', () => { const imageElement = UNSAFE_getByType(Image); expect(imageElement).toBeTruthy(); }); + + it('renders component tree correctly', () => { + const { toJSON } = renderWithProvider(); + + expect(toJSON()).toMatchSnapshot(); + }); }); describe('analytics tracking', () => { @@ -150,7 +156,7 @@ describe('RestoreWallet', () => { }); }); - it('shows loading indicator while restoring', async () => { + it('triggers vault restore on button press', async () => { let resolveRestore: (value: { success: boolean }) => void; const restorePromise = new Promise<{ success: boolean }>((resolve) => { resolveRestore = resolve; @@ -158,15 +164,13 @@ describe('RestoreWallet', () => { (EngineService.initializeVaultFromBackup as jest.Mock).mockReturnValue( restorePromise, ); - const { getByText, UNSAFE_getByType } = renderWithProvider( - , - ); + const { getByText } = renderWithProvider(); fireEvent.press( getByText(strings('restore_wallet.restore_needed_action')), ); - expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy(); + expect(EngineService.initializeVaultFromBackup).toHaveBeenCalled(); // @ts-expect-error resolveRestore is assigned in Promise constructor resolveRestore({ success: true }); diff --git a/app/components/Views/RestoreWallet/RestoreWallet.tsx b/app/components/Views/RestoreWallet/RestoreWallet.tsx index c3af43d18fe..0cc7d945791 100644 --- a/app/components/Views/RestoreWallet/RestoreWallet.tsx +++ b/app/components/Views/RestoreWallet/RestoreWallet.tsx @@ -1,12 +1,19 @@ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { View, Image, ActivityIndicator } from 'react-native'; +import { Image } from 'react-native'; import { strings } from '../../../../locales/i18n'; -import { createStyles } from './styles'; -import Text, { +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + Text, + Button, + BoxAlignItems, + BoxJustifyContent, TextVariant, -} from '../../../component-library/components/Texts/Text'; -import StyledButton from '../../UI/StyledButton'; + TextColor, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import { createNavigationDetails, useParams, @@ -15,7 +22,6 @@ import Routes from '../../../constants/navigation/Routes'; import EngineService from '../../../core/EngineService'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation, StackActions } from '@react-navigation/native'; -import { useAppThemeFromContext } from '../../../util/theme'; import { createWalletResetNeededNavDetails } from './WalletResetNeeded'; import { createWalletRestoredNavDetails } from './WalletRestored'; import { MetaMetricsEvents } from '../../../core/Analytics'; @@ -23,7 +29,7 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import generateDeviceAnalyticsMetaData from '../../../util/metrics'; import { useMetrics } from '../../../components/hooks/useMetrics'; -/* eslint-disable import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ +/* eslint-disable import-x/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ const onboardingDeviceImage = require('../../../images/swaps_onboard_device.png'); interface RestoreWalletParams { previousScreen: string; @@ -44,8 +50,7 @@ export const createRestoreWalletNavDetailsNested = const RestoreWallet = () => { const { trackEvent, createEventBuilder } = useMetrics(); - const { colors } = useAppThemeFromContext(); - const styles = createStyles(colors); + const tw = useTailwind(); const [loading, setLoading] = useState(false); @@ -89,31 +94,42 @@ const RestoreWallet = () => { }, [deviceMetaData, navigation, trackEvent, createEventBuilder]); return ( - - - + + + - - + + {strings('restore_wallet.restore_needed_title')} - + {strings('restore_wallet.restore_needed_description')} - - - + + + ); }; diff --git a/app/components/Views/RestoreWallet/WalletRestored.tsx b/app/components/Views/RestoreWallet/WalletRestored.tsx index f58a46fea58..10b57e8a0c2 100644 --- a/app/components/Views/RestoreWallet/WalletRestored.tsx +++ b/app/components/Views/RestoreWallet/WalletRestored.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { View, diff --git a/app/components/Views/RestoreWallet/__snapshots__/RestoreWallet.test.tsx.snap b/app/components/Views/RestoreWallet/__snapshots__/RestoreWallet.test.tsx.snap new file mode 100644 index 00000000000..afabd9495b5 --- /dev/null +++ b/app/components/Views/RestoreWallet/__snapshots__/RestoreWallet.test.tsx.snap @@ -0,0 +1,202 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RestoreWallet rendering renders component tree correctly 1`] = ` + + + + + + + Restore needed + + + Something went wrong, but don’t worry! Let’s try to restore your wallet. + + + + + + Restore wallet + + + + +`; diff --git a/app/components/Views/RestoreWallet/styles.ts b/app/components/Views/RestoreWallet/styles.ts index d675612af60..ef43b996596 100644 --- a/app/components/Views/RestoreWallet/styles.ts +++ b/app/components/Views/RestoreWallet/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import type { ThemeColors } from '@metamask/design-tokens'; import { StyleSheet } from 'react-native'; diff --git a/app/components/Views/RevealPrivateCredential/index.ts b/app/components/Views/RevealPrivateCredential/index.ts index 49434a3dc0d..64cc4d0b7c7 100644 --- a/app/components/Views/RevealPrivateCredential/index.ts +++ b/app/components/Views/RevealPrivateCredential/index.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import RevealPrivateCredential from './RevealPrivateCredential'; export { RevealPrivateCredential }; diff --git a/app/components/Views/RevealPrivateCredential/styles.ts b/app/components/Views/RevealPrivateCredential/styles.ts index b4404a3ca43..a533f24e8d7 100644 --- a/app/components/Views/RevealPrivateCredential/styles.ts +++ b/app/components/Views/RevealPrivateCredential/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { Platform, StyleSheet } from 'react-native'; import { fontStyles } from '../../../styles/common'; import { Colors, Theme } from '../../../util/theme/models'; diff --git a/app/components/Views/Settings/AdvancedSettings/__snapshots__/index.test.tsx.snap b/app/components/Views/Settings/AdvancedSettings/__snapshots__/index.test.tsx.snap index 2630c4c9aad..1eed00192af 100644 --- a/app/components/Views/Settings/AdvancedSettings/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Settings/AdvancedSettings/__snapshots__/index.test.tsx.snap @@ -323,7 +323,7 @@ exports[`AdvancedSettings should render correctly 1`] = ` } } > - Dismiss "Switch to smart account" suggestion + Smart account requests from dapps @@ -378,7 +378,7 @@ exports[`AdvancedSettings should render correctly 1`] = ` } } > - Turn this on to no longer see the "Switch to smart account" suggestion on any account. Smart accounts unlocks faster transactions, lower network fees and more flexibility on payment for those. + Let dapps request smart account features for standard accounts. This won't affect accounts that are already smart accounts. + this.toggleDismissSmartAccountSuggestionEnabled(!val) + } testId={AdvancedViewSelectorsIDs.DISMISS_SMART_ACCOUNT_UPDATE} styles={styles} /> diff --git a/app/components/Views/Settings/AdvancedSettings/index.test.tsx b/app/components/Views/Settings/AdvancedSettings/index.test.tsx index 3ee0da63aa6..3115be427c5 100644 --- a/app/components/Views/Settings/AdvancedSettings/index.test.tsx +++ b/app/components/Views/Settings/AdvancedSettings/index.test.tsx @@ -132,7 +132,7 @@ describe('AdvancedSettings', () => { Device.isIos = jest.fn().mockReturnValue(true); Device.isAndroid = jest.fn().mockReturnValue(false); - it('should render option to dismiss smart account upgrade', async () => { + it('should render smart account dapp requests toggle on by default', async () => { const { findByLabelText } = renderWithProvider( , { @@ -141,12 +141,12 @@ describe('AdvancedSettings', () => { ); const switchElement = await findByLabelText( - strings('app_settings.dismiss_smart_account_update_heading'), + strings('app_settings.smart_account_dapp_requests_heading'), ); - expect(switchElement.props.value).toBe(false); + expect(switchElement.props.value).toBe(true); }); - it('should update dismissSmartAccountSuggestionEnabled when dismiss smart account upgrade is pressed', async () => { + it('should set dismissSmartAccountSuggestionEnabled to true when smart account dapp requests toggle is turned off', async () => { const { findByLabelText } = renderWithProvider( , { @@ -155,12 +155,14 @@ describe('AdvancedSettings', () => { ); const switchElement = await findByLabelText( - strings('app_settings.dismiss_smart_account_update_heading'), + strings('app_settings.smart_account_dapp_requests_heading'), ); fireEvent(switchElement, 'onValueChange', false); - expect(mockDismissSmartAccountSuggestionEnabled).toHaveBeenCalled(); + expect(mockDismissSmartAccountSuggestionEnabled).toHaveBeenCalledWith( + true, + ); }); it('should render smart transactions opt in switch on by default', async () => { diff --git a/app/components/Views/Settings/AppInformation/index.js b/app/components/Views/Settings/AppInformation/index.js index 0386dbfe742..133bf088e41 100644 --- a/app/components/Views/Settings/AppInformation/index.js +++ b/app/components/Views/Settings/AppInformation/index.js @@ -98,7 +98,7 @@ const createStyles = (colors) => }, }); -const foxImage = require('../../../../images/branding/fox.png'); // eslint-disable-line import/no-commonjs +const foxImage = require('../../../../images/branding/fox.png'); // eslint-disable-line import-x/no-commonjs /** * View that contains app information diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx b/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx index 16aca39e32b..1edd8ab10f8 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx @@ -34,6 +34,8 @@ import Icon, { } from '../../../../../../component-library/components/Icons/Icon'; import { selectAdditionalNetworksBlacklistFeatureFlag } from '../../../../../../selectors/featureFlagController/networkBlacklist'; import { getGasFeesSponsoredNetworkEnabled } from '../../../../../../selectors/featureFlagController/gasFeesSponsored'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../selectors/accountsController'; +import { isHardwareAccount } from '../../../../../../util/address'; import TagColored, { TagColor, } from '../../../../../../component-library/components-temp/TagColored'; @@ -65,6 +67,12 @@ const CustomNetwork = ({ const isGasFeesSponsoredNetworkEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, ); + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + const isHardwareWallet = Boolean( + selectedAddress && isHardwareAccount(selectedAddress), + ); const { safeChains } = useSafeChains(); const blacklistedChainIds = useSelector( selectAdditionalNetworksBlacklistFeatureFlag, @@ -181,7 +189,8 @@ const CustomNetwork = ({ {networkConfiguration.nickname} - {isGasFeesSponsoredNetworkEnabled( + {!isHardwareWallet && + isGasFeesSponsoredNetworkEnabled( networkConfiguration.chainId, ) ? ( diff --git a/app/components/Views/Settings/NotificationsSettings/PushNotificationToggle.hooks.test.tsx b/app/components/Views/Settings/NotificationsSettings/PushNotificationToggle.hooks.test.tsx index e145556f564..09b0fe0217a 100644 --- a/app/components/Views/Settings/NotificationsSettings/PushNotificationToggle.hooks.test.tsx +++ b/app/components/Views/Settings/NotificationsSettings/PushNotificationToggle.hooks.test.tsx @@ -1,7 +1,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as PushNotificationsHooks from '../../../../util/notifications/hooks/usePushNotifications'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as NotificationService from '../../../../util/notifications/services/NotificationService'; import { usePushNotificationSettingsToggle } from './PushNotificationToggle.hooks'; diff --git a/app/components/Views/Settings/NotificationsSettings/PushNotificationToggle.test.tsx b/app/components/Views/Settings/NotificationsSettings/PushNotificationToggle.test.tsx index dbe0d14bb43..8fe9eae5e72 100644 --- a/app/components/Views/Settings/NotificationsSettings/PushNotificationToggle.test.tsx +++ b/app/components/Views/Settings/NotificationsSettings/PushNotificationToggle.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { fireEvent, waitFor } from '@testing-library/react-native'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as PushNotificationToggleHooksModule from './PushNotificationToggle.hooks'; import renderWithProvider from '../../../../util/test/renderWithProvider'; import { diff --git a/app/components/Views/Settings/SecuritySettings/Sections/ChangePassword/styles.ts b/app/components/Views/Settings/SecuritySettings/Sections/ChangePassword/styles.ts index 84e60885ae7..aa82c442918 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/ChangePassword/styles.ts +++ b/app/components/Views/Settings/SecuritySettings/Sections/ChangePassword/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; export const createStyles = () => diff --git a/app/components/Views/Settings/SecuritySettings/Sections/ClearPrivacy/styles.ts b/app/components/Views/Settings/SecuritySettings/Sections/ClearPrivacy/styles.ts index 5252a41b663..d3d8cbeaadf 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/ClearPrivacy/styles.ts +++ b/app/components/Views/Settings/SecuritySettings/Sections/ClearPrivacy/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; export const styleSheet = () => diff --git a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.test.tsx b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.test.tsx index c5ebee60e6d..545f466e354 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.test.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.test.tsx @@ -377,7 +377,18 @@ describe('MetaMetricsAndDataCollectionSection', () => { deviceProp: 'Device value', userProp: 'User value', }); - expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + updated_after_onboarding: true, + location: 'settings', + }), + }), + ); + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, properties: expect.objectContaining({ @@ -407,7 +418,18 @@ describe('MetaMetricsAndDataCollectionSection', () => { fireEvent(metaMetricsSwitch, 'valueChange', true); await waitFor(() => { - expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + updated_after_onboarding: true, + location: 'onboarding_default_settings', + }), + }), + ); + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, properties: expect.objectContaining({ @@ -467,6 +489,16 @@ describe('MetaMetricsAndDataCollectionSection', () => { fireEvent(metaMetricsSwitch, 'valueChange', true); await waitFor(() => { + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + updated_after_onboarding: true, + location: 'settings', + account_type: AccountType.MetamaskGoogle, + }), + }), + ); expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( expect.objectContaining({ name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, @@ -808,6 +840,16 @@ describe('MetaMetricsAndDataCollectionSection', () => { }); expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + location: 'settings', + updated_after_onboarding: true, + }), + }), + ); + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, properties: expect.objectContaining({ @@ -827,8 +869,8 @@ describe('MetaMetricsAndDataCollectionSection', () => { }, ); expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( - // if MetaMetrics is initially disabled, trackEvent is called twice and this is 2nd call - !metaMetricsInitiallyEnabled ? 2 : 1, + // if MetaMetrics is initially disabled, marketing consent is the 3rd trackEvent + !metaMetricsInitiallyEnabled ? 3 : 1, expect.objectContaining({ name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, properties: expect.objectContaining({ diff --git a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx index c2940f9408b..59debfafdfb 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx @@ -114,6 +114,17 @@ const MetaMetricsAndDataCollectionSection: React.FC< setAnalyticsEnabled(true); analytics.identify(consolidatedTraits); + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( + MetaMetricsEvents.METRICS_OPT_IN, + ) + .addProperties({ + updated_after_onboarding: true, + location: analyticsLocation, + ...(accountType && { account_type: accountType }), + }) + .build(), + ); analytics.trackEvent( AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED, diff --git a/app/components/Views/Settings/SecuritySettings/Sections/ProtectYourWallet/styles.ts b/app/components/Views/Settings/SecuritySettings/Sections/ProtectYourWallet/styles.ts index 9035bc2f4eb..599f8bf4250 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/ProtectYourWallet/styles.ts +++ b/app/components/Views/Settings/SecuritySettings/Sections/ProtectYourWallet/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { fontStyles } from '../../../../../../styles/common'; import { StyleSheet } from 'react-native'; diff --git a/app/components/Views/SimpleWebview/index.tsx b/app/components/Views/SimpleWebview/index.tsx index 54de8bf379e..71794dcb9fe 100644 --- a/app/components/Views/SimpleWebview/index.tsx +++ b/app/components/Views/SimpleWebview/index.tsx @@ -4,7 +4,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { WebView } from '@metamask/react-native-webview'; import getHeaderCompactStandardNavbarOptions from '../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions'; import { IconName } from '@metamask/design-system-react-native'; -import Share from 'react-native-share'; // eslint-disable-line import/default +import Share from 'react-native-share'; // eslint-disable-line import-x/default import Logger from '../../../util/Logger'; import { baseStyles } from '../../../styles/common'; import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; diff --git a/app/components/Views/Snaps/SnapSettings/index.ts b/app/components/Views/Snaps/SnapSettings/index.ts index cdef0b73e11..548de6dfc9f 100644 --- a/app/components/Views/Snaps/SnapSettings/index.ts +++ b/app/components/Views/Snaps/SnapSettings/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import SnapSettings from './SnapSettings'; export { SnapSettings }; diff --git a/app/components/Views/Snaps/SnapsSettingsList/index.ts b/app/components/Views/Snaps/SnapsSettingsList/index.ts index 8259f82aef6..ff211b5e461 100644 --- a/app/components/Views/Snaps/SnapsSettingsList/index.ts +++ b/app/components/Views/Snaps/SnapsSettingsList/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import SnapsSettingsList from './SnapsSettingsList'; export { SnapsSettingsList }; diff --git a/app/components/Views/Snaps/components/SnapDescription/index.ts b/app/components/Views/Snaps/components/SnapDescription/index.ts index 25ac400e26e..ea3d44dfde2 100644 --- a/app/components/Views/Snaps/components/SnapDescription/index.ts +++ b/app/components/Views/Snaps/components/SnapDescription/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import SnapDescription from './SnapDescription'; export { SnapDescription }; diff --git a/app/components/Views/Snaps/components/SnapDetails/index.ts b/app/components/Views/Snaps/components/SnapDetails/index.ts index dfb9b5fe547..24e29e337e8 100644 --- a/app/components/Views/Snaps/components/SnapDetails/index.ts +++ b/app/components/Views/Snaps/components/SnapDetails/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import SnapDetails from './SnapDetails'; export { SnapDetails }; diff --git a/app/components/Views/Snaps/components/SnapElement/index.ts b/app/components/Views/Snaps/components/SnapElement/index.ts index b97cd3c3316..30aaceafb6e 100644 --- a/app/components/Views/Snaps/components/SnapElement/index.ts +++ b/app/components/Views/Snaps/components/SnapElement/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import SnapElement from './SnapElement'; export { SnapElement }; diff --git a/app/components/Views/Snaps/components/SnapPermissionCell/index.ts b/app/components/Views/Snaps/components/SnapPermissionCell/index.ts index 2a67b5af26b..ab4d5752994 100644 --- a/app/components/Views/Snaps/components/SnapPermissionCell/index.ts +++ b/app/components/Views/Snaps/components/SnapPermissionCell/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import SnapPermissionCell from './SnapPermissionCell'; export { SnapPermissionCell }; diff --git a/app/components/Views/Snaps/components/SnapPermissions/index.ts b/app/components/Views/Snaps/components/SnapPermissions/index.ts index 954ae53806e..1cea5defe1e 100644 --- a/app/components/Views/Snaps/components/SnapPermissions/index.ts +++ b/app/components/Views/Snaps/components/SnapPermissions/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import SnapPermissions from './SnapPermissions'; export { SnapPermissions }; diff --git a/app/components/Views/Snaps/components/SnapVersionTag/index.ts b/app/components/Views/Snaps/components/SnapVersionTag/index.ts index 2485b2be4b9..3cfbf905ea9 100644 --- a/app/components/Views/Snaps/components/SnapVersionTag/index.ts +++ b/app/components/Views/Snaps/components/SnapVersionTag/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import SnapVersionBadge from './SnapVersionTag'; export { SnapVersionBadge }; diff --git a/app/components/Views/SuccessErrorSheet/utils.test.ts b/app/components/Views/SuccessErrorSheet/utils.test.ts new file mode 100644 index 00000000000..a0b29825c74 --- /dev/null +++ b/app/components/Views/SuccessErrorSheet/utils.test.ts @@ -0,0 +1,113 @@ +import { NavigationProp, ParamListBase } from '@react-navigation/native'; +import Routes from '../../../constants/navigation/Routes'; +import { + navigateToSuccessErrorSheet, + navigateToSuccessErrorSheetPromise, +} from './utils'; + +const mockNavigate = jest.fn(); +const mockNavigation = { + navigate: mockNavigate, +} as unknown as NavigationProp; + +const baseParams = { + type: 'error' as const, + title: 'Error Title', + description: 'Error description', +}; + +describe('navigateToSuccessErrorSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('forwards params to the success error sheet route', () => { + const onClose = jest.fn(); + const onPrimaryButtonPress = jest.fn(); + const params = { + ...baseParams, + type: 'success' as const, + onClose, + onPrimaryButtonPress, + descriptionAlign: 'center' as const, + primaryButtonLabel: 'OK', + }; + + navigateToSuccessErrorSheet(mockNavigation, params); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: expect.objectContaining({ + type: 'success', + onClose, + onPrimaryButtonPress, + descriptionAlign: 'center', + primaryButtonLabel: 'OK', + }), + }); + }); +}); + +describe('navigateToSuccessErrorSheetPromise', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resolves and invokes the original onPrimaryButtonPress callback', async () => { + const onPrimaryButtonPress = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onPrimaryButtonPress(); + }); + + await expect( + navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onPrimaryButtonPress, + }), + ).resolves.toBeUndefined(); + + expect(onPrimaryButtonPress).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + }), + ); + }); + + it('resolves and invokes the original onSecondaryButtonPress callback', async () => { + const onSecondaryButtonPress = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onSecondaryButtonPress(); + }); + + await expect( + navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onSecondaryButtonPress, + }), + ).resolves.toBeUndefined(); + + expect(onSecondaryButtonPress).toHaveBeenCalledTimes(1); + }); + + it('resolves and invokes the original onClose callback', async () => { + const onClose = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onClose(); + }); + + await expect( + navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onClose, + }), + ).resolves.toBeUndefined(); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/Views/SuccessErrorSheet/utils.ts b/app/components/Views/SuccessErrorSheet/utils.ts new file mode 100644 index 00000000000..e9c655fa924 --- /dev/null +++ b/app/components/Views/SuccessErrorSheet/utils.ts @@ -0,0 +1,37 @@ +import { NavigationProp, ParamListBase } from '@react-navigation/native'; +import { SuccessErrorSheetParams } from './interface'; +import Routes from '../../../constants/navigation/Routes'; + +export const navigateToSuccessErrorSheet = ( + navigation: NavigationProp, + params: SuccessErrorSheetParams, +) => { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: { + ...params, + }, + }); +}; + +export const navigateToSuccessErrorSheetPromise = async ( + navigation: NavigationProp, + params: SuccessErrorSheetParams, +) => + new Promise((resolve) => { + navigateToSuccessErrorSheet(navigation, { + ...params, + onPrimaryButtonPress: () => { + params.onPrimaryButtonPress?.(); + resolve(); + }, + onSecondaryButtonPress: () => { + params.onSecondaryButtonPress?.(); + resolve(); + }, + onClose: () => { + params.onClose?.(); + resolve(); + }, + }); + }); diff --git a/app/components/Views/TrendingView/TrendingView.testIds.ts b/app/components/Views/TrendingView/TrendingView.testIds.ts index a274816fb04..a411767501f 100644 --- a/app/components/Views/TrendingView/TrendingView.testIds.ts +++ b/app/components/Views/TrendingView/TrendingView.testIds.ts @@ -3,6 +3,15 @@ export const TrendingViewSelectorsIDs = { 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', + 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', + TRENDING_TOKENS_HEADER_SEARCH_BAR: 'trending-tokens-header-search-bar', } as const; export type TrendingViewSelectorsIDsType = typeof TrendingViewSelectorsIDs; diff --git a/app/components/Views/TrendingView/TrendingView.view.test.tsx b/app/components/Views/TrendingView/TrendingView.view.test.tsx index f3f0041fbe3..4379531e969 100644 --- a/app/components/Views/TrendingView/TrendingView.view.test.tsx +++ b/app/components/Views/TrendingView/TrendingView.view.test.tsx @@ -1,8 +1,5 @@ import '../../../../tests/component-view/mocks'; -import { - describeForPlatforms, - itForPlatforms, -} from '../../../util/test/platform'; +import { describeForPlatforms } from '../../../../tests/component-view/platform'; import { renderTrendingViewWithRoutes } from '../../../../tests/component-view/renderers/trending'; import { TrendingViewSelectorsIDs } from './TrendingView.testIds'; import { @@ -10,8 +7,7 @@ import { clearTrendingApiMocks, mockTrendingTokensData, mockBnbChainToken, - getTrendingTokensMock, -} from '../../../../tests/component-view/mocks/trendingApiMocks'; +} from '../../../../tests/component-view/api-mocking/trending'; import { fireEvent, waitFor, @@ -22,19 +18,6 @@ import { } from '@testing-library/react-native'; import { ReactTestInstance } from 'react-test-renderer'; -// TODO: Anti-pattern — only Engine and native modules should be mocked here. -// getTrendingTokens is a standalone service function called directly from -// components, not through a controller on Engine. -// https://github.com/MetaMask/metamask-mobile/issues/26270 -// eslint-disable-next-line no-restricted-syntax -jest.mock('@metamask/assets-controllers', () => { - const actual = jest.requireActual('@metamask/assets-controllers'); - return { - ...actual, - getTrendingTokens: jest.fn().mockResolvedValue([]), - }; -}); - const TRENDING_ETHEREUM_ID = 'trending-token-row-item-eip155:1/erc20:0x0000000000000000000000000000000000000000'; const TRENDING_BITCOIN_ID = @@ -96,67 +79,68 @@ describeForPlatforms('ExploreFeed - Component Tests', () => { clearTrendingApiMocks(); }); - itForPlatforms('renders Explore screen wrapped in SafeAreaView', async () => { - const { getByTestId } = renderTrendingViewWithRoutes(); + it('Explore screen shows safe area, header and title and user can open trending full view', async () => { + const { getByTestId, getByText } = renderTrendingViewWithRoutes(); await waitFor(() => { expect( getByTestId(TrendingViewSelectorsIDs.EXPLORE_SAFE_AREA), ).toBeOnTheScreen(); + expect( + getByTestId(TrendingViewSelectorsIDs.EXPLORE_HEADER_ROOT), + ).toBeOnTheScreen(); + expect(getByText('Explore')).toBeOnTheScreen(); }); - }); - - itForPlatforms('renders HeaderRoot on Explore screen', async () => { - const { getByTestId } = renderTrendingViewWithRoutes(); await waitFor(() => { expect( - getByTestId(TrendingViewSelectorsIDs.EXPLORE_HEADER_ROOT), + getByTestId(TrendingViewSelectorsIDs.TRENDING_FEED_SCROLL_VIEW), ).toBeOnTheScreen(); }); - }); - itForPlatforms('renders Explore title on Explore screen', async () => { - const { getByText } = renderTrendingViewWithRoutes(); + const viewAllButton = getByTestId( + TrendingViewSelectorsIDs.SECTION_HEADER_VIEW_ALL_TOKENS, + ); + await actButtonPress(viewAllButton); await waitFor(() => { - expect(getByText('Explore')).toBeOnTheScreen(); + const header = getByTestId( + TrendingViewSelectorsIDs.TRENDING_TOKENS_HEADER, + ); + expect(header).toHaveTextContent('Trending tokens'); }); }); - itForPlatforms( - 'user sees trending tokens section with mocked data', - async () => { - const { findByText, queryByTestId } = renderTrendingViewWithRoutes(); + it('user sees trending tokens section with mocked data', async () => { + const { findByText, queryByTestId } = renderTrendingViewWithRoutes(); - await waitFor(async () => { - expect(await findByText('Ethereum')).toBeOnTheScreen(); - }); + await waitFor(async () => { + expect(await findByText('Ethereum')).toBeOnTheScreen(); + }); - await assertTrendingTokenRowsVisibility({ - queryByTestId, - visible: [ - { - id: TRENDING_ETHEREUM_ID, - name: 'Ethereum', - pricePercentageChange: '+5.20%', - }, - { - id: TRENDING_BITCOIN_ID, - name: 'Bitcoin', - pricePercentageChange: '-2.50%', - }, - { - id: TRENDING_UNISWAP_ID, - name: 'Uniswap', - pricePercentageChange: '+12.80%', - }, - ], - }); - }, - ); + await assertTrendingTokenRowsVisibility({ + queryByTestId, + visible: [ + { + id: TRENDING_ETHEREUM_ID, + name: 'Ethereum', + pricePercentageChange: '+5.20%', + }, + { + id: TRENDING_BITCOIN_ID, + name: 'Bitcoin', + pricePercentageChange: '-2.50%', + }, + { + id: TRENDING_UNISWAP_ID, + name: 'Uniswap', + pricePercentageChange: '+12.80%', + }, + ], + }); + }); - itForPlatforms('user navigates to trending tokens full view', async () => { + it('user navigates to trending tokens full view', async () => { const { getByTestId, queryByTestId } = renderTrendingViewWithRoutes(); await waitFor(() => { @@ -165,11 +149,15 @@ describeForPlatforms('ExploreFeed - Component Tests', () => { ).toBeOnTheScreen(); }); - const viewAllButton = getByTestId('section-header-view-all-tokens'); + const viewAllButton = getByTestId( + TrendingViewSelectorsIDs.SECTION_HEADER_VIEW_ALL_TOKENS, + ); await actButtonPress(viewAllButton); await waitFor(() => { - const header = getByTestId('trending-tokens-header'); + const header = getByTestId( + TrendingViewSelectorsIDs.TRENDING_TOKENS_HEADER, + ); expect(header).toHaveTextContent('Trending tokens'); }); @@ -195,41 +183,42 @@ describeForPlatforms('ExploreFeed - Component Tests', () => { }); }); - itForPlatforms( - 'user can search for a trending token from the explore feed', - async () => { - const { findByTestId, getByTestId } = renderTrendingViewWithRoutes(); + it('user can search for a trending token from the explore feed', async () => { + const { findByTestId, getByTestId } = renderTrendingViewWithRoutes(); - await waitFor(() => { - expect( - getByTestId(TrendingViewSelectorsIDs.TRENDING_FEED_SCROLL_VIEW), - ).toBeOnTheScreen(); - }); + await waitFor(() => { + expect( + getByTestId(TrendingViewSelectorsIDs.TRENDING_FEED_SCROLL_VIEW), + ).toBeOnTheScreen(); + }); - const searchButton = getByTestId('explore-view-search-button'); - await actButtonPress(searchButton); + const searchButton = getByTestId( + TrendingViewSelectorsIDs.EXPLORE_VIEW_SEARCH_BUTTON, + ); + await actButtonPress(searchButton); - const searchInput = await findByTestId('explore-view-search-input'); - expect(searchInput).toBeOnTheScreen(); + const searchInput = await findByTestId( + TrendingViewSelectorsIDs.EXPLORE_VIEW_SEARCH_INPUT, + ); + expect(searchInput).toBeOnTheScreen(); - await userEvent.type(searchInput, 'ethereum'); + await userEvent.type(searchInput, 'ethereum'); - const searchResultsList = await findByTestId( - 'trending-search-results-list', - ); + const searchResultsList = await findByTestId( + TrendingViewSelectorsIDs.TRENDING_SEARCH_RESULTS_LIST, + ); - await assertTrendingTokenRowsVisibility({ - queryByTestId: within(searchResultsList).queryByTestId, - visible: [ - { - id: TRENDING_ETHEREUM_ID, - name: 'Ethereum', - pricePercentageChange: '+5.20%', - }, - ], - }); - }, - ); + await assertTrendingTokenRowsVisibility({ + queryByTestId: within(searchResultsList).queryByTestId, + visible: [ + { + id: TRENDING_ETHEREUM_ID, + name: 'Ethereum', + pricePercentageChange: '+5.20%', + }, + ], + }); + }); }); describeForPlatforms('TrendingTokensFullView - Component Tests', () => { @@ -241,74 +230,75 @@ describeForPlatforms('TrendingTokensFullView - Component Tests', () => { clearTrendingApiMocks(); }); - itForPlatforms( - 'displays only BNB tokens when BNB Chain network filter is selected', - async () => { - getTrendingTokensMock.mockImplementation( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async (params: any) => { - if ( - params?.chainIds && - params.chainIds.length === 1 && - params.chainIds[0] === 'eip155:56' - ) { - return mockBnbChainToken; - } - return mockTrendingTokensData; - }, - ); + it('displays only BNB tokens when BNB Chain network filter is selected', async () => { + setupTrendingApiFetchMock(mockTrendingTokensData, (uri) => { + const url = new URL(uri, 'https://token.api.cx.metamask.io'); + const chainIdsParam = url.searchParams.get('chainIds') ?? ''; + const chainIds = chainIdsParam.split(',').map((s) => s.trim()); + if (chainIds.length === 1 && chainIds[0] === 'eip155:56') { + return mockBnbChainToken; + } + return mockTrendingTokensData; + }); - const { findByText, getByTestId, queryByTestId } = - renderTrendingViewWithRoutes(); + const { findByText, getByTestId, queryByTestId } = + renderTrendingViewWithRoutes(); - await waitFor(() => { - expect( - getByTestId(TrendingViewSelectorsIDs.TRENDING_FEED_SCROLL_VIEW), - ).toBeOnTheScreen(); - }); + await waitFor(() => { + expect( + getByTestId(TrendingViewSelectorsIDs.TRENDING_FEED_SCROLL_VIEW), + ).toBeOnTheScreen(); + }); - const viewAllButton = getByTestId('section-header-view-all-tokens'); - await actButtonPress(viewAllButton); + const viewAllButton = getByTestId( + TrendingViewSelectorsIDs.SECTION_HEADER_VIEW_ALL_TOKENS, + ); + await actButtonPress(viewAllButton); - await waitFor(() => { - expect(getByTestId('trending-tokens-header')).toBeOnTheScreen(); - }); + await waitFor(() => { + expect( + getByTestId(TrendingViewSelectorsIDs.TRENDING_TOKENS_HEADER), + ).toBeOnTheScreen(); + }); - await assertTrendingTokenRowsVisibility({ - queryByTestId, - visible: [ - { id: TRENDING_ETHEREUM_ID }, - { id: TRENDING_BITCOIN_ID }, - { id: TRENDING_UNISWAP_ID }, - ], - missing: [{ id: TRENDING_BNB_ID }], - }); + await assertTrendingTokenRowsVisibility({ + queryByTestId, + visible: [ + { id: TRENDING_ETHEREUM_ID }, + { id: TRENDING_BITCOIN_ID }, + { id: TRENDING_UNISWAP_ID }, + ], + missing: [{ id: TRENDING_BNB_ID }], + }); - const networkButton = getByTestId('all-networks-button'); - await actButtonPress(networkButton); + const networkButton = getByTestId( + TrendingViewSelectorsIDs.ALL_NETWORKS_BUTTON, + ); + await actButtonPress(networkButton); - await waitFor(() => { - expect(getByTestId('close-button')).toBeOnTheScreen(); - }); + await waitFor(() => { + expect( + getByTestId(TrendingViewSelectorsIDs.CLOSE_BUTTON), + ).toBeOnTheScreen(); + }); - const bnbNetworkOption = await findByText('BNB Chain'); - expect(bnbNetworkOption).toBeOnTheScreen(); + const bnbNetworkOption = await findByText('BNB Chain'); + expect(bnbNetworkOption).toBeOnTheScreen(); - await actButtonPress(bnbNetworkOption); + await actButtonPress(bnbNetworkOption); - await assertTrendingTokenRowsVisibility({ - queryByTestId, - visible: [{ id: TRENDING_BNB_ID }], - missing: [ - { id: TRENDING_ETHEREUM_ID }, - { id: TRENDING_BITCOIN_ID }, - { id: TRENDING_UNISWAP_ID }, - ], - }); - }, - ); + await assertTrendingTokenRowsVisibility({ + queryByTestId, + visible: [{ id: TRENDING_BNB_ID }], + missing: [ + { id: TRENDING_ETHEREUM_ID }, + { id: TRENDING_BITCOIN_ID }, + { id: TRENDING_UNISWAP_ID }, + ], + }); + }); - itForPlatforms('user can search on trending tokens full view', async () => { + it('user can search on trending tokens full view', async () => { const { findByTestId, getByTestId, queryByTestId } = renderTrendingViewWithRoutes(); @@ -318,17 +308,25 @@ describeForPlatforms('TrendingTokensFullView - Component Tests', () => { ).toBeOnTheScreen(); }); - const viewAllButton = getByTestId('section-header-view-all-tokens'); + const viewAllButton = getByTestId( + TrendingViewSelectorsIDs.SECTION_HEADER_VIEW_ALL_TOKENS, + ); await actButtonPress(viewAllButton); await waitFor(() => { - expect(getByTestId('trending-tokens-header')).toBeOnTheScreen(); + expect( + getByTestId(TrendingViewSelectorsIDs.TRENDING_TOKENS_HEADER), + ).toBeOnTheScreen(); }); - const searchToggle = getByTestId('trending-tokens-header-search-toggle'); + const searchToggle = getByTestId( + TrendingViewSelectorsIDs.TRENDING_TOKENS_HEADER_SEARCH_TOGGLE, + ); await actButtonPress(searchToggle); - const searchInput = await findByTestId('trending-tokens-header-search-bar'); + const searchInput = await findByTestId( + TrendingViewSelectorsIDs.TRENDING_TOKENS_HEADER_SEARCH_BAR, + ); expect(searchInput).toBeOnTheScreen(); await userEvent.type(searchInput, 'ethereum'); diff --git a/app/components/Views/TrendingView/sections.config.tsx b/app/components/Views/TrendingView/sections.config.tsx index 36154309c6c..be3b525baa7 100644 --- a/app/components/Views/TrendingView/sections.config.tsx +++ b/app/components/Views/TrendingView/sections.config.tsx @@ -10,6 +10,7 @@ import TrendingTokensSkeleton from '../../UI/Trending/components/TrendingTokenSk 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'; @@ -214,6 +215,7 @@ export const SECTIONS_CONFIG: Record = { screen: Routes.PERPS.MARKET_LIST, params: { defaultMarketTypeFilter: 'all', + source: PERPS_EVENT_VALUE.SOURCE.EXPLORE, }, }); }, @@ -225,7 +227,10 @@ export const SECTIONS_CONFIG: Record = { Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, - params: { market: item as PerpsMarketData }, + params: { + market: item as PerpsMarketData, + source: PERPS_EVENT_VALUE.SOURCE.EXPLORE, + }, }, ); }} diff --git a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx index 5ee291123d8..3efd772e4d2 100644 --- a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx +++ b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx @@ -125,6 +125,7 @@ jest.mock('../confirmations/components/modals/cancel-speedup-modal', () => { : null, }; }); + jest.mock('../../UI/Transactions/RetryModal', () => 'RetryModal'); jest.mock( '../../UI/Transactions/TransactionsFooter', diff --git a/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.test.ts b/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.test.ts index d9c940caf6f..7f12c0efd53 100644 --- a/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.test.ts +++ b/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.test.ts @@ -85,8 +85,8 @@ jest.mock('../../../core/Engine', () => ({ stopTransaction: jest.fn(), }, ApprovalController: { - accept: jest.fn(), - reject: jest.fn(), + acceptRequest: jest.fn(), + rejectRequest: jest.fn(), }, GasFeeController: { startPolling: jest.fn(), @@ -109,7 +109,7 @@ describe('useUnifiedTxActions', () => { interface EngineContextMock { TransactionController: { stopTransaction: jest.Mock }; - ApprovalController: { accept: jest.Mock; reject: jest.Mock }; + ApprovalController: { acceptRequest: jest.Mock; rejectRequest: jest.Mock }; } const engineContext = Engine.context as unknown as EngineContextMock; @@ -449,7 +449,8 @@ describe('useUnifiedTxActions', () => { await result.current.cancelUnsignedQRTransaction(tx); }); - const rejectMock = engineContext.ApprovalController.reject as jest.Mock; + const rejectMock = engineContext.ApprovalController + .rejectRequest as jest.Mock; expect(rejectMock).toHaveBeenCalled(); const [id] = rejectMock.mock.calls[0]; expect(id).toBe('13'); diff --git a/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts b/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts index 1205f473e68..459ebf99604 100644 --- a/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts +++ b/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts @@ -190,7 +190,6 @@ export function useUnifiedTxActions() { if (params?.error) { return undefined; } - // Legacy tx with gasPrice 0x0 would produce 0 from the modal; fall back to market estimate so the replacement gets mined. if ( params && 'gasPrice' in params && @@ -213,8 +212,9 @@ export function useUnifiedTxActions() { throw new Error('Missing transaction id for speed up'); } + const gasValues = getParamsToSend(params); + if (isLedgerAccount) { - const gasValues = getParamsToSend(params); const isEip1559 = gasValues && 'maxFeePerGas' in gasValues; await signLedgerTransaction({ @@ -230,7 +230,7 @@ export function useUnifiedTxActions() { return; } - await speedUpTx(speedUpTxId, getParamsToSend(params)); + await speedUpTx(speedUpTxId, gasValues); onSpeedUpCancelCompleted(); } catch (error: unknown) { toggleRetry(getErrorMessage(error)); @@ -247,8 +247,9 @@ export function useUnifiedTxActions() { throw new Error('Missing transaction id for cancel'); } + const gasValues = getParamsToSend(params); + if (isLedgerAccount) { - const gasValues = getParamsToSend(params); const isEip1559 = gasValues && 'maxFeePerGas' in gasValues; await signLedgerTransaction({ @@ -265,7 +266,7 @@ export function useUnifiedTxActions() { await Engine.context.TransactionController.stopTransaction( cancelTxId, - getParamsToSend(params), + gasValues, ); onSpeedUpCancelCompleted(); } catch (error: unknown) { @@ -289,7 +290,7 @@ export function useUnifiedTxActions() { ); const cancelUnsignedQRTransaction = async (tx: TransactionMeta) => { - await Engine.context.ApprovalController.reject( + await Engine.context.ApprovalController.rejectRequest( tx.id, providerErrors.userRejectedRequest(), ); diff --git a/app/components/Views/Wallet/Wallet.view.test.tsx b/app/components/Views/Wallet/Wallet.view.test.tsx index 79cd437ebf6..ba80fd21518 100644 --- a/app/components/Views/Wallet/Wallet.view.test.tsx +++ b/app/components/Views/Wallet/Wallet.view.test.tsx @@ -4,7 +4,7 @@ import { renderWalletViewWithRoutes, } from '../../../../tests/component-view/renderers/wallet'; import { WalletViewSelectorsIDs } from './WalletView.testIds'; -import { describeForPlatforms } from '../../../util/test/platform'; +import { describeForPlatforms } from '../../../../tests/component-view/platform'; import { fireEvent } from '@testing-library/react-native'; import Routes from '../../../constants/navigation/Routes'; diff --git a/app/components/Views/Wallet/WalletView.testIds.ts b/app/components/Views/Wallet/WalletView.testIds.ts index 0aba48ca7cd..278e01c5b8e 100644 --- a/app/components/Views/Wallet/WalletView.testIds.ts +++ b/app/components/Views/Wallet/WalletView.testIds.ts @@ -11,6 +11,7 @@ export const WalletViewSelectorsIDs = { TOTAL_BALANCE_TEXT: 'total-balance-text', CARD_BUTTON: 'card-button', STAKE_BUTTON: 'stake-button', + CARD_BUTTON_BADGE: 'card-button-badge', EARN_EARNINGS_HISTORY_BUTTON: 'earn-earnings-history-button', UNSTAKE_BUTTON: 'unstake-button', STAKE_MORE_BUTTON: 'stake-more-button', diff --git a/app/components/Views/WalletActions/WalletActions.view.test.tsx b/app/components/Views/WalletActions/WalletActions.view.test.tsx index 5614b72e909..8e8a1cd2413 100644 --- a/app/components/Views/WalletActions/WalletActions.view.test.tsx +++ b/app/components/Views/WalletActions/WalletActions.view.test.tsx @@ -1,7 +1,7 @@ import '../../../../tests/component-view/mocks'; import { renderWalletActionsView } from '../../../../tests/component-view/renderers/walletActions'; import { WalletActionsBottomSheetSelectorsIDs } from './WalletActionsBottomSheet.testIds'; -import { describeForPlatforms } from '../../../util/test/platform'; +import { describeForPlatforms } from '../../../../tests/component-view/platform'; // Regression: #24972 – Perps missing from Trade menu when non-EVM network selected describeForPlatforms('WalletActions', () => { diff --git a/app/components/Views/WalletActions/WalletActionsBottomSheet.testIds.ts b/app/components/Views/WalletActions/WalletActionsBottomSheet.testIds.ts index 8ef99908a3b..3e58b47ddfd 100644 --- a/app/components/Views/WalletActions/WalletActionsBottomSheet.testIds.ts +++ b/app/components/Views/WalletActions/WalletActionsBottomSheet.testIds.ts @@ -1,3 +1,5 @@ +import enContent from '../../../../locales/languages/en.json'; + export const WalletActionsBottomSheetSelectorsIDs = { SEND_BUTTON: 'wallet-send-button', RECEIVE_BUTTON: 'wallet-receive-action', @@ -11,3 +13,8 @@ export const WalletActionsBottomSheetSelectorsIDs = { PERPS_BUTTON: 'wallet-perps-action', PREDICT_BUTTON: 'wallet-predict-action', }; + +export const WalletActionsBottomSheetSelectorsText = { + PERPS_DESCRIPTION: enContent.asset_overview.perps_description, + PREDICT_DESCRIPTION: enContent.asset_overview.predict_description, +} as const; diff --git a/app/components/Views/WalletCreationError/SRPErrorScreen.styles.ts b/app/components/Views/WalletCreationError/SRPErrorScreen.styles.ts deleted file mode 100644 index 896cadc91e9..00000000000 --- a/app/components/Views/WalletCreationError/SRPErrorScreen.styles.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { Theme } from '../../../util/theme/models'; - -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - const { colors } = theme; - - return StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - scrollContent: { - flexGrow: 1, - padding: 16, - }, - content: { - flex: 1, - alignItems: 'center', - paddingTop: 48, - }, - warningIcon: { - marginBottom: 16, - }, - title: { - textAlign: 'center', - marginBottom: 16, - }, - infoBanner: { - flexDirection: 'row', - backgroundColor: colors.info.muted, - borderRadius: 8, - padding: 12, - marginBottom: 24, - width: '100%', - }, - infoBannerIcon: { - marginRight: 8, - marginTop: 2, - }, - infoBannerText: { - flex: 1, - }, - errorReportContainer: { - width: '100%', - marginBottom: 24, - }, - errorReportHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 8, - }, - errorReportContent: { - backgroundColor: colors.background.alternative, - borderRadius: 8, - padding: 12, - maxHeight: 200, - }, - buttonContainer: { - width: '100%', - paddingTop: 16, - paddingBottom: 24, - }, - button: { - marginBottom: 16, - }, - }); -}; - -export default styleSheet; diff --git a/app/components/Views/WalletCreationError/SRPErrorScreen.tsx b/app/components/Views/WalletCreationError/SRPErrorScreen.tsx index 66e22942225..7f114a90d44 100644 --- a/app/components/Views/WalletCreationError/SRPErrorScreen.tsx +++ b/app/components/Views/WalletCreationError/SRPErrorScreen.tsx @@ -1,11 +1,24 @@ import React, { useCallback, useState, useRef, useEffect } from 'react'; -import { View, SafeAreaView, ScrollView, Linking } from 'react-native'; +import { SafeAreaView, ScrollView, Linking } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import Clipboard from '@react-native-clipboard/clipboard'; import { captureException } from '@sentry/react-native'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + Text, + TextVariant, + TextColor, + FontWeight, + Icon, + IconName, + IconSize, + IconColor, +} from '@metamask/design-system-react-native'; + import { OnboardingActionTypes, saveOnboardingEvent as saveEvent, @@ -14,27 +27,17 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding'; import { ITrackingEvent } from '../../../core/Analytics/MetaMetrics.types'; import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; -import Text, { - TextVariant, - TextColor, -} from '../../../component-library/components/Texts/Text'; import Button, { ButtonVariants, ButtonSize, ButtonWidthTypes, } from '../../../component-library/components/Buttons/Button'; -import Icon, { - IconName, - IconSize, - IconColor, -} from '../../../component-library/components/Icons/Icon'; +import { IconName as CLibIconName } from '../../../component-library/components/Icons/Icon'; import { strings } from '../../../../locales/i18n'; import Routes from '../../../constants/navigation/Routes'; -import { useStyles } from '../../../component-library/hooks/useStyles'; import AppConstants from '../../../core/AppConstants'; import { Authentication } from '../../../core'; -import styleSheet from './SRPErrorScreen.styles'; interface SRPErrorScreenProps { error: Error; @@ -46,11 +49,10 @@ const SRPErrorScreen = ({ saveOnboardingEvent, }: SRPErrorScreenProps) => { const navigation = useNavigation(); - const { styles } = useStyles(styleSheet, {}); + const tw = useTailwind(); const [copied, setCopied] = useState(false); const copyTimeoutRef = useRef | null>(null); - // Cleanup timeout on unmount useEffect( () => () => { if (copyTimeoutRef.current) { @@ -60,7 +62,6 @@ const SRPErrorScreen = ({ [], ); - // Track screen viewed event useEffect(() => { trackOnboarding( MetricsEventBuilder.createEventBuilder( @@ -90,7 +91,6 @@ const SRPErrorScreen = ({ saveOnboardingEvent, ); - // Delete wallet await Authentication.deleteWallet(); navigation.reset({ routes: [{ name: Routes.ONBOARDING.ROOT_NAV }], @@ -146,50 +146,50 @@ const SRPErrorScreen = ({ }, []); return ( - + - + - + {strings('wallet_creation_error.title')} - + {strings('wallet_creation_error.srp_description_part1')}{' '} {strings('wallet_creation_error.metamask_support')} {'.'} - + - - - + + + {strings('wallet_creation_error.error_report')} )} + diff --git a/app/components/Views/confirmations/components/send/send-alert-modal/index.ts b/app/components/Views/confirmations/components/send/send-alert-modal/index.ts new file mode 100644 index 00000000000..4ea22997925 --- /dev/null +++ b/app/components/Views/confirmations/components/send/send-alert-modal/index.ts @@ -0,0 +1,2 @@ +export { SendAlertModal } from './send-alert-modal'; +export type { SendAlertModalProps } from './send-alert-modal.types'; diff --git a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx new file mode 100644 index 00000000000..4327e5fbe17 --- /dev/null +++ b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; + +import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import { SendAlertModal } from './send-alert-modal'; + +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => { + const mockStrings: Record = { + 'send.cancel': 'Cancel', + 'send.i_understand': 'I understand', + }; + return mockStrings[key] || key; + }), +})); + +jest.mock( + '../../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const mockReact = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const MockBottomSheet = mockReact.forwardRef( + ( + { children, onClose }: { children: unknown; onClose?: () => void }, + _ref: unknown, + ) => + mockReact.createElement( + View, + { testID: 'bottom-sheet', onTouchEnd: onClose }, + children, + ), + ); + MockBottomSheet.displayName = 'MockBottomSheet'; + return { + __esModule: true, + default: MockBottomSheet, + }; + }, +); + +jest.mock( + '../../../../../../component-library/components/BottomSheets/BottomSheetFooter', + () => { + const mockReact = jest.requireActual('react'); + const { View, Pressable, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + buttonPropsArray, + }: { + buttonPropsArray: { + label: string; + onPress: () => void; + testID?: string; + }[]; + }) => + mockReact.createElement( + View, + { testID: 'bottom-sheet-footer' }, + buttonPropsArray.map( + (btn: { label: string; onPress: () => void; testID?: string }) => + mockReact.createElement( + Pressable, + { key: btn.label, testID: btn.testID, onPress: btn.onPress }, + mockReact.createElement(Text, null, btn.label), + ), + ), + ), + }; + }, +); + +describe('SendAlertModal', () => { + const defaultProps = { + isOpen: true, + title: 'Token Contract Address', + errorMessage: 'Sending to a token contract may result in lost tokens.', + onAcknowledge: jest.fn(), + onClose: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns null when isOpen is false', () => { + const { toJSON } = renderWithProvider( + , + ); + + expect(toJSON()).toBeNull(); + }); + + it('renders modal content when isOpen is true', () => { + const { getByText } = renderWithProvider( + , + ); + + expect(getByText('Token Contract Address')).toBeOnTheScreen(); + expect( + getByText('Sending to a token contract may result in lost tokens.'), + ).toBeOnTheScreen(); + }); + + it('displays the title text', () => { + const { getByText } = renderWithProvider( + , + ); + + expect(getByText('Custom Title')).toBeOnTheScreen(); + }); + + it('displays the error message text', () => { + const { getByText } = renderWithProvider( + , + ); + + expect(getByText('Custom error message')).toBeOnTheScreen(); + }); + + it('calls onClose when cancel button is pressed', () => { + const onClose = jest.fn(); + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press(getByTestId('send-alert-modal-cancel-button')); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onAcknowledge when acknowledge button is pressed', () => { + const onAcknowledge = jest.fn(); + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press(getByTestId('send-alert-modal-acknowledge-button')); + + expect(onAcknowledge).toHaveBeenCalledTimes(1); + }); + + it('does not call onAcknowledge when cancel is pressed', () => { + const onAcknowledge = jest.fn(); + const onClose = jest.fn(); + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press(getByTestId('send-alert-modal-cancel-button')); + + expect(onAcknowledge).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose when acknowledge is pressed', () => { + const onAcknowledge = jest.fn(); + const onClose = jest.fn(); + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press(getByTestId('send-alert-modal-acknowledge-button')); + + expect(onClose).not.toHaveBeenCalled(); + expect(onAcknowledge).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx new file mode 100644 index 00000000000..5fa80651a23 --- /dev/null +++ b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx @@ -0,0 +1,77 @@ +import React, { useRef } from 'react'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetFooter from '../../../../../../component-library/components/BottomSheets/BottomSheetFooter'; +import { ButtonsAlignment } from '../../../../../../component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter.types'; +import { + ButtonSize, + ButtonVariants, +} from '../../../../../../component-library/components/Buttons/Button'; +import { SendAlertModalProps } from './send-alert-modal.types'; + +export const SendAlertModal = ({ + isOpen, + title, + errorMessage, + onAcknowledge, + onClose, +}: SendAlertModalProps) => { + const bottomSheetRef = useRef(null); + + if (!isOpen) { + return null; + } + + return ( + + + + {title} + + {errorMessage} + + + + + ); +}; diff --git a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.types.ts b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.types.ts new file mode 100644 index 00000000000..ebb4cd421c3 --- /dev/null +++ b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.types.ts @@ -0,0 +1,7 @@ +export interface SendAlertModalProps { + isOpen: boolean; + title: string; + errorMessage: string; + onAcknowledge: () => void; + onClose: () => void; +} diff --git a/app/components/Views/confirmations/components/send/send.view.test.tsx b/app/components/Views/confirmations/components/send/send.view.test.tsx new file mode 100644 index 00000000000..238361f6545 --- /dev/null +++ b/app/components/Views/confirmations/components/send/send.view.test.tsx @@ -0,0 +1,338 @@ +import '../../../../../../tests/component-view/mocks'; +import React from 'react'; +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { renderScreenWithRoutes } from '../../../../../../tests/component-view/render'; +import { + buildAddressBookOverridesWithEvmContact, + buildTronSendFixture, + sendViewOverrides, +} from '../../../../../../tests/component-view/presets/send'; +import { initialStateWallet } from '../../../../../../tests/component-view/presets/wallet'; +import { describeForPlatforms } from '../../../../../../tests/component-view/platform'; +import Routes from '../../../../../constants/navigation/Routes'; +import { TokenStandard } from '../../types/token'; +import { + getNftRowTestId, + getRecipientAvatarTestId, + getRecipientRowTestId, + getSelectedRecipientTestId, + RedesignedSendViewSelectorsIDs, +} from './RedesignedSendView.testIds'; +import { Send } from './send'; + +describeForPlatforms('Send', () => { + describe('Non-EVM', () => { + /** + * Regression test for Issue #22789 and related to #23251 + * TRON send flow: selecting a destination account must move the flow forward + * (previously it stayed on the recipient list and did not navigate). + */ + it('TRON send: selecting destination account updates selection', async () => { + const { tronOverrides, recipientAddresses } = buildTronSendFixture(); + + const state = initialStateWallet().withOverrides(tronOverrides).build(); + + const TRON_MAINNET_CHAIN_ID = 'tron:728126428'; + + const tronAsset = { + address: `${TRON_MAINNET_CHAIN_ID}/native`, + chainId: TRON_MAINNET_CHAIN_ID, + symbol: 'TRX', + decimals: 6, + balance: '100', + rawBalance: '0x64', + accountId: 'tron-acc-1', + }; + + const { getByTestId, getByRole, findByTestId } = renderScreenWithRoutes( + Send as unknown as React.ComponentType, + { name: Routes.SEND.DEFAULT }, + [], + { state }, + { screen: Routes.SEND.AMOUNT, params: { asset: tronAsset } }, + ); + + expect( + getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT), + ).toBeOnTheScreen(); + + fireEvent.press( + getByTestId(RedesignedSendViewSelectorsIDs.PERCENTAGE_BUTTON_100), + ); + fireEvent.press(getByRole('button', { name: 'Continue' })); + + expect( + await findByTestId( + RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT, + ), + ).toBeOnTheScreen(); + + const recipientItem = await findByTestId( + getRecipientRowTestId(recipientAddresses[0]), + {}, + { timeout: 10000 }, + ); + fireEvent.press(recipientItem); + + expect( + await findByTestId( + getSelectedRecipientTestId(recipientAddresses[0]), + {}, + { timeout: 10000 }, + ), + ).toBeOnTheScreen(); + }); + + /** + * Regression test for issue #22205 + * EVM contacts must not appear in non-EVM (e.g. Solana, BTC) send flow Recipient screen. + * Only contacts for the current chain/protocol should be shown. + */ + it('Solana send Recipient screen does not show EVM contacts', async () => { + const SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + const EVM_CONTACT_ADDRESS = '0x1234567890123456789012345678901234567890'; + + const addressBookOverrides = + buildAddressBookOverridesWithEvmContact(EVM_CONTACT_ADDRESS); + + const solanaAsset = { + address: `${SOLANA_CHAIN_ID}/native`, + chainId: SOLANA_CHAIN_ID, + symbol: 'SOL', + decimals: 9, + balance: '100', + rawBalance: '100', + }; + + const state = initialStateWallet() + .withOverrides(sendViewOverrides) + .withOverrides(addressBookOverrides) + .build(); + + const { findByTestId, queryByTestId } = renderScreenWithRoutes( + Send as unknown as React.ComponentType, + { name: Routes.SEND.DEFAULT }, + [], + { state }, + { screen: Routes.SEND.RECIPIENT, params: { asset: solanaAsset } }, + ); + + expect( + await findByTestId( + RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT, + ), + ).toBeOnTheScreen(); + + const evmContactRow = queryByTestId( + getRecipientRowTestId(EVM_CONTACT_ADDRESS), + ); + expect(evmContactRow).not.toBeOnTheScreen(); + }); + }); + + describe('ERC-721', () => { + /** + * Regression test for issue #12317 + * When sending an ERC-721 token, the Next/Continue button must be enabled so the user + * can proceed from Amount to Recipient (and not get stuck with "Fiat conversions not available"). + */ + it('Amount screen shows enabled Continue button and user can proceed to Recipient', async () => { + const erc721Asset = { + address: '0x4B3E2eD66631FE2dE488CB0c23eF3A91A41601f7', + chainId: '0x1', + symbol: 'NFT', + name: 'Test NFT', + standard: TokenStandard.ERC721, + tokenId: '42', + balance: '1', + }; + + const state = initialStateWallet() + .withOverrides(sendViewOverrides) + .build(); + + const { getByTestId, getByRole, getByText, findByTestId } = + renderScreenWithRoutes( + Send as unknown as React.ComponentType, + { name: Routes.SEND.DEFAULT }, + [], + { state }, + { screen: Routes.SEND.AMOUNT, params: { asset: erc721Asset } }, + ); + + expect( + getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT), + ).toBeOnTheScreen(); + + fireEvent.press(getByText('1')); + + const continueButton = getByRole('button', { name: 'Continue' }); + expect(continueButton).toBeOnTheScreen(); + expect(continueButton).toBeEnabled(); + + fireEvent.press(continueButton); + + expect( + await findByTestId( + RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT, + ), + ).toBeOnTheScreen(); + }); + + /** + * Regression test for issue #19002 + * When starting Send from home and selecting an ERC-721 NFT in the asset picker, + * the flow must go to Recipient (not Amount). ERC721 must not be treated as ERC1155. + */ + it('Asset screen navigates to Recipient not Amount', async () => { + const accountAddress = '0x0000000000000000000000000000000000000001'; + const erc721InState = { + address: '0x4B3E2eD66631FE2dE488CB0c23eF3A91A41601f7', + tokenId: '42', + standard: 'ERC721', + name: 'Test ERC721 NFT', + favorite: false, + isCurrentlyOwned: true, + }; + + const nftOverrides = { + engine: { + backgroundState: { + NftController: { + allNfts: { + [accountAddress]: { + '0x1': [erc721InState], + }, + }, + allNftContracts: {}, + }, + }, + }, + } as unknown as Record; + + const state = initialStateWallet() + .withOverrides(sendViewOverrides) + .withOverrides(nftOverrides) + .build(); + + const { findByTestId } = renderScreenWithRoutes( + Send as unknown as React.ComponentType, + { name: Routes.SEND.DEFAULT }, + [], + { state }, + { screen: Routes.SEND.ASSET }, + ); + + const nftRow = await findByTestId( + getNftRowTestId('Test ERC721 NFT', '42'), + {}, + { timeout: 5000 }, + ); + expect(nftRow).toBeOnTheScreen(); + fireEvent.press(nftRow); + + expect( + await findByTestId( + RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT, + ), + ).toBeOnTheScreen(); + }); + }); + + describe('Recipient list', () => { + /** + * Regression test for issue #22806 + * Recipient list (accounts or contacts) must render each entry with the expected avatar. + * Uses address-book contacts to avoid dependency on multichain account tree/feature flags. + */ + it('renders each contact with avatar', async () => { + const contactAddresses = [ + '0x0000000000000000000000000000000000000002', + '0x0000000000000000000000000000000000000003', + ]; + + const contactOverrides = { + engine: { + backgroundState: { + AddressBookController: { + addressBook: { + '0x1': { + [contactAddresses[0].toLowerCase()]: { + name: 'Contact One', + address: contactAddresses[0], + }, + [contactAddresses[1].toLowerCase()]: { + name: 'Contact Two', + address: contactAddresses[1], + }, + }, + }, + }, + }, + }, + } as unknown as Record; + + const state = initialStateWallet() + .withOverrides(sendViewOverrides) + .withOverrides(contactOverrides) + .build(); + + const { getByTestId, getByRole, findByTestId } = renderScreenWithRoutes( + Send as unknown as React.ComponentType, + { name: Routes.SEND.DEFAULT }, + [], + { state }, + { + screen: Routes.SEND.AMOUNT, + params: { + asset: { + chainId: '0x1', + symbol: 'ETH', + decimals: 18, + balance: '1', + }, + }, + }, + ); + + expect( + getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT), + ).toBeOnTheScreen(); + + fireEvent.press( + getByTestId(RedesignedSendViewSelectorsIDs.PERCENTAGE_BUTTON_100), + ); + fireEvent.press(getByRole('button', { name: 'Continue' })); + + expect( + await findByTestId( + RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT, + ), + ).toBeOnTheScreen(); + + const avatarElements: ReturnType[] = []; + for (const address of contactAddresses) { + const recipientRow = await waitFor( + () => screen.getByTestId(getRecipientRowTestId(address)), + { timeout: 5000 }, + ); + expect(recipientRow).toBeOnTheScreen(); + const avatar = getByTestId(getRecipientAvatarTestId(address)); + expect(avatar).toBeOnTheScreen(); + avatarElements.push(avatar); + } + + // Regression guard for #22806: all contacts rendered the same avatar. + // Extract the accountAddress fed to each Avatar and verify all are unique. + const avatarAddresses = avatarElements.map((el) => { + const nodes = el.findAll((node) => 'accountAddress' in node.props); + return nodes[0]?.props.accountAddress; + }); + for (const addr of avatarAddresses) { + expect(addr).toBeDefined(); + } + const uniqueAddresses = new Set(avatarAddresses); + expect(uniqueAddresses.size).toBe(avatarAddresses.length); + }); + }); +}); diff --git a/app/components/Views/confirmations/components/smart-contract-with-logo/smart-contract-with-logo.tsx b/app/components/Views/confirmations/components/smart-contract-with-logo/smart-contract-with-logo.tsx index 96e3aaf0c0c..30339b85b05 100644 --- a/app/components/Views/confirmations/components/smart-contract-with-logo/smart-contract-with-logo.tsx +++ b/app/components/Views/confirmations/components/smart-contract-with-logo/smart-contract-with-logo.tsx @@ -6,7 +6,7 @@ import Text from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './smart-contract-with-logo.styles'; -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const foxImage = require('../../../../../images/branding/fox.png'); const SmartContractWithLogo = () => { diff --git a/app/components/Views/confirmations/components/token-list/token-list.test.tsx b/app/components/Views/confirmations/components/token-list/token-list.test.tsx index 97e25241a48..09979116700 100644 --- a/app/components/Views/confirmations/components/token-list/token-list.test.tsx +++ b/app/components/Views/confirmations/components/token-list/token-list.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as AssetSelectionMetrics from '../../hooks/send/metrics/useAssetSelectionMetrics'; import { TokenList } from './token-list'; import { AssetType, HighlightedItem } from '../../types/token'; diff --git a/app/components/Views/confirmations/constants/alerts.ts b/app/components/Views/confirmations/constants/alerts.ts index db1a9af50f7..f95af9de9f1 100644 --- a/app/components/Views/confirmations/constants/alerts.ts +++ b/app/components/Views/confirmations/constants/alerts.ts @@ -20,6 +20,7 @@ export enum AlertKeys { PerpsDepositMinimum = 'perps_deposit_minimum', PerpsHardwareAccount = 'perps_hardware_account', SignedOrSubmitted = 'signed_or_submitted', + TokenContractAddress = 'token_contract_address', TokenTrustSignalMalicious = 'token_trust_signal_malicious', TokenTrustSignalWarning = 'token_trust_signal_warning', } diff --git a/app/components/Views/confirmations/context/ledger-context/ledger-context.test.tsx b/app/components/Views/confirmations/context/ledger-context/ledger-context.test.tsx index 38feded6af5..6b2252dfd80 100644 --- a/app/components/Views/confirmations/context/ledger-context/ledger-context.test.tsx +++ b/app/components/Views/confirmations/context/ledger-context/ledger-context.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Text, View } from 'react-native'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as AddressUtils from '../../../../../util/address'; import { LedgerContextProvider, useLedgerContext } from './ledger-context'; import { personalSignatureConfirmationState } from '../../../../../util/test/confirm-data-helpers'; diff --git a/app/components/Views/confirmations/context/qr-hardware-context/qr-hardware-context.test.tsx b/app/components/Views/confirmations/context/qr-hardware-context/qr-hardware-context.test.tsx index 1fdf9d5b125..80932e348aa 100644 --- a/app/components/Views/confirmations/context/qr-hardware-context/qr-hardware-context.test.tsx +++ b/app/components/Views/confirmations/context/qr-hardware-context/qr-hardware-context.test.tsx @@ -6,9 +6,9 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { personalSignatureConfirmationState } from '../../../../../util/test/confirm-data-helpers'; import { Footer } from '../../components/footer'; import QRInfo from '../../components/qr-info'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as Camera from './useCamera'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as QRHardwareAwareness from './useQRHardwareAwareness'; import { QRHardwareContextProvider, diff --git a/app/components/Views/confirmations/hooks/7702/useBatchApproveBalanceChanges.test.ts b/app/components/Views/confirmations/hooks/7702/useBatchApproveBalanceChanges.test.ts index 72dd0d5b3f3..2a5c696924c 100644 --- a/app/components/Views/confirmations/hooks/7702/useBatchApproveBalanceChanges.test.ts +++ b/app/components/Views/confirmations/hooks/7702/useBatchApproveBalanceChanges.test.ts @@ -10,7 +10,7 @@ import { import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; import useBalanceChanges from '../../../../UI/SimulationDetails/useBalanceChanges'; import { AssetType } from '../../../../UI/SimulationDetails/types'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as TokenUtils from '../../utils/token'; import { useBatchApproveBalanceChanges } from './useBatchApproveBalanceChanges'; diff --git a/app/components/Views/confirmations/hooks/7702/useEIP7702Accounts.test.tsx b/app/components/Views/confirmations/hooks/7702/useEIP7702Accounts.test.tsx index 278cff7f3c6..a52dc77102d 100644 --- a/app/components/Views/confirmations/hooks/7702/useEIP7702Accounts.test.tsx +++ b/app/components/Views/confirmations/hooks/7702/useEIP7702Accounts.test.tsx @@ -1,7 +1,7 @@ import { NetworkConfiguration } from '@metamask/network-controller'; import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as TransactionUtil from '../../utils/transaction'; import { EIP7702NetworkConfiguration } from './useEIP7702Networks'; import { useEIP7702Accounts } from './useEIP7702Accounts'; diff --git a/app/components/Views/confirmations/hooks/alerts/useBatchedUnusedApprovalsAlert.test.tsx b/app/components/Views/confirmations/hooks/alerts/useBatchedUnusedApprovalsAlert.test.tsx index 01af6523fee..18cf69e6458 100755 --- a/app/components/Views/confirmations/hooks/alerts/useBatchedUnusedApprovalsAlert.test.tsx +++ b/app/components/Views/confirmations/hooks/alerts/useBatchedUnusedApprovalsAlert.test.tsx @@ -19,9 +19,9 @@ import { import { backgroundState } from '../../../../../util/test/initial-root-state'; import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; import { AlertKeys } from '../../constants/alerts'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as ApprovalUtils from '../../utils/approvals'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as TokenUtils from '../../utils/token'; import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; import { Severity } from '../../types/alerts'; diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts index e390ff456dc..2841baebcf6 100644 --- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts @@ -23,6 +23,7 @@ import { useOriginTrustSignalAlerts } from './useOriginTrustSignalAlerts'; import { useGasEstimateFailedAlert } from './useGasEstimateFailedAlert'; import { useGasSponsorshipWarningAlert } from './useGasSponsorshipWarningAlert'; import { useFirstTimeInteractionAlert } from './useFirstTimeInteractionAlert'; +import { useTokenContractAlert } from './useTokenContractAlert'; jest.mock('./useBlockaidAlerts'); jest.mock('./useGasEstimateFailedAlert'); @@ -41,6 +42,7 @@ jest.mock('./useTokenTrustSignalAlerts'); jest.mock('./useAddressTrustSignalAlerts'); jest.mock('./useOriginTrustSignalAlerts'); jest.mock('./useFirstTimeInteractionAlert'); +jest.mock('./useTokenContractAlert'); describe('useConfirmationAlerts', () => { const ALERT_MESSAGE_MOCK = 'This is a test alert message.'; @@ -170,6 +172,14 @@ describe('useConfirmationAlerts', () => { severity: Severity.Danger, }, ]; + const mockTokenContractAlert: Alert[] = [ + { + key: 'TokenContractAlert', + title: 'Test Token Contract Alert', + message: ALERT_MESSAGE_MOCK, + severity: Severity.Warning, + }, + ]; beforeEach(() => { jest.clearAllMocks(); (useBlockaidAlerts as jest.Mock).mockReturnValue([]); @@ -189,6 +199,7 @@ describe('useConfirmationAlerts', () => { (useAddressTrustSignalAlerts as jest.Mock).mockReturnValue([]); (useOriginTrustSignalAlerts as jest.Mock).mockReturnValue([]); (useFirstTimeInteractionAlert as jest.Mock).mockReturnValue([]); + (useTokenContractAlert as jest.Mock).mockReturnValue([]); }); it('returns empty array if no alerts', () => { @@ -263,6 +274,9 @@ describe('useConfirmationAlerts', () => { (useOriginTrustSignalAlerts as jest.Mock).mockReturnValue( mockOriginTrustSignalAlerts, ); + (useTokenContractAlert as jest.Mock).mockReturnValue( + mockTokenContractAlert, + ); const { result } = renderHookWithProvider(() => useConfirmationAlerts(), { state: siweSignatureConfirmationState, }); @@ -278,6 +292,7 @@ describe('useConfirmationAlerts', () => { ...mockInsufficientPredictBalanceAlert, ...mockBurnAddressAlert, ...mockTokenTrustSignalAlerts, + ...mockTokenContractAlert, ...mockUpgradeAccountAlert, ...mockOriginTrustSignalAlerts, ...mockAddressTrustSignalAlerts, diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts index 89778771ed5..e7f0d83247d 100644 --- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts +++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts @@ -17,6 +17,7 @@ import { useTokenTrustSignalAlerts } from './useTokenTrustSignalAlerts'; import { useAddressTrustSignalAlerts } from './useAddressTrustSignalAlerts'; import { useOriginTrustSignalAlerts } from './useOriginTrustSignalAlerts'; import { useFirstTimeInteractionAlert } from './useFirstTimeInteractionAlert'; +import { useTokenContractAlert } from './useTokenContractAlert'; function useSignatureAlerts(): Alert[] { const domainMismatchAlerts = useDomainMismatchAlerts(); @@ -38,6 +39,7 @@ function useTransactionAlerts(): Alert[] { const burnAddressAlert = useBurnAddressAlert(); const tokenTrustSignalAlerts = useTokenTrustSignalAlerts(); const firstTimeInteractionAlert = useFirstTimeInteractionAlert(); + const tokenContractAlert = useTokenContractAlert(); return useMemo( () => [ @@ -53,6 +55,7 @@ function useTransactionAlerts(): Alert[] { ...burnAddressAlert, ...tokenTrustSignalAlerts, ...firstTimeInteractionAlert, + ...tokenContractAlert, ], [ gasEstimateFailedAlert, @@ -67,6 +70,7 @@ function useTransactionAlerts(): Alert[] { burnAddressAlert, tokenTrustSignalAlerts, firstTimeInteractionAlert, + tokenContractAlert, ], ); } diff --git a/app/components/Views/confirmations/hooks/alerts/useTokenContractAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useTokenContractAlert.test.ts new file mode 100644 index 00000000000..969849e34e1 --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/useTokenContractAlert.test.ts @@ -0,0 +1,256 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import { waitFor } from '@testing-library/react-native'; + +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; +import { AlertKeys } from '../../constants/alerts'; +import { Severity } from '../../types/alerts'; +import { memoizedGetTokenStandardAndDetails } from '../../utils/token'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { useTransferRecipient } from '../transactions/useTransferRecipient'; +import { useTokenContractAlert } from './useTokenContractAlert'; + +jest.mock('../transactions/useTransactionMetadataRequest'); +jest.mock('../transactions/useTransferRecipient'); +jest.mock('../../utils/token'); + +jest.mock('../../../../../core/Engine', () => ({ + context: { + NetworkController: { + findNetworkClientIdByChainId: jest.fn().mockReturnValue('mainnet'), + }, + }, +})); + +jest.mock('../../../../../util/address', () => ({ + ...jest.requireActual('../../../../../util/address'), + toChecksumAddress: jest.fn((addr: string) => addr), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +const mockUseTransactionMetadataRequest = + useTransactionMetadataRequest as jest.Mock; +const mockUseTransferRecipient = useTransferRecipient as jest.Mock; +const mockGetTokenDetails = + memoizedGetTokenStandardAndDetails as unknown as jest.Mock; + +describe('useTokenContractAlert', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseTransactionMetadataRequest.mockReturnValue(undefined); + mockUseTransferRecipient.mockReturnValue(undefined); + mockGetTokenDetails.mockResolvedValue(undefined); + }); + + it('returns empty array when transaction metadata is undefined', async () => { + mockUseTransactionMetadataRequest.mockReturnValue(undefined); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it('returns empty array when transaction type is not a transfer type', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.contractInteraction, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it('returns empty array when recipient is undefined', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue(undefined); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it('returns empty array when chainId is undefined', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: undefined, + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it('returns empty array when address is not a token contract', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockResolvedValue(undefined); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it('returns empty array when token lookup throws an error', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockRejectedValue(new Error('Network error')); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it('returns alert when recipient is a token contract for simpleSend', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockResolvedValue({ standard: 'ERC20' }); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toHaveLength(1); + }); + expect(result.current[0]).toMatchObject({ + key: AlertKeys.TokenContractAddress, + field: RowAlertKey.InteractingWith, + severity: Severity.Warning, + isBlocking: false, + }); + expect(result.current[0].title).toBeDefined(); + expect(result.current[0].message).toBeDefined(); + }); + + it('returns alert when recipient is a token contract for tokenMethodTransfer', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.tokenMethodTransfer, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockResolvedValue({ standard: 'ERC20' }); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toHaveLength(1); + }); + expect(result.current[0].key).toBe(AlertKeys.TokenContractAddress); + }); + + it('returns alert when recipient is a token contract for tokenMethodTransferFrom', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.tokenMethodTransferFrom, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockResolvedValue({ standard: 'ERC20' }); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toHaveLength(1); + }); + expect(result.current[0].key).toBe(AlertKeys.TokenContractAddress); + }); + + it('returns empty array when token has no standard field', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockResolvedValue({ name: 'SomeToken' }); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it('calls memoizedGetTokenStandardAndDetails with checksummed address', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockResolvedValue(undefined); + + renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(mockGetTokenDetails).toHaveBeenCalledWith({ + tokenAddress: '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + networkClientId: 'mainnet', + }); + }); + }); + + it('returns non-blocking warning alert with correct structure', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockResolvedValue({ standard: 'ERC721' }); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toHaveLength(1); + }); + const alert = result.current[0]; + expect(alert.isBlocking).toBe(false); + expect(alert.severity).toBe(Severity.Warning); + expect(alert.field).toBe(RowAlertKey.InteractingWith); + }); +}); diff --git a/app/components/Views/confirmations/hooks/alerts/useTokenContractAlert.ts b/app/components/Views/confirmations/hooks/alerts/useTokenContractAlert.ts new file mode 100644 index 00000000000..29dc6863022 --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/useTokenContractAlert.ts @@ -0,0 +1,75 @@ +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; +import { useMemo } from 'react'; + +import { strings } from '../../../../../../locales/i18n'; +import Engine from '../../../../../core/Engine'; +import { toChecksumAddress } from '../../../../../util/address'; +import { useAsyncResult } from '../../../../hooks/useAsyncResult'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; +import { AlertKeys } from '../../constants/alerts'; +import { Alert, Severity } from '../../types/alerts'; +import { memoizedGetTokenStandardAndDetails } from '../../utils/token'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { useTransferRecipient } from '../transactions/useTransferRecipient'; + +const TRANSFER_TRANSACTION_TYPES: TransactionType[] = [ + TransactionType.simpleSend, + TransactionType.tokenMethodTransfer, + TransactionType.tokenMethodTransferFrom, +]; + +export function useTokenContractAlert(): Alert[] { + const transactionMetadata = + useTransactionMetadataRequest() as TransactionMeta; + const recipient = useTransferRecipient(); + const chainId = transactionMetadata?.chainId; + const transactionType = transactionMetadata?.type; + + const isTransfer = + transactionType !== undefined && + TRANSFER_TRANSACTION_TYPES.includes(transactionType as TransactionType); + + const { value: isTokenContract } = useAsyncResult(async () => { + if (!isTransfer || !recipient || !chainId) { + return false; + } + + try { + const { NetworkController } = Engine.context; + const networkClientId = NetworkController.findNetworkClientIdByChainId( + chainId as Hex, + ); + + const checksummedRecipient = toChecksumAddress(recipient); + const token = await memoizedGetTokenStandardAndDetails({ + tokenAddress: checksummedRecipient, + networkClientId, + }); + + return Boolean(token?.standard); + } catch { + return false; + } + }, [isTransfer, recipient, chainId]); + + return useMemo(() => { + if (!isTokenContract) { + return []; + } + + return [ + { + key: AlertKeys.TokenContractAddress, + field: RowAlertKey.InteractingWith, + message: strings('alert_system.token_contract_warning.message'), + title: strings('alert_system.token_contract_warning.title'), + severity: Severity.Warning, + isBlocking: false, + }, + ]; + }, [isTokenContract]); +} diff --git a/app/components/Views/confirmations/hooks/gas/useGasFeeEstimates.ts b/app/components/Views/confirmations/hooks/gas/useGasFeeEstimates.ts index 2f845aadced..099917bc314 100644 --- a/app/components/Views/confirmations/hooks/gas/useGasFeeEstimates.ts +++ b/app/components/Views/confirmations/hooks/gas/useGasFeeEstimates.ts @@ -13,12 +13,22 @@ import Engine from '../../../../../core/Engine'; * GasFeeController that it is done requiring new gas estimates. Also checks * the returned gas estimate for validity on the current network. * + * Normalizes falsy networkClientId (e.g. '' from networkClientId ?? '') to + * undefined so NetworkController.getNetworkClientById is never called with + * a falsy value (which throws "No network client ID was provided"). + * * @param _networkClientId - The optional network client ID to get gas fee estimates for. Defaults to the currently selected network. * @returns {GasEstimates} GasEstimates object */ -export function useGasFeeEstimates(networkClientId: string) { +export function useGasFeeEstimates(networkClientId: string | undefined) { const [chainId, setChainId] = useState(''); + // Avoid passing '' or other falsy values to NetworkController/GasFeeController; + // getNetworkClientById(undefined) is never called, getNetworkClientById('') throws. + const effectiveNetworkClientId = networkClientId?.trim() + ? networkClientId + : undefined; + const gasFeeEstimates = useSelector( (state: RootState) => selectGasFeeEstimatesByChainId(state, chainId), isEqual, @@ -26,10 +36,13 @@ export function useGasFeeEstimates(networkClientId: string) { const { NetworkController } = Engine.context; useEffect(() => { + if (!effectiveNetworkClientId) { + return; + } let isMounted = true; const networkConfig = NetworkController.getNetworkConfigurationByNetworkClientId( - networkClientId, + effectiveNetworkClientId, ); if (networkConfig && isMounted) { @@ -39,7 +52,7 @@ export function useGasFeeEstimates(networkClientId: string) { return () => { isMounted = false; }; - }, [networkClientId, NetworkController]); + }, [effectiveNetworkClientId, NetworkController]); usePolling({ startPolling: Engine.context.GasFeeController.startPolling.bind( @@ -49,7 +62,11 @@ export function useGasFeeEstimates(networkClientId: string) { Engine.context.GasFeeController.stopPollingByPollingToken.bind( Engine.context.GasFeeController, ), - input: [{ networkClientId }], + // GasFeeController.startPolling requires networkClientId: string; never pass + // undefined/'' (see hook JSDoc). When missing, skip polling until we have an id. + input: effectiveNetworkClientId + ? [{ networkClientId: effectiveNetworkClientId }] + : [], }); return { diff --git a/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts b/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts index 2a8316a0f24..61c83d09474 100644 --- a/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts +++ b/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts @@ -2,6 +2,7 @@ import { useTransactionMetadataRequest } from '../transactions/useTransactionMet import { useAsyncResult } from '../../../../hooks/useAsyncResult'; import { isRelaySupported } from '../../../../../util/transactions/transaction-relay'; import { Hex } from '@metamask/utils'; +import { isHardwareAccount } from '../../../../../util/address'; import { useGaslessSupportedSmartTransactions } from './useGaslessSupportedSmartTransactions'; /** @@ -45,10 +46,15 @@ export function useIsGaslessSupported() { txParams?.to !== undefined, ); - const isSupported = Boolean( - isSmartTransactionAndBundleSupported || is7702Supported, + const fromAddress = txParams?.from; + const isHardwareWallet = Boolean( + fromAddress && isHardwareAccount(fromAddress), ); + const isSupported = + !isHardwareWallet && + Boolean(isSmartTransactionAndBundleSupported || is7702Supported); + const isPending = smartTransactionPending || (shouldCheck7702Eligibility && relayPending); diff --git a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.test.ts b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.test.ts index d40a56b0676..19144ece22e 100644 --- a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.test.ts +++ b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.test.ts @@ -4,6 +4,7 @@ import { useAlerts } from '../../context/alert-system-context'; import { useConfirmationMetricEvents } from './useConfirmationMetricEvents'; import { useConfirmationAlertMetrics } from './useConfirmationAlertMetrics'; import { AlertKeys } from '../../constants/alerts'; +import { useSignatureRequest } from '../signatures/useSignatureRequest'; jest.mock('react-redux', () => ({ useSelector: jest.fn(), @@ -17,6 +18,10 @@ jest.mock('./useConfirmationMetricEvents', () => ({ useConfirmationMetricEvents: jest.fn(), })); +jest.mock('../signatures/useSignatureRequest', () => ({ + useSignatureRequest: jest.fn(), +})); + describe('useConfirmationAlertMetrics', () => { const ALERT_FIELD_FROM_MOCK = 'from'; const mockSetConfirmationMetric = jest.fn(); @@ -32,6 +37,7 @@ describe('useConfirmationAlertMetrics', () => { setConfirmationMetric: mockSetConfirmationMetric, }); (useAlerts as jest.Mock).mockReturnValue(mockUseAlerts); + (useSignatureRequest as jest.Mock).mockReturnValue({ id: 'test-id' }); }); const baseAlertProperties = { @@ -214,4 +220,55 @@ describe('useConfirmationAlertMetrics', () => { properties: baseAlertProperties, }); }); + + it('trackAlertMetrics does not call setConfirmationMetric when no alerts', () => { + (useAlerts as jest.Mock).mockReturnValue({ + alerts: [], + isAlertConfirmed: jest.fn(), + alertKey: '', + }); + (useSelector as jest.Mock).mockReturnValue({}); + + const { result } = renderHook(() => useConfirmationAlertMetrics()); + result.current.trackAlertMetrics(); + + expect(mockSetConfirmationMetric).not.toHaveBeenCalled(); + }); + + it('resolves alert name using prefix matching for composite keys', () => { + const compositeKey = `${AlertKeys.Blockaid}_extra_suffix`; + (useAlerts as jest.Mock).mockReturnValue({ + alerts: [{ key: compositeKey }], + isAlertConfirmed: jest.fn(), + alertKey: compositeKey, + }); + (useSelector as jest.Mock).mockReturnValue({ + properties: {}, + }); + + const { result } = renderHook(() => useConfirmationAlertMetrics()); + result.current.trackInlineAlertClicked('field'); + + expect(mockSetConfirmationMetric).toHaveBeenCalledWith({ + properties: expect.objectContaining({ + alert_trigger_name: ['blockaid'], + alert_key_clicked: ['blockaid'], + }), + }); + }); + + it('handles undefined signatureRequest', () => { + (useSignatureRequest as jest.Mock).mockReturnValue(undefined); + (useSelector as jest.Mock).mockReturnValue({ + properties: baseAlertProperties, + }); + + const { result } = renderHook(() => useConfirmationAlertMetrics()); + + result.current.trackAlertMetrics(); + + expect(mockSetConfirmationMetric).toHaveBeenCalledWith({ + properties: baseAlertProperties, + }); + }); }); diff --git a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts index e94e9250623..d59160ffa94 100644 --- a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts +++ b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts @@ -127,6 +127,7 @@ const ALERTS_NAME_METRICS: AlertNameMetrics = { [AlertKeys.PerpsDepositMinimum]: 'minimum_deposit', [AlertKeys.PerpsHardwareAccount]: 'perps_hardware_account', [AlertKeys.SignedOrSubmitted]: 'signed_or_submitted', + [AlertKeys.TokenContractAddress]: 'token_contract_address', [AlertKeys.TokenTrustSignalMalicious]: 'token_trust_signal_malicious', [AlertKeys.TokenTrustSignalWarning]: 'token_trust_signal_warning', }; diff --git a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts index db31676315a..774c3fd151b 100644 --- a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts @@ -5,7 +5,10 @@ import { SetPayTokenRequest, } from './useAutomaticTransactionPayToken'; import { useTransactionPayToken } from './useTransactionPayToken'; -import { simpleSendTransactionControllerMock } from '../../__mocks__/controllers/transaction-controller-mock'; +import { + simpleSendTransactionControllerMock, + transactionIdMock, +} from '../../__mocks__/controllers/transaction-controller-mock'; import { transactionApprovalControllerMock } from '../../__mocks__/controllers/approval-controller-mock'; import { MetaMaskPayTokensFlags, @@ -18,12 +21,14 @@ import { Hex } from '@metamask/utils'; import { useTransactionPayRequiredTokens } from './useTransactionPayData'; import { useTransactionPayAvailableTokens } from './useTransactionPayAvailableTokens'; import { AssetType } from '../../types/token'; +import { useWithdrawTokenFilter } from './useWithdrawTokenFilter'; jest.mock('./useTransactionPayToken'); jest.mock('../../../../../util/address'); jest.mock('../../../../../selectors/transactionPayController'); jest.mock('./useTransactionPayData'); jest.mock('./useTransactionPayAvailableTokens'); +jest.mock('./useWithdrawTokenFilter'); jest.mock( '../../../../../selectors/featureFlagController/confirmations', () => ({ @@ -82,6 +87,7 @@ describe('useAutomaticTransactionPayToken', () => { const useTransactionPayAvailableTokensMock = jest.mocked( useTransactionPayAvailableTokens, ); + const useWithdrawTokenFilterMock = jest.mocked(useWithdrawTokenFilter); const isHardwareAccountMock = jest.mocked(isHardwareAccount); const useTransactionPayRequiredTokensMock = jest.mocked( useTransactionPayRequiredTokens, @@ -110,6 +116,7 @@ describe('useAutomaticTransactionPayToken', () => { ]); isHardwareAccountMock.mockReturnValue(false); + useWithdrawTokenFilterMock.mockReturnValue((tokens) => tokens); selectMetaMaskPayTokensFlagsMock.mockReturnValue({ preferredTokens: { default: [], overrides: {} }, @@ -561,6 +568,97 @@ describe('useAutomaticTransactionPayToken', () => { }); }); + it('selects last used token for predict withdraw from nested transaction history', () => { + const predictWithdrawStateMock = merge( + {}, + simpleSendTransactionControllerMock, + transactionApprovalControllerMock, + { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + id: transactionIdMock, + nestedTransactions: [ + { type: TransactionType.predictWithdraw }, + ], + status: 'unapproved', + time: 200, + txParams: { from: '0x123' }, + type: TransactionType.batch, + }, + { + id: 'previous-predict-withdraw', + metamaskPay: { + chainId: CHAIN_ID_2_MOCK, + tokenAddress: TOKEN_ADDRESS_2_MOCK, + }, + nestedTransactions: [ + { type: TransactionType.predictWithdraw }, + ], + status: 'confirmed', + time: 100, + txParams: { from: '0x123' }, + type: TransactionType.batch, + }, + ], + }, + }, + }, + }, + ); + + useTransactionPayAvailableTokensMock.mockReturnValue({ + availableTokens: [ + { + address: TOKEN_ADDRESS_2_MOCK, + balance: '1', + chainId: CHAIN_ID_2_MOCK, + symbol: 'BNB', + }, + { + address: PREFERRED_TOKEN_ADDRESS_MOCK, + balance: '1', + chainId: PREFERRED_CHAIN_ID_MOCK, + symbol: 'MUSD', + }, + ] as AssetType[], + hasTokens: true, + }); + selectMetaMaskPayTokensFlagsMock.mockReturnValue({ + preferredTokens: { + default: [], + overrides: { + predictWithdraw: [ + { + address: PREFERRED_TOKEN_ADDRESS_MOCK, + chainId: PREFERRED_CHAIN_ID_MOCK, + successRate: 1, + }, + ], + }, + }, + minimumRequiredTokenBalance: 0, + blockedTokens: { + default: { + chainIds: [], + tokens: [], + }, + overrides: {}, + }, + } as MetaMaskPayTokensFlags); + + renderHookWithProvider(() => useAutomaticTransactionPayToken(), { + state: predictWithdrawStateMock, + }); + + expect(setPayTokenMock).toHaveBeenCalledWith({ + address: TOKEN_ADDRESS_2_MOCK, + chainId: CHAIN_ID_2_MOCK, + }); + }); + it('treats missing fiat balance as 0 for minimum balance check', () => { selectMetaMaskPayTokensFlagsMock.mockReturnValue({ preferredTokens: { diff --git a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts index fa5b427e67d..1a2951603a4 100644 --- a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts +++ b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts @@ -8,13 +8,19 @@ import { TransactionMeta } from '@metamask/transaction-controller'; import { useTransactionPayRequiredTokens } from './useTransactionPayData'; import { useTransactionPayAvailableTokens } from './useTransactionPayAvailableTokens'; import { AssetType } from '../../types/token'; -import { isTransactionPayWithdraw } from '../../utils/transaction'; +import { + getPostQuoteTransactionType, + isTransactionPayWithdraw, +} from '../../utils/transaction'; import { useSelector } from 'react-redux'; import { selectMetaMaskPayTokensFlags, PreferredToken, getPreferredTokensForTransactionType, } from '../../../../../selectors/featureFlagController/confirmations'; +import { RootState } from '../../../../../reducers'; +import { selectLastWithdrawTokenByType } from '../../../../../selectors/transactionController'; +import { useWithdrawTokenFilter } from './useWithdrawTokenFilter'; export interface SetPayTokenRequest { address: Hex; @@ -30,22 +36,19 @@ export function useAutomaticTransactionPayToken({ disable?: boolean; preferredToken?: SetPayTokenRequest; } = {}) { - const isUpdated = useRef(false); - const { setPayToken } = useTransactionPayToken(); + const isUpdated = useRef(); + const { payToken, setPayToken } = useTransactionPayToken(); const requiredTokens = useTransactionPayRequiredTokens(); - const { availableTokens: tokens } = useTransactionPayAvailableTokens(); + const { availableTokens } = useTransactionPayAvailableTokens(); const payTokensFlags = useSelector(selectMetaMaskPayTokensFlags); - const tokensWithBalance = useMemo( - () => tokens.filter((t) => !t.disabled), - [tokens], - ); - const transactionMetaRequest = useTransactionMetadataRequest(); const transactionMeta = useMemo( () => transactionMetaRequest ?? ({ txParams: {} } as TransactionMeta), [transactionMetaRequest], ); + const transactionId = transactionMeta.id; + const postQuoteTransactionType = getPostQuoteTransactionType(transactionMeta); const { txParams: { from }, @@ -65,24 +68,45 @@ export function useAutomaticTransactionPayToken({ () => getPreferredTokensForTransactionType( payTokensFlags.preferredTokens, - transactionMeta.type, + postQuoteTransactionType ?? transactionMeta.type, ), - [transactionMeta.type, payTokensFlags.preferredTokens], + [ + transactionMeta.type, + postQuoteTransactionType, + payTokensFlags.preferredTokens, + ], ); - // For withdrawals, skip auto-selection — the default token is derived - // from required tokens and shown via PayWithRow const isWithdraw = isTransactionPayWithdraw(transactionMeta); + const lastWithdrawToken = useSelector((state: RootState) => + selectLastWithdrawTokenByType(state, postQuoteTransactionType), + ); + const withdrawTokenFilter = useWithdrawTokenFilter(); + + const tokens = useMemo( + () => + isWithdraw + ? withdrawTokenFilter(availableTokens) + : availableTokens.filter((t) => !t.disabled), + [availableTokens, isWithdraw, withdrawTokenFilter], + ); useEffect(() => { - if (disable || isWithdraw || isUpdated.current) { + if ( + disable || + payToken || + !transactionId || + isUpdated.current === transactionId + ) { return; } const automaticToken = getBestToken({ isHardwareWallet, + isWithdraw, + lastWithdrawToken, targetToken, - tokens: tokensWithBalance, + tokens, preferredToken, preferredTokensFromFlags, minimumRequiredTokenBalance: payTokensFlags.minimumRequiredTokenBalance, @@ -98,25 +122,30 @@ export function useAutomaticTransactionPayToken({ chainId: automaticToken.chainId, }); - isUpdated.current = true; + isUpdated.current = transactionId; log('Automatically selected pay token', automaticToken); }, [ disable, isHardwareWallet, isWithdraw, + lastWithdrawToken, payTokensFlags.minimumRequiredTokenBalance, + payToken, preferredToken, preferredTokensFromFlags, requiredTokens, setPayToken, targetToken, - tokensWithBalance, + tokens, + transactionId, ]); } function getBestToken({ isHardwareWallet, + isWithdraw, + lastWithdrawToken, preferredToken, preferredTokensFromFlags, minimumRequiredTokenBalance, @@ -124,6 +153,8 @@ function getBestToken({ tokens, }: { isHardwareWallet: boolean; + isWithdraw: boolean; + lastWithdrawToken?: SetPayTokenRequest; preferredToken?: SetPayTokenRequest; preferredTokensFromFlags: PreferredToken[]; minimumRequiredTokenBalance: number; @@ -141,6 +172,20 @@ function getBestToken({ return targetTokenFallback; } + if (isWithdraw && lastWithdrawToken) { + const lastWithdrawTokenAvailable = tokens.some( + (token) => + token.address.toLowerCase() === + lastWithdrawToken.address.toLowerCase() && + token.chainId?.toLowerCase() === + lastWithdrawToken.chainId.toLowerCase(), + ); + + if (lastWithdrawTokenAvailable) { + return lastWithdrawToken; + } + } + if (preferredToken) { const preferredTokenAvailable = tokens.some( (token) => @@ -166,6 +211,13 @@ function getBestToken({ ); if (matchingToken) { + if (isWithdraw) { + return { + address: matchingToken.address as Hex, + chainId: matchingToken.chainId as Hex, + }; + } + const fiatBalance = matchingToken.fiat?.balance ?? 0; if (fiatBalance >= minimumRequiredTokenBalance) { @@ -179,6 +231,10 @@ function getBestToken({ } if (tokens?.length) { + if (isWithdraw) { + return undefined; + } + return { address: tokens[0].address as Hex, chainId: tokens[0].chainId as Hex, diff --git a/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts b/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts index d5e5209d5ae..454b72f61ef 100644 --- a/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts +++ b/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts @@ -15,7 +15,7 @@ import { validatePositiveNumericString, } from './useAmountValidation'; import { AssetType, TokenStandard } from '../../types/token'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as SendContext from '../../context/send-context/send-context'; import { validateAmountMultichain } from '../../utils/multichain-snaps'; diff --git a/app/components/Views/confirmations/hooks/send/useNameValidation.test.ts b/app/components/Views/confirmations/hooks/send/useNameValidation.test.ts index e7a3c69cc7b..05c366622d0 100644 --- a/app/components/Views/confirmations/hooks/send/useNameValidation.test.ts +++ b/app/components/Views/confirmations/hooks/send/useNameValidation.test.ts @@ -1,9 +1,9 @@ import { AddressResolution } from '@metamask/snaps-sdk'; import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as SnapNameResolution from '../../../../Snaps/hooks/useSnapNameResolution'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as SendValidationUtils from '../../utils/send-address-validations'; import { evmSendStateMock } from '../../__mocks__/send.mock'; import { useNameValidation } from './useNameValidation'; diff --git a/app/components/Views/confirmations/hooks/send/usePercentageAmount.test.ts b/app/components/Views/confirmations/hooks/send/usePercentageAmount.test.ts index 71169713adc..ca44971a875 100644 --- a/app/components/Views/confirmations/hooks/send/usePercentageAmount.test.ts +++ b/app/components/Views/confirmations/hooks/send/usePercentageAmount.test.ts @@ -8,7 +8,7 @@ import { SOLANA_ASSET, } from '../../__mocks__/send.mock'; import { useSendContext } from '../../context/send-context'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as SendUtils from '../../utils/send'; import { usePercentageAmount } from './usePercentageAmount'; import { useBalance } from './useBalance'; diff --git a/app/components/Views/confirmations/hooks/send/useSendActions.test.ts b/app/components/Views/confirmations/hooks/send/useSendActions.test.ts index e496b215f01..8eb38809f7a 100644 --- a/app/components/Views/confirmations/hooks/send/useSendActions.test.ts +++ b/app/components/Views/confirmations/hooks/send/useSendActions.test.ts @@ -10,13 +10,13 @@ import { SOLANA_ASSET, } from '../../__mocks__/send.mock'; import { useSendContext } from '../../context/send-context'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as SendUtils from '../../utils/send'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as SendExitMetrics from './metrics/useSendExitMetrics'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as MultichainSnaps from '../../utils/multichain-snaps'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as SendType from './useSendType'; import { useSendActions } from './useSendActions'; diff --git a/app/components/Views/confirmations/hooks/send/useSnapAmounOnInput.test.ts b/app/components/Views/confirmations/hooks/send/useSnapAmounOnInput.test.ts index 9499aaedd01..c0155c881e3 100644 --- a/app/components/Views/confirmations/hooks/send/useSnapAmounOnInput.test.ts +++ b/app/components/Views/confirmations/hooks/send/useSnapAmounOnInput.test.ts @@ -2,9 +2,9 @@ import { InternalAccount } from '@metamask/keyring-internal-api'; import { CaipAssetType } from '@metamask/utils'; import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as SendContext from '../../context/send-context/send-context'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as MultichainSnaps from '../../utils/multichain-snaps'; import { useSnapAmountOnInput } from './useSnapAmountOnInput'; diff --git a/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts b/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts index fb79267c211..560cbcfeb55 100644 --- a/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts +++ b/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts @@ -46,6 +46,7 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, + toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -63,6 +64,7 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, + toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -90,6 +92,7 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Invalid address', + toAddressErrorAllowAcknowledge: false, toAddressValidated: '0x123', toAddressWarning: undefined, }); @@ -111,6 +114,7 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Invalid address', + toAddressErrorAllowAcknowledge: false, toAddressValidated: 'dummy', toAddressWarning: undefined, }); @@ -139,9 +143,101 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Invalid address', + toAddressErrorAllowAcknowledge: false, toAddressValidated: 'dummy', toAddressWarning: undefined, }); }); }); + + it('validate valid evm hex address through validateHexAddress', async () => { + mockUseSendContext.mockReturnValue({ + asset: { + name: 'Ethereum', + address: ETHEREUM_ADDRESS, + isNative: true, + chainId: '0x1', + symbol: 'ETH', + decimals: 18, + }, + to: '0xdB055877e6c13b6A6B25aBcAA29B393777dD0a73', + chainId: '0x1', + } as unknown as ReturnType); + const { result } = renderHookWithProvider( + () => useToAddressValidation(), + mockState, + ); + await waitFor(() => { + expect(result.current.toAddressValidated).toBe( + '0xdB055877e6c13b6A6B25aBcAA29B393777dD0a73', + ); + expect(result.current.loading).toBe(false); + }); + }); + + it('validate valid solana address through validateSolanaAddress', async () => { + mockUseSendContext.mockReturnValue({ + asset: SOLANA_ASSET, + to: '14grJpemFaf88c8tiVb77W7TYg2W3ir6pfkKz3YjhhZ5', + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + } as unknown as ReturnType); + const { result } = renderHookWithProvider( + () => useToAddressValidation(), + mockState, + ); + await waitFor(() => { + expect(result.current.toAddressValidated).toBe( + '14grJpemFaf88c8tiVb77W7TYg2W3ir6pfkKz3YjhhZ5', + ); + expect(result.current.loading).toBe(false); + }); + }); + + it('validate ENS name through validateName', async () => { + mockUseSendContext.mockReturnValue({ + asset: { + name: 'Ethereum', + address: ETHEREUM_ADDRESS, + isNative: true, + chainId: '0x1', + symbol: 'ETH', + decimals: 18, + }, + to: 'test.eth', + chainId: '0x1', + } as unknown as ReturnType); + const { result } = renderHookWithProvider( + () => useToAddressValidation(), + mockState, + ); + await waitFor(() => { + expect(result.current.toAddressValidated).toBe('test.eth'); + expect(result.current.loading).toBe(false); + }); + }); + + it('returns no validation when chainId is missing', async () => { + mockUseSendContext.mockReturnValue({ + asset: { + name: 'Ethereum', + address: ETHEREUM_ADDRESS, + isNative: true, + chainId: '0x1', + symbol: 'ETH', + decimals: 18, + }, + to: '0xdB055877e6c13b6A6B25aBcAA29B393777dD0a73', + chainId: undefined, + } as unknown as ReturnType); + const { result } = renderHookWithProvider( + () => useToAddressValidation(), + mockState, + ); + await waitFor(() => { + expect(result.current.toAddressValidated).toBe( + '0xdB055877e6c13b6A6B25aBcAA29B393777dD0a73', + ); + expect(result.current.toAddressError).toBeUndefined(); + }); + }); }); diff --git a/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts b/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts index 8dbb2082833..bdd9eca301d 100644 --- a/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts +++ b/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts @@ -20,6 +20,7 @@ interface ValidationResult { error?: string; warning?: string; resolvedAddress?: string; + allowAcknowledge?: boolean; } export const useToAddressValidation = () => { @@ -109,15 +110,17 @@ export const useToAddressValidation = () => { const { toAddressValidated, - error: toAddressError, + error, warning: toAddressWarning, resolvedAddress, + allowAcknowledge, } = result ?? {}; return { loading, resolvedAddress, - toAddressError, + toAddressError: error, + toAddressErrorAllowAcknowledge: allowAcknowledge === true, toAddressValidated, toAddressWarning, }; diff --git a/app/components/Views/confirmations/hooks/signatures/useTokenDecimalsInTypedSignRequest.test.ts b/app/components/Views/confirmations/hooks/signatures/useTokenDecimalsInTypedSignRequest.test.ts index 39e223c3e7c..fd02da4be06 100644 --- a/app/components/Views/confirmations/hooks/signatures/useTokenDecimalsInTypedSignRequest.test.ts +++ b/app/components/Views/confirmations/hooks/signatures/useTokenDecimalsInTypedSignRequest.test.ts @@ -3,7 +3,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { typedSignV4SignatureRequest } from '../../../../../util/test/confirm-data-helpers'; import { DataTreeInput } from '../../components/data-tree/data-tree'; import { parseNormalizeAndSanitizeSignTypedData } from '../../utils/signature'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as TokenDecimalHook from '../useGetTokenStandardAndDetails'; import { useTokenDecimalsInTypedSignRequest } from './useTokenDecimalsInTypedSignRequest'; diff --git a/app/components/Views/confirmations/hooks/useConfirmActions.test.ts b/app/components/Views/confirmations/hooks/useConfirmActions.test.ts index 23e608f4208..3ea894e1a4f 100644 --- a/app/components/Views/confirmations/hooks/useConfirmActions.test.ts +++ b/app/components/Views/confirmations/hooks/useConfirmActions.test.ts @@ -8,9 +8,9 @@ import { stakingDepositConfirmationState, } from '../../../../util/test/confirm-data-helpers'; import PPOMUtil from '../../../../lib/ppom/ppom-util'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as QRHardwareHook from '../context/qr-hardware-context/qr-hardware-context'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as LedgerContext from '../context/ledger-context/ledger-context'; import { useTransactionConfirm } from './transactions/useTransactionConfirm'; import { useConfirmActions } from './useConfirmActions'; diff --git a/app/components/Views/confirmations/hooks/useConfirmNavigation.test.ts b/app/components/Views/confirmations/hooks/useConfirmNavigation.test.ts index e781d0ff737..d842ad6aea1 100644 --- a/app/components/Views/confirmations/hooks/useConfirmNavigation.test.ts +++ b/app/components/Views/confirmations/hooks/useConfirmNavigation.test.ts @@ -21,7 +21,7 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('../../../../core/Engine', () => ({ context: { ApprovalController: { - reject: jest.fn(), + rejectRequest: jest.fn(), }, }, })); @@ -111,8 +111,8 @@ describe('useConfirmNavigation', () => { Engine.context.ApprovalController, ); - expect(approvalControllerMock.reject).toHaveBeenCalledTimes(1); - expect(approvalControllerMock.reject).toHaveBeenCalledWith( + expect(approvalControllerMock.rejectRequest).toHaveBeenCalledTimes(1); + expect(approvalControllerMock.rejectRequest).toHaveBeenCalledWith( TRANSACTION_ID_MOCK, expect.anything(), ); diff --git a/app/components/Views/confirmations/hooks/useConfirmNavigation.ts b/app/components/Views/confirmations/hooks/useConfirmNavigation.ts index 2d456c4e5b9..7dac76a53fa 100644 --- a/app/components/Views/confirmations/hooks/useConfirmNavigation.ts +++ b/app/components/Views/confirmations/hooks/useConfirmNavigation.ts @@ -104,7 +104,10 @@ function rejectTransactions(transactions: TransactionMeta[]) { for (const tx of transactions) { try { - ApprovalController.reject(tx.id, providerErrors.userRejectedRequest()); + ApprovalController.rejectRequest( + tx.id, + providerErrors.userRejectedRequest(), + ); log('Rejected transaction', tx.type, tx.id); } catch { log('Failed to reject transaction', tx.type, tx.id); diff --git a/app/components/Views/confirmations/hooks/useIsInsufficientBalance.test.ts b/app/components/Views/confirmations/hooks/useIsInsufficientBalance.test.ts index b6fe2351936..4ac33094053 100644 --- a/app/components/Views/confirmations/hooks/useIsInsufficientBalance.test.ts +++ b/app/components/Views/confirmations/hooks/useIsInsufficientBalance.test.ts @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react-hooks'; import { useIsInsufficientBalance } from './useIsInsufficientBalance'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as useInsufficientBalanceAlertModule from './alerts/useInsufficientBalanceAlert'; import { Severity } from '../types/alerts'; diff --git a/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/index.jsx b/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/index.jsx deleted file mode 100644 index 4737b404131..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/index.jsx +++ /dev/null @@ -1,787 +0,0 @@ -/* eslint-disable react/prop-types */ -/* eslint-disable react/no-unstable-nested-components */ -import BigNumber from 'bignumber.js'; -import React, { useCallback, useMemo, useState } from 'react'; -import { - ScrollView, - TouchableOpacity, - TouchableWithoutFeedback, - View, -} from 'react-native'; -import Icon from 'react-native-vector-icons/Ionicons'; -import MaterialCommunityIcon from 'react-native-vector-icons/MaterialCommunityIcons'; -import { EditGasViewSelectorsIDs } from '../EditGasView.testIds'; -import { strings } from '../../../../../../../locales/i18n'; -import { MetaMetricsEvents } from '../../../../../../core/Analytics'; -import AppConstants from '../../../../../../core/AppConstants'; -import { useGasTransaction } from '../../../../../../core/GasPolling/GasPolling'; -import { - GAS_PRICE_INCREMENT as GAS_INCREMENT, - GAS_LIMIT_INCREMENT, - GAS_LIMIT_MIN, - GAS_PRICE_MIN as GAS_MIN, -} from '../../../../../../util/gasUtils'; -import { - getDecimalChainId, - isMainnetByChainId, -} from '../../../../../../util/networks'; -import { - mockTheme, - useAppThemeFromContext, -} from '../../../../../../util/theme'; -import Alert, { AlertType } from '../../../../../Base/Alert'; -import useModalHandler from '../../../../../Base/hooks/useModalHandler'; -import HorizontalSelector from '../../../../../Base/HorizontalSelector'; -import RangeInput from '../../../../../Base/RangeInput'; -import Text from '../../../../../Base/Text'; -import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; -import FadeAnimationView from '../../../../../UI/FadeAnimationView'; -import StyledButton from '../../../../../UI/StyledButton'; -import InfoModal from '../../../../../Base/InfoModal'; -import TimeEstimateInfoModal from '../../../../../UI/TimeEstimateInfoModal'; -import createStyles from './styles'; - -const EditGasFee1559Update = ({ - selectedGasValue, - gasOptions, - primaryCurrency, - chainId, - onCancel, - onChange, - onSave, - error, - dappSuggestedGas, - ignoreOptions, - updateOption, - extendOptions = {}, - recommended, - warningMinimumEstimateOption, - suggestedEstimateOption, - animateOnChange, - isAnimating, - analyticsParams, - warning, - selectedGasObject, - onlyGas, -}) => { - const [modalInfo, updateModalInfo] = useState({ - isVisible: false, - value: '', - }); - const [showAdvancedOptions, setShowAdvancedOptions] = - useState(!selectedGasValue); - const [maxPriorityFeeError, setMaxPriorityFeeError] = useState(''); - const [maxFeeError, setMaxFeeError] = useState(''); - const [showLearnMoreModal, setShowLearnMoreModal] = useState(false); - const [selectedOption, setSelectedOption] = useState(selectedGasValue); - const [showInputs, setShowInputs] = useState(!dappSuggestedGas); - const [gasObject, updateGasObject] = useState({ - suggestedMaxFeePerGas: selectedGasObject.suggestedMaxFeePerGas, - suggestedMaxPriorityFeePerGas: - selectedGasObject.suggestedMaxPriorityFeePerGas, - suggestedGasLimit: selectedGasObject.suggestedGasLimit, - }); - - const [ - isVisibleTimeEstimateInfoModal, - showTimeEstimateInfoModal, - hideTimeEstimateInfoModal, - ] = useModalHandler(false); - const { colors } = useAppThemeFromContext() || mockTheme; - const { trackEvent, createEventBuilder } = useAnalytics(); - const styles = createStyles(colors); - - const gasTransaction = useGasTransaction({ - onlyGas, - gasSelected: selectedOption, - legacy: false, - gasObject, - }); - - const { - renderableGasFeeMinNative, - renderableGasFeeMaxNative, - renderableGasFeeMaxConversion, - renderableMaxFeePerGasNative, - renderableGasFeeMinConversion, - renderableMaxPriorityFeeNative, - renderableMaxFeePerGasConversion, - renderableMaxPriorityFeeConversion, - timeEstimateColor, - timeEstimate, - timeEstimateId, - suggestedMaxFeePerGas, - suggestedMaxPriorityFeePerGas, - suggestedGasLimit, - } = gasTransaction; - - const getAnalyticsParams = useCallback(() => { - try { - return { - ...analyticsParams, - chain_id: getDecimalChainId(chainId), - function_type: analyticsParams.view, - gas_mode: selectedOption ? 'Basic' : 'Advanced', - speed_set: selectedOption || undefined, - }; - } catch (err) { - return {}; - } - }, [analyticsParams, chainId, selectedOption]); - - const toggleAdvancedOptions = useCallback(() => { - if (!showAdvancedOptions) { - trackEvent( - createEventBuilder(MetaMetricsEvents.GAS_ADVANCED_OPTIONS_CLICKED) - .addProperties(getAnalyticsParams()) - .build(), - ); - } - setShowAdvancedOptions(!showAdvancedOptions); - }, [getAnalyticsParams, showAdvancedOptions, trackEvent, createEventBuilder]); - - const toggleLearnMoreModal = useCallback(() => { - setShowLearnMoreModal(!showLearnMoreModal); - }, [showLearnMoreModal]); - - const toggleInfoModal = useCallback( - (value) => { - updateModalInfo({ isVisible: !modalInfo.isVisible, value }); - }, - [updateModalInfo, modalInfo.isVisible], - ); - - const save = useCallback(() => { - trackEvent( - createEventBuilder(MetaMetricsEvents.GAS_FEE_CHANGED) - .addProperties(getAnalyticsParams()) - .build(), - ); - - const newGasPriceObject = { - suggestedMaxFeePerGas: gasObject?.suggestedMaxFeePerGas, - suggestedMaxPriorityFeePerGas: gasObject?.suggestedMaxPriorityFeePerGas, - suggestedGasLimit: gasObject?.suggestedGasLimit, - }; - - onSave(gasTransaction, newGasPriceObject); - }, [ - getAnalyticsParams, - onSave, - gasTransaction, - gasObject, - trackEvent, - createEventBuilder, - ]); - - const changeGas = useCallback( - (gas, option) => { - setSelectedOption(option); - updateGasObject({ - ...gasObject, - suggestedMaxFeePerGas: gas.suggestedMaxFeePerGas, - suggestedMaxPriorityFeePerGas: gas.suggestedMaxPriorityFeePerGas, - suggestedGasLimit: gas.suggestedGasLimit || gasObject.suggestedGasLimit, - }); - onChange(option); - }, - [onChange, gasObject], - ); - - const changedGasLimit = useCallback( - (value) => { - const newGas = { ...gasTransaction, suggestedGasLimit: value }; - changeGas(newGas, null); - }, - [changeGas, gasTransaction], - ); - - const changedMaxPriorityFee = useCallback( - (value) => { - const lowerValue = new BigNumber( - gasOptions?.[ - warningMinimumEstimateOption - ]?.suggestedMaxPriorityFeePerGas, - ); - - const higherValue = new BigNumber( - gasOptions?.high?.suggestedMaxPriorityFeePerGas, - ).multipliedBy(new BigNumber(1.5)); - const updateFloor = new BigNumber(updateOption?.maxPriortyFeeThreshold); - - const valueBN = new BigNumber(value); - - if (updateFloor && !updateFloor.isNaN() && valueBN.lt(updateFloor)) { - setMaxPriorityFeeError( - updateOption?.isCancel - ? strings('edit_gas_fee_eip1559.max_priority_fee_cancel_low', { - cancel_value: updateFloor, - }) - : strings('edit_gas_fee_eip1559.max_priority_fee_speed_up_low', { - speed_up_floor_value: updateFloor, - }), - ); - } else if (!lowerValue.isNaN() && valueBN.lt(lowerValue)) { - setMaxPriorityFeeError( - strings('edit_gas_fee_eip1559.max_priority_fee_low'), - ); - } else if (!higherValue.isNaN() && valueBN.gt(higherValue)) { - setMaxPriorityFeeError( - strings('edit_gas_fee_eip1559.max_priority_fee_high'), - ); - } else { - setMaxPriorityFeeError(null); - } - - const newGas = { - ...gasTransaction, - suggestedMaxPriorityFeePerGas: value, - }; - - changeGas(newGas, null); - }, - [ - changeGas, - gasTransaction, - gasOptions, - updateOption, - warningMinimumEstimateOption, - ], - ); - - const changedMaxFeePerGas = useCallback( - (value) => { - const lowerValue = new BigNumber( - gasOptions?.[warningMinimumEstimateOption]?.suggestedMaxFeePerGas, - ); - const higherValue = new BigNumber( - gasOptions?.high?.suggestedMaxFeePerGas, - ).multipliedBy(new BigNumber(1.5)); - const updateFloor = new BigNumber(updateOption?.maxFeeThreshold); - - const valueBN = new BigNumber(value); - - if (updateFloor && !updateFloor.isNaN() && valueBN.lt(updateFloor)) { - setMaxFeeError( - updateOption?.isCancel - ? strings('edit_gas_fee_eip1559.max_fee_cancel_low', { - cancel_value: updateFloor, - }) - : strings('edit_gas_fee_eip1559.max_fee_speed_up_low', { - speed_up_floor_value: updateFloor, - }), - ); - } else if (!lowerValue.isNaN() && valueBN.lt(lowerValue)) { - setMaxFeeError(strings('edit_gas_fee_eip1559.max_fee_low')); - } else if (!higherValue.isNaN() && valueBN.gt(higherValue)) { - setMaxFeeError(strings('edit_gas_fee_eip1559.max_fee_high')); - } else { - setMaxFeeError(''); - } - - const newGas = { - ...gasTransaction, - suggestedMaxFeePerGas: value, - }; - - changeGas(newGas, null); - }, - [ - changeGas, - gasTransaction, - gasOptions, - updateOption, - warningMinimumEstimateOption, - ], - ); - - const selectOption = useCallback( - (option) => { - setSelectedOption(option); - setMaxFeeError(''); - setMaxPriorityFeeError(''); - changeGas({ ...gasOptions?.[option] }, option); - }, - [changeGas, gasOptions], - ); - - const shouldIgnore = useCallback( - (option) => ignoreOptions?.find((item) => item === option), - [ignoreOptions], - ); - - const renderOptions = useMemo( - () => - [ - { - name: AppConstants.GAS_OPTIONS.LOW, - label: strings('edit_gas_fee_eip1559.low'), - }, - { - name: AppConstants.GAS_OPTIONS.MEDIUM, - label: strings('edit_gas_fee_eip1559.market'), - }, - { - name: AppConstants.GAS_OPTIONS.HIGH, - label: strings('edit_gas_fee_eip1559.aggressive'), - }, - ] - .filter(({ name }) => !shouldIgnore(name)) - .map(({ name, label, ...option }) => ({ - name, - label: function LabelComponent(selected, disabled) { - return ( - - {label} - - ); - }, - topLabel: recommended?.name === name && recommended.render, - ...option, - ...extendOptions[name], - })), - [recommended, extendOptions, shouldIgnore], - ); - - const isMainnet = isMainnetByChainId(chainId); - const nativeCurrencySelected = primaryCurrency === 'ETH' || !isMainnet; - - const switchNativeCurrencyDisplayOptions = (nativeValue, fiatValue) => { - if (nativeCurrencySelected) return nativeValue; - return fiatValue; - }; - - const valueToWatch = `${renderableGasFeeMinNative}${renderableGasFeeMaxNative}`; - - const LeftLabelComponent = ({ value, infoValue }) => ( - - - {strings(value)} - - toggleInfoModal(infoValue)} - > - - - - ); - - const RightLabelComponent = ({ value }) => ( - - - {strings(value)}: - {' '} - {gasOptions?.[suggestedEstimateOption]?.suggestedMaxFeePerGas} GWEI - - ); - - const TextComponent = ({ title, value }) => ( - <> - - {strings(title)} - - - {strings(value)} - - - ); - - const renderInputs = (option) => ( - - - - - - - - - {strings('edit_gas_fee_eip1559.advanced_options')} - - - - - - {(showAdvancedOptions || option?.maxFeeThreshold) && ( - - - - } - min={GAS_LIMIT_MIN} - value={suggestedGasLimit} - onChangeValue={changedGasLimit} - name={strings('edit_gas_fee_eip1559.gas_limit')} - increment={GAS_LIMIT_INCREMENT} - /> - - - - } - rightLabelComponent={ - - } - value={suggestedMaxPriorityFeePerGas} - name={strings('edit_gas_fee_eip1559.max_priority_fee')} - unit={'GWEI'} - min={GAS_MIN} - increment={GAS_INCREMENT} - inputInsideLabel={ - renderableMaxPriorityFeeNative && - `≈ ${switchNativeCurrencyDisplayOptions( - renderableMaxPriorityFeeNative, - renderableMaxPriorityFeeConversion, - )}` - } - error={maxPriorityFeeError} - onChangeValue={changedMaxPriorityFee} - /> - - - - } - rightLabelComponent={ - - } - value={suggestedMaxFeePerGas} - name={strings('edit_gas_fee_eip1559.max_fee')} - unit={'GWEI'} - min={GAS_MIN} - increment={GAS_INCREMENT} - error={maxFeeError} - onChangeValue={changedMaxFeePerGas} - inputInsideLabel={ - renderableMaxFeePerGasNative && - `≈ ${switchNativeCurrencyDisplayOptions( - renderableMaxFeePerGasNative, - renderableMaxFeePerGasConversion, - )}` - } - /> - - - )} - - - - - - {strings('edit_gas_fee_eip1559.learn_more.title')} - - - - {option - ? strings('edit_gas_fee_eip1559.submit') - : strings('edit_gas_fee_eip1559.save')} - - - - ); - - const renderWarning = useMemo(() => { - if (!warning) return null; - if (typeof warning === 'string') - return ( - ( - - )} - style={styles.warningContainer} - > - {() => ( - - - {warning} - - - )} - - ); - - return warning; - }, [warning, styles, colors]); - - const renderError = useMemo(() => { - if (!error) return null; - if (typeof error === 'string') - return ( - ( - - )} - style={styles.warningContainer} - > - {() => ( - - - {error} - - - )} - - ); - - return error; - }, [error, styles, colors]); - - const renderDisplayTitle = useMemo(() => { - if (updateOption) - return updateOption.isCancel - ? strings('edit_gas_fee_eip1559.cancel_transaction') - : strings('edit_gas_fee_eip1559.speed_up_transaction'); - return strings('edit_gas_fee_eip1559.edit_priority'); - }, [updateOption]); - - return ( - - - - - - - - - - - {renderDisplayTitle} - - - - {updateOption && ( - - - {strings('edit_gas_fee_eip1559.new_gas_fee')}{' '} - - - toggleInfoModal('new_gas_fee')} - > - - - - )} - - {renderWarning} - {renderError} - - - - ~ - {switchNativeCurrencyDisplayOptions( - renderableGasFeeMinNative, - renderableGasFeeMinConversion, - )} - - - - - {strings('edit_gas_fee_eip1559.max_fee')}:{' '} - - {switchNativeCurrencyDisplayOptions( - renderableGasFeeMaxNative, - renderableGasFeeMaxConversion, - )}{' '} - ( - {switchNativeCurrencyDisplayOptions( - renderableGasFeeMaxConversion, - renderableGasFeeMaxNative, - )} - ) - - - - {timeEstimate} - - {timeEstimateId === - (AppConstants.GAS_TIMES.MAYBE || - AppConstants.GAS_TIMES.UNKNOWN) && ( - showTimeEstimateInfoModal()} - > - - - )} - - - {!showInputs ? ( - - setShowInputs(true)} - > - {strings('edit_gas_fee_eip1559.edit_suggested_gas_fee')} - - - ) : ( - renderInputs(updateOption) - )} - - - updateModalInfo({ ...modalInfo, isVisible: false }) - } - body={ - - - {modalInfo.value === 'gas_limit' && - strings('edit_gas_fee_eip1559.learn_more_gas_limit')} - {modalInfo.value === 'max_priority_fee' && - strings( - 'edit_gas_fee_eip1559.learn_more_max_priority_fee', - )} - {modalInfo.value === 'max_fee' && - strings('edit_gas_fee_eip1559.learn_more_max_fee')} - {modalInfo.value === 'new_gas_fee' && updateOption?.isCancel - ? strings( - 'edit_gas_fee_eip1559.learn_more_cancel_gas_fee', - ) - : strings('edit_gas_fee_eip1559.learn_more_new_gas_fee')} - - - } - /> - - - - - - {strings('edit_gas_fee_eip1559.learn_more.intro')} - - - - - - - - - } - /> - - - - - - ); -}; - -export default EditGasFee1559Update; diff --git a/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/styles.ts b/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/styles.ts deleted file mode 100644 index 4263cfaaf8e..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/styles.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { StyleSheet } from 'react-native'; -import Device from '../../../../../../util/device'; -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const createStyles = (colors: any) => - StyleSheet.create({ - root: { - backgroundColor: colors.background.default, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - minHeight: 200, - maxHeight: '95%', - paddingTop: 24, - paddingBottom: Device.isIphoneX() ? 32 : 24, - }, - wrapper: { - paddingHorizontal: 24, - }, - customGasHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - width: '100%', - paddingBottom: 20, - }, - newGasFeeHeader: { - flexDirection: 'row', - alignItems: 'center', - width: '100%', - justifyContent: 'center', - }, - headerContainer: { - alignItems: 'center', - marginBottom: 22, - }, - headerText: { - fontSize: 48, - flex: 1, - textAlign: 'center', - }, - headerTitle: { - flexDirection: 'row', - }, - saveButton: { - marginBottom: 20, - }, - labelTextContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - hitSlop: { - top: 10, - left: 10, - bottom: 10, - right: 10, - }, - labelInfo: { - color: colors.text.muted, - }, - advancedOptionsContainer: { - marginTop: 25, - marginBottom: 30, - }, - advancedOptionsInputsContainer: { - marginTop: 14, - }, - rangeInputContainer: { - marginBottom: 20, - }, - advancedOptionsButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - advancedOptionsIcon: { - paddingTop: 1, - marginLeft: 5, - }, - learnMoreLabels: { - marginTop: 9, - }, - warningTextContainer: { - lineHeight: 20, - paddingLeft: 4, - flex: 1, - }, - warningText: { - lineHeight: 20, - flex: 1, - color: colors.text.default, - }, - warningContainer: { - marginBottom: 20, - }, - dappEditGasContainer: { - marginVertical: 20, - }, - subheader: { - marginBottom: 6, - }, - learnMoreModal: { - maxHeight: Device.getDeviceHeight() * 0.7, - }, - redInfo: { - marginLeft: 2, - color: colors.error.default, - }, - }); - -export default createStyles; diff --git a/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/types.ts b/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/types.ts deleted file mode 100644 index e134502a368..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/types.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { GasFeeOptions } from '../../../../../../core/GasPolling/types'; - -export interface RenderInputProps { - updateOption: - | { - isCancel: boolean; - maxFeeThreshold: string; - maxPriortyFeeThreshold: string; - showAdvanced: boolean | undefined; - } - | undefined; -} -export interface EditGasFee1559UpdateProps { - /** - * The selected gas value (low, medium, high) - */ - selectedGasValue: string; - /** - * Gas fee options. - */ - gasOptions: GasFeeOptions; - /** - * Primary currency, either ETH or Fiat - */ - primaryCurrency: string; - /** - * Option to display speed up/cancel view - */ - updateOption: RenderInputProps; - /** - * If the values should animate upon update or not - */ - animateOnChange: boolean | undefined; - /** - * A string representing the network chainId - */ - chainId: string; - /** - * Function to set the gas selected value - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onChange: any; - /** - * Function called when user cancels - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onCancel: any; - /** - * Function called when user saves the new gas data - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onSave: any; - /** - * Error message to show - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - error: any; - /** - * Warning message to show - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - warning: any; - /** - * Boolean that specifies if the gas price was suggested by the dapp - */ - dappSuggestedGas: boolean | undefined; - /** - * An array of selected gas value and lower that should be ignored. - */ - ignoreOptions: string[] | undefined; - /** - * Extend options object. Object has option keys and properties will be spread - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - extendOptions: any; - /** - * Recommended object with type and render function - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - recommended: any; - /** - * Estimate option to compare with for too low warning - */ - warningMinimumEstimateOption: string; - /** - * Suggested estimate option to show recommended values - */ - suggestedEstimateOption: string; - /** - * Boolean to determine if the animation is happening - */ - isAnimating: boolean; - /** - * Extra analytics params to be send with the gas analytics - */ - analyticsParams: { - chain_id: string; - gas_estimate_type: string; - gas_mode: string; - speed_set: string; - view: string; - }; - /** - * This is used in calculating the new gas price from the advanced view. - * The maxFeePerGas is the max fee per gas that the user can set. - * The maxPriorityFeePerGas is the max fee per gas that the user can set for priority transactions. - */ - selectedGasObject: { - suggestedMaxFeePerGas: string; - suggestedMaxPriorityFeePerGas: string; - suggestedGasLimit: string; - }; - onlyGas?: boolean; -} diff --git a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/EditGasFeeLegacyUpdate.test.tsx b/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/EditGasFeeLegacyUpdate.test.tsx deleted file mode 100644 index 8b05a9c3819..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/EditGasFeeLegacyUpdate.test.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import React from 'react'; - -import { backgroundState } from '../../../../../../util/test/initial-root-state'; -import renderWithProvider, { - DeepPartial, -} from '../../../../../../util/test/renderWithProvider'; -import EditGasFeeLegacyUpdate from '.'; -import { RootState } from '../../../../../../reducers'; - -// Mock useGasTransaction since legacy transaction state has been removed -jest.mock('../../../../../../core/GasPolling/GasPolling', () => ({ - ...jest.requireActual('../../../../../../core/GasPolling/GasPolling'), - useGasTransaction: jest.fn(), -})); - -import { useGasTransaction } from '../../../../../../core/GasPolling/GasPolling'; - -const mockUseGasTransaction = useGasTransaction as jest.MockedFunction< - typeof useGasTransaction ->; - -const mockInitialState: ( - txnType?: 'none' | 'eth_gasPrice' | 'fee-market' | 'legacy' | undefined, -) => DeepPartial = (txnType = 'none') => ({ - engine: { - backgroundState: { - ...backgroundState, - GasFeeController: { - gasEstimateType: txnType, - }, - }, - }, -}); - -const selectedGasObjectForFeeMarket = { - legacyGasLimit: undefined, - suggestedMaxFeePerGas: '10', -}; - -const selectedGasObjectForLegacy = { - legacyGasLimit: undefined, - suggestedGasPrice: '3', -}; - -const sharedProps = { - view: 'Transaction', - analyticsParams: undefined, - onSave: () => undefined, - error: undefined, - onCancel: () => undefined, - onUpdatingValuesStart: () => undefined, - onUpdatingValuesEnd: () => undefined, - animateOnChange: undefined, - isAnimating: true, - hasDappSuggestedGas: false, - warning: 'test', - onlyGas: true, - chainId: '0x1', -}; - -const editGasFeeLegacyForFeeMarket = { - ...sharedProps, - selectedGasObject: selectedGasObjectForFeeMarket, -}; - -const editGasFeeLegacyForLegacy = { - ...sharedProps, - selectedGasObject: selectedGasObjectForLegacy, -}; - -// Mock gas transaction data for fee-market type (~ 0.00021 ETH = 21000 gas * 10 gwei) -const mockGasTransactionFeeMarket = { - transactionFee: '0.00021 ETH', - transactionFeeFiat: '$0.50', - suggestedGasPrice: '10', - suggestedGasPriceHex: '0x2540be400', - suggestedGasLimit: '21000', - suggestedGasLimitHex: '0x5208', - totalHex: '0x4e3b29200000', -}; - -// Mock gas transaction data for legacy type (~ 0.00006 ETH = 21000 gas * 3 gwei) -const mockGasTransactionLegacy = { - transactionFee: '0.00006 ETH', - transactionFeeFiat: '$0.15', - suggestedGasPrice: '3', - suggestedGasPriceHex: '0xb2d05e00', - suggestedGasLimit: '21000', - suggestedGasLimitHex: '0x5208', - totalHex: '0x174876e800', -}; - -describe('EditGasFeeLegacyUpdate', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should match snapshot', async () => { - mockUseGasTransaction.mockReturnValue( - mockGasTransactionFeeMarket as unknown as ReturnType< - typeof useGasTransaction - >, - ); - const initialState = mockInitialState(); - const container = renderWithProvider( - , - { state: initialState }, - ); - expect(container).toMatchSnapshot(); - }); - - it('should calculate the correct gas transaction fee for 1559 transaction', async () => { - mockUseGasTransaction.mockReturnValue( - mockGasTransactionFeeMarket as unknown as ReturnType< - typeof useGasTransaction - >, - ); - const initialState = mockInitialState('fee-market'); - const { findByText } = renderWithProvider( - , - { state: initialState }, - ); - - expect(await findByText('~ 0.00021 ETH')).toBeDefined(); - }); - - it('should calculate the correct gas transaction fee for legacy transaction', async () => { - mockUseGasTransaction.mockReturnValue( - mockGasTransactionLegacy as unknown as ReturnType< - typeof useGasTransaction - >, - ); - const initialState = mockInitialState('legacy'); - const { findByText } = renderWithProvider( - , - { state: initialState }, - ); - - expect(await findByText('~ 0.00006 ETH')).toBeDefined(); - }); -}); diff --git a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/__snapshots__/EditGasFeeLegacyUpdate.test.tsx.snap b/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/__snapshots__/EditGasFeeLegacyUpdate.test.tsx.snap deleted file mode 100644 index 8ac551743e9..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/__snapshots__/EditGasFeeLegacyUpdate.test.tsx.snap +++ /dev/null @@ -1,893 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EditGasFeeLegacyUpdate should match snapshot 1`] = ` - - - - - - - - -  - - - - Edit priority - - -  - - - - - - - 󰋼 - - - - - test - - - - - - - - ~ - 0.00021 ETH - - - - $0.50 - - - - - - - - Gas limit - - - - - 󰋼 - - - - - - - - -  - - - - - - - - - - -  - - - - - - - - - - - - Gas price - - - - (GWEI) - - - - 󰋼 - - - - - - - - -  - - - - - - - - - ≈ $0.50 - - - -  - - - - - - - - - - - Save - - - - - - - - - -`; diff --git a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/index.jsx b/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/index.jsx deleted file mode 100644 index 7eb5e0cc5c6..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/index.jsx +++ /dev/null @@ -1,420 +0,0 @@ -/* eslint-disable react/prop-types */ -/* eslint-disable react/display-name */ -import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; -import BigNumber from 'bignumber.js'; -import React, { useCallback, useMemo, useState } from 'react'; -import { - ScrollView, - TouchableOpacity, - TouchableWithoutFeedback, - View, -} from 'react-native'; -import Icon from 'react-native-vector-icons/Ionicons'; -import MaterialCommunityIcon from 'react-native-vector-icons/MaterialCommunityIcons'; -import { useSelector } from 'react-redux'; -import { EditGasViewSelectorsIDs } from '../EditGasView.testIds'; -import { strings } from '../../../../../../../locales/i18n'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../../component-library/components/Texts/Text'; -import { MetaMetricsEvents } from '../../../../../../core/Analytics'; -import { useGasTransaction } from '../../../../../../core/GasPolling/GasPolling'; -import { selectGasFeeEstimates } from '../../../../../../selectors/confirmTransaction'; -import { selectGasFeeControllerEstimateType } from '../../../../../../selectors/gasFeeController'; -import { selectPrimaryCurrency } from '../../../../../../selectors/settings'; -import { - GAS_LIMIT_INCREMENT, - GAS_LIMIT_MIN, - GAS_PRICE_INCREMENT, - GAS_PRICE_MIN, -} from '../../../../../../util/gasUtils'; -import { - getDecimalChainId, - isMainnetByChainId, -} from '../../../../../../util/networks'; -import { useTheme } from '../../../../../../util/theme'; -import Alert, { AlertType } from '../../../../../Base/Alert'; -import RangeInput from '../../../../../Base/RangeInput'; -import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; -import FadeAnimationView from '../../../../../UI/FadeAnimationView'; -import StyledButton from '../../../../../UI/StyledButton'; -import InfoModal from '../../../../../Base/InfoModal'; -import createStyles from './styles'; - -const EditGasFeeLegacy = ({ - onCancel, - onSave, - error, - warning, - onUpdatingValuesStart, - onUpdatingValuesEnd, - animateOnChange, - isAnimating, - analyticsParams, - view, - onlyGas, - selectedGasObject, - hasDappSuggestedGas, - chainId, -}) => { - const { trackEvent, createEventBuilder } = useAnalytics(); - const [showRangeInfoModal, setShowRangeInfoModal] = useState(false); - const [infoText, setInfoText] = useState(''); - const [gasPriceError, setGasPriceError] = useState(''); - const [showEditUI, setShowEditUI] = useState(!hasDappSuggestedGas); - const [gasObjectLegacy, updateGasObjectLegacy] = useState({ - legacyGasLimit: selectedGasObject.legacyGasLimit, - suggestedGasPrice: - selectedGasObject.suggestedGasPrice || - selectedGasObject.suggestedMaxFeePerGas, - }); - - const { colors } = useTheme(); - const styles = createStyles(colors); - const gasFeeEstimate = useSelector(selectGasFeeEstimates); - - const primaryCurrency = useSelector(selectPrimaryCurrency); - - const gasEstimateType = useSelector(selectGasFeeControllerEstimateType); - - const gasTransaction = useGasTransaction({ - onlyGas, - legacy: true, - gasObjectLegacy, - }); - - const save = useCallback(() => { - trackEvent( - createEventBuilder(MetaMetricsEvents.GAS_FEE_CHANGED) - .addProperties({ - ...analyticsParams, - chain_id: getDecimalChainId(chainId), - function_type: view, - gas_mode: 'Basic', - }) - .build(), - ); - - const newGasPriceObject = { - suggestedGasPrice: gasObjectLegacy?.suggestedGasPrice, - legacyGasLimit: gasObjectLegacy?.legacyGasLimit, - }; - onSave(gasTransaction, newGasPriceObject); - }, [ - onSave, - gasTransaction, - gasObjectLegacy, - analyticsParams, - chainId, - view, - trackEvent, - createEventBuilder, - ]); - - const changeGas = useCallback((gas) => { - updateGasObjectLegacy({ - legacyGasLimit: gas.suggestedGasLimit, - suggestedGasPrice: gas.suggestedGasPrice, - }); - }, []); - - const changedGasPrice = useCallback( - (value) => { - let newGas; - - const lowerValue = new BigNumber( - gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY - ? gasFeeEstimate?.low - : gasFeeEstimate?.gasPrice, - ); - const higherValue = new BigNumber( - gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY - ? gasFeeEstimate?.high - : gasFeeEstimate?.gasPrice, - ).multipliedBy(new BigNumber(1.5)); - - const valueBN = new BigNumber(value); - - if (!lowerValue.isNaN() && valueBN.lt(lowerValue)) { - setGasPriceError(strings('edit_gas_fee_eip1559.gas_price_low')); - } else if (!higherValue.isNaN() && valueBN.gt(higherValue)) { - setGasPriceError(strings('edit_gas_fee_eip1559.gas_price_high')); - } else { - setGasPriceError(''); - } - - if (typeof gasTransaction === 'object') { - newGas = { ...gasTransaction, suggestedGasPrice: value }; - } else { - newGas = { suggestedGasPrice: value }; - } - - changeGas(newGas); - }, - [changeGas, gasEstimateType, gasTransaction, gasFeeEstimate], - ); - - const changedGasLimit = useCallback( - (value) => { - const newGas = - typeof gasTransaction === 'object' - ? { ...gasTransaction, suggestedGasLimit: value } - : { suggestedGasLimit: value }; - - changeGas(newGas); - }, - [changeGas, gasTransaction], - ); - - const showTransactionWarning = useMemo(() => { - if (!warning) return null; - if (typeof warning === 'string') - return ( - ( - - )} - > - {() => ( - - - {warning} - - - )} - - ); - - return warning; - }, [warning, styles, colors]); - - const showTransactionError = useMemo(() => { - if (!error) return null; - if (typeof error === 'string') - return ( - ( - - )} - > - {() => ( - - - {error} - - - )} - - ); - - return error; - }, [error, styles, colors]); - - const { - suggestedGasLimit, - suggestedGasPrice, - transactionFee, - transactionFeeFiat, - } = gasTransaction; - - const isMainnet = isMainnetByChainId(chainId); - const nativeCurrencySelected = primaryCurrency === 'ETH' || !isMainnet; - let gasFeePrimary, gasFeeSecondary; - if (nativeCurrencySelected) { - gasFeePrimary = transactionFee; - gasFeeSecondary = transactionFeeFiat; - } else { - gasFeePrimary = transactionFeeFiat; - gasFeeSecondary = transactionFee; - } - - const valueToWatch = transactionFee; - - const handleInfoModalPress = (text) => { - setShowRangeInfoModal(true); - setInfoText(text); - }; - - return ( - - - - - - - - - - - {strings('transaction.edit_priority')} - - - - - {showTransactionWarning} - {showTransactionError} - - {!showEditUI ? ( - - - - ~ {gasFeePrimary} - - - - {gasFeeSecondary} - - - setShowEditUI(true)} - > - {strings('edit_gas_fee_eip1559.edit_suggested_gas_fee')} - - - ) : ( - - - - - ~ {gasFeePrimary} - - - - {gasFeeSecondary} - - - - - - {strings('edit_gas_fee_eip1559.gas_limit')}{' '} - - - handleInfoModalPress('gas_limit')} - > - - - - } - value={suggestedGasLimit} - onChangeValue={changedGasLimit} - min={GAS_LIMIT_MIN} - name={strings('edit_gas_fee_eip1559.gas_limit')} - increment={GAS_LIMIT_INCREMENT} - /> - - - - - {strings('edit_gas_fee_eip1559.gas_price')}{' '} - - (GWEI) - - handleInfoModalPress('gas_price')} - > - - - - } - value={suggestedGasPrice} - name={strings('edit_gas_fee_eip1559.gas_price')} - increment={GAS_PRICE_INCREMENT} - min={GAS_PRICE_MIN} - inputInsideLabel={ - transactionFeeFiat && `≈ ${transactionFeeFiat}` - } - onChangeValue={changedGasPrice} - error={gasPriceError} - /> - - - - - {strings('edit_gas_fee_eip1559.save')} - - - - )} - setShowRangeInfoModal(false)} - body={ - - - {infoText === 'gas_limit' && - strings( - 'edit_gas_fee_eip1559.learn_more_gas_limit_legacy', - )} - {infoText === 'gas_price' && - strings('edit_gas_fee_eip1559.learn_more_gas_price')} - - - } - /> - - - - - ); -}; - -export default EditGasFeeLegacy; diff --git a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/styles.ts b/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/styles.ts deleted file mode 100644 index c4029ac78c7..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/styles.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { StyleSheet } from 'react-native'; - -import Device from '../../../../../../util/device'; - -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const createStyles = (colors: any) => - StyleSheet.create({ - root: { - backgroundColor: colors.background.default, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - minHeight: 200, - maxHeight: '95%', - paddingTop: 24, - paddingBottom: Device.isIphoneX() ? 32 : 24, - }, - wrapper: { - paddingHorizontal: 24, - }, - customGasHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - width: '100%', - paddingBottom: 20, - }, - headerContainer: { - alignItems: 'center', - marginBottom: 22, - }, - headerText: { - fontSize: 48, - }, - headerTitleSide: { - flex: 1, - }, - labelTextContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - hitSlop: { - top: 10, - left: 10, - bottom: 10, - right: 10, - }, - labelInfo: { - color: colors.text.muted, - }, - advancedOptionsContainer: { - marginTop: 25, - marginBottom: 30, - }, - advancedOptionsInputsContainer: { - marginTop: 14, - }, - rangeInputContainer: { - marginBottom: 20, - }, - advancedOptionsButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - advancedOptionsIcon: { - paddingTop: 1, - marginLeft: 5, - }, - textContainer: { - lineHeight: 20, - textAlign: 'center', - }, - text: { - lineHeight: 20, - marginHorizontal: 4, - }, - dappEditGasContainer: { - marginVertical: 20, - }, - }); - -export default createStyles; diff --git a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/types.ts b/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/types.ts deleted file mode 100644 index 6090db68ecf..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/types.ts +++ /dev/null @@ -1,68 +0,0 @@ -export interface EditGasFeeLegacyUpdateProps { - /** - * Function called when user cancels - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onCancel: any; - /** - * Function called when user saves the new gas - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onSave: (gasTxn: any, newGasObject: any) => void; - /** - * Error message to show - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - error: any; - /** - * Warning message to show - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - warning?: any; - /** - * Extend options object. Object has option keys and properties will be spread - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - extendOptions?: any; - /** - * Function to call when update animation starts - */ - onUpdatingValuesStart: () => void; - /** - * Function to call when update animation ends - */ - onUpdatingValuesEnd: () => void; - /** - * If the values should animate upon update or not - */ - animateOnChange: boolean | undefined; - /** - * Boolean to determine if the animation is happening - */ - isAnimating: boolean; - /** - * Extra analytics params to be send with the gas analytics - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - analyticsParams: any; - view: string; - onlyGas?: boolean; - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - selectedGasObject: any; - hasDappSuggestedGas?: boolean; - chainId: string; -} - -export interface EditLegacyGasTransaction { - suggestedGasLimit: string; - suggestedGasPrice: string; - transactionFee: string; - transactionFeeFiat: string; -} diff --git a/app/components/Views/confirmations/legacy/components/UpdateEIP1559Tx/index.jsx b/app/components/Views/confirmations/legacy/components/UpdateEIP1559Tx/index.jsx deleted file mode 100644 index d478dc21324..00000000000 --- a/app/components/Views/confirmations/legacy/components/UpdateEIP1559Tx/index.jsx +++ /dev/null @@ -1,269 +0,0 @@ -import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; -import { CANCEL_RATE, SPEED_UP_RATE } from '@metamask/transaction-controller'; -import { isHexString } from '@metamask/utils'; -import BigNumber from 'bignumber.js'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { connect } from 'react-redux'; -import { strings } from '../../../../../../../locales/i18n'; -import AppConstants from '../../../../../../core/AppConstants'; -import { - startGasPolling, - stopGasPolling, -} from '../../../../../../core/GasPolling/GasPolling'; -import { selectAccounts } from '../../../../../../selectors/accountTrackerController'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../selectors/accountsController'; -import { selectGasFeeEstimates } from '../../../../../../selectors/confirmTransaction'; -import { selectGasFeeControllerEstimateType } from '../../../../../../selectors/gasFeeController'; -import { selectNativeCurrencyByChainId } from '../../../../../../selectors/networkController'; -import { getDecimalChainId } from '../../../../../../util/networks'; -import { - addHexPrefix, - fromWei, - hexToBN, - renderFromWei, -} from '../../../../../../util/number'; -import { getTicker } from '../../../../../../util/transactions'; -import EditGasFee1559Update from '../EditGasFee1559Update'; - -const UpdateEIP1559Tx = ({ - gas, - accounts, - selectedAddress, - ticker, - existingGas, - gasFeeEstimates, - gasEstimateType, - primaryCurrency, - isCancel, - chainId, - onCancel, - onSave, -}) => { - const [animateOnGasChange, setAnimateOnGasChange] = useState(false); - const [gasSelected, setGasSelected] = useState( - AppConstants.GAS_OPTIONS.MEDIUM, - ); - const stopUpdateGas = useRef(false); - /** - * Flag to only display high gas selection option if the legacy is higher then low/med - */ - const onlyDisplayHigh = useRef(false); - /** - * Options - */ - const updateTx1559Options = useRef(); - const pollToken = useRef(); - const firstTime = useRef(true); - - const suggestedGasLimit = fromWei(gas, 'wei'); - - useEffect(() => { - if (animateOnGasChange) setAnimateOnGasChange(false); - }, [animateOnGasChange]); - - useEffect(() => { - const startGasEstimatePolling = async () => { - pollToken.current = await startGasPolling(pollToken.current); - }; - startGasEstimatePolling(); - - return () => { - stopGasPolling(); - }; - }, []); - - const isMaxFeePerGasMoreThanLegacy = useCallback( - (maxFeePerGas) => { - const newDecMaxFeePerGas = new BigNumber(existingGas.maxFeePerGas).times( - new BigNumber(isCancel ? CANCEL_RATE : SPEED_UP_RATE), - ); - return { - result: maxFeePerGas.gte(newDecMaxFeePerGas), - value: newDecMaxFeePerGas, - }; - }, - [existingGas.maxFeePerGas, isCancel], - ); - - const isMaxPriorityFeePerGasMoreThanLegacy = useCallback( - (maxPriorityFeePerGas) => { - const newDecMaxPriorityFeePerGas = new BigNumber( - existingGas.maxPriorityFeePerGas, - ).times(new BigNumber(isCancel ? CANCEL_RATE : SPEED_UP_RATE)); - return { - result: maxPriorityFeePerGas.gte(newDecMaxPriorityFeePerGas), - value: newDecMaxPriorityFeePerGas, - }; - }, - [existingGas.maxPriorityFeePerGas, isCancel], - ); - - const validateAmount = useCallback( - (updateTx) => { - let error; - const totalMaxHexPrefixed = addHexPrefix(updateTx.totalMaxHex); - - if (!isHexString(totalMaxHexPrefixed)) { - return strings('transaction.invalid_amount'); - } - const updateTxCost = hexToBN(totalMaxHexPrefixed); - const accountBalance = hexToBN(accounts[selectedAddress].balance); - const isMaxFeePerGasMoreThanLegacyResult = isMaxFeePerGasMoreThanLegacy( - new BigNumber(updateTx.suggestedMaxFeePerGas), - ); - const isMaxPriorityFeePerGasMoreThanLegacyResult = - isMaxPriorityFeePerGasMoreThanLegacy( - new BigNumber(updateTx.suggestedMaxPriorityFeePerGas), - ); - if (accountBalance.lt(updateTxCost)) { - const amount = renderFromWei(updateTxCost.sub(accountBalance)); - const tokenSymbol = getTicker(ticker); - error = strings('transaction.insufficient_amount', { - amount, - tokenSymbol, - }); - } else if (!isMaxFeePerGasMoreThanLegacyResult.result) { - error = isCancel - ? strings('edit_gas_fee_eip1559.max_fee_cancel_low', { - cancel_value: isMaxFeePerGasMoreThanLegacyResult.value, - }) - : strings('edit_gas_fee_eip1559.max_fee_speed_up_low', { - speed_up_floor_value: isMaxFeePerGasMoreThanLegacyResult.value, - }); - } else if (!isMaxPriorityFeePerGasMoreThanLegacyResult.result) { - error = isCancel - ? strings('edit_gas_fee_eip1559.max_priority_fee_cancel_low', { - cancel_value: isMaxPriorityFeePerGasMoreThanLegacyResult.value, - }) - : strings('edit_gas_fee_eip1559.max_priority_fee_speed_up_low', { - speed_up_floor_value: - isMaxPriorityFeePerGasMoreThanLegacyResult.value, - }); - } - - return error; - }, - [ - accounts, - selectedAddress, - isMaxFeePerGasMoreThanLegacy, - isMaxPriorityFeePerGasMoreThanLegacy, - ticker, - isCancel, - ], - ); - - useEffect(() => { - if (stopUpdateGas.current) return; - if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { - if (firstTime.current) { - const newDecMaxFeePerGas = new BigNumber( - existingGas.maxFeePerGas, - ).times(new BigNumber(isCancel ? CANCEL_RATE : SPEED_UP_RATE)); - const newDecMaxPriorityFeePerGas = new BigNumber( - existingGas.maxPriorityFeePerGas, - ).times(new BigNumber(isCancel ? CANCEL_RATE : SPEED_UP_RATE)); - - //Check to see if default SPEED_UP_RATE/CANCEL_RATE is greater than current market medium value - if ( - !isMaxFeePerGasMoreThanLegacy( - new BigNumber(gasFeeEstimates.medium.suggestedMaxPriorityFeePerGas), - ).result || - !isMaxPriorityFeePerGasMoreThanLegacy( - new BigNumber(gasFeeEstimates.medium.suggestedMaxFeePerGas), - ).result - ) { - updateTx1559Options.current = { - maxPriortyFeeThreshold: newDecMaxPriorityFeePerGas, - maxFeeThreshold: newDecMaxFeePerGas, - showAdvanced: true, - isCancel, - }; - - onlyDisplayHigh.current = true; - //Disable polling - stopUpdateGas.current = true; - setGasSelected(''); - } else { - updateTx1559Options.current = { - maxPriortyFeeThreshold: - gasFeeEstimates.medium.suggestedMaxPriorityFeePerGas, - maxFeeThreshold: gasFeeEstimates.medium.suggestedMaxFeePerGas, - showAdvanced: false, - isCancel, - }; - setAnimateOnGasChange(true); - } - } - - firstTime.current = false; - } - }, [ - existingGas.maxFeePerGas, - existingGas.maxPriorityFeePerGas, - gasEstimateType, - gasFeeEstimates, - gasSelected, - isCancel, - gas, - suggestedGasLimit, - isMaxFeePerGasMoreThanLegacy, - isMaxPriorityFeePerGasMoreThanLegacy, - ]); - - const update1559TempGasValue = (selected) => { - stopUpdateGas.current = !selected; - setGasSelected(selected); - }; - - const onSaveTxnWithError = (gasTxn) => { - gasTxn.error = validateAmount(gasTxn); - onSave(gasTxn); - }; - - const getGasAnalyticsParams = () => ({ - chain_id: getDecimalChainId(chainId), - gas_estimate_type: gasEstimateType, - gas_mode: gasSelected ? 'Basic' : 'Advanced', - speed_set: gasSelected || undefined, - view: isCancel ? AppConstants.CANCEL_RATE : AppConstants.SPEED_UP_RATE, - }); - - const selectedGasObject = { - suggestedMaxFeePerGas: existingGas.maxFeePerGas, - suggestedMaxPriorityFeePerGas: existingGas.maxPriorityFeePerGas, - suggestedGasLimit, - }; - return ( - - ); -}; - -const mapStateToProps = (state, ownProps) => ({ - accounts: selectAccounts(state), - selectedAddress: selectSelectedInternalAccountFormattedAddress(state), - ticker: selectNativeCurrencyByChainId(state, ownProps.chainId), - gasFeeEstimates: selectGasFeeEstimates(state), - gasEstimateType: selectGasFeeControllerEstimateType(state), - primaryCurrency: state.settings.primaryCurrency, -}); - -export default connect(mapStateToProps)(UpdateEIP1559Tx); diff --git a/app/components/Views/confirmations/legacy/components/UpdateEIP1559Tx/types.ts b/app/components/Views/confirmations/legacy/components/UpdateEIP1559Tx/types.ts deleted file mode 100644 index 5f86a5f6193..00000000000 --- a/app/components/Views/confirmations/legacy/components/UpdateEIP1559Tx/types.ts +++ /dev/null @@ -1,81 +0,0 @@ -import BigNumber from 'bignumber.js'; - -export interface UpdateEIP1559Props { - /** - * Map of accounts to information objects including balances - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - accounts: any; - /** - * Chain Id - */ - chainId: string; - /** - * ETH or fiat, depending on user setting - */ - primaryCurrency: string; - /** - * Gas fee estimates returned by the gas fee controller - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - gasFeeEstimates: any; - /** - * Estimate type returned by the gas fee controller, can be market-fee, legacy or eth_gasPrice - */ - gasEstimateType: string; - /** - * A string that represents the selected address - */ - selectedAddress: string; - /** - * A bool indicates whether tx is speed up/cancel - */ - isCancel: boolean; - /** - * Current provider ticker - */ - ticker: string; - /** - * The max fee and max priorty fee selected tx - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - existingGas: any; - /** - * Gas object used to get suggestedGasLimit - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - gas: any; - /** - * Function that cancels the tx update - */ - onCancel: () => void; - /** - * Function that performs the rest of the tx update - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onSave: (tx: any) => void; -} - -export interface UpdateTx1559Options { - /** - * The legacy calculated max priorty fee used in subcomponent for threshold warning messages - */ - maxPriortyFeeThreshold: BigNumber; - /** - * The legacy calculated max fee used in subcomponent for threshold warning messages - */ - maxFeeThreshold: BigNumber; - /** - * Boolean to indicate to sumcomponent if the view should display only advanced settings - */ - showAdvanced: boolean; - /** - * Boolean to indicate if this is a cancel tx update - */ - isCancel: boolean; -} diff --git a/app/components/Views/confirmations/utils/multichain-snaps.test.ts b/app/components/Views/confirmations/utils/multichain-snaps.test.ts index 782d0e67b66..4567f43f529 100644 --- a/app/components/Views/confirmations/utils/multichain-snaps.test.ts +++ b/app/components/Views/confirmations/utils/multichain-snaps.test.ts @@ -1,6 +1,6 @@ import { InternalAccount } from '@metamask/keyring-internal-api'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as SnapUtils from '../../../../core/Snaps/utils'; import { sendMultichainTransactionForReview, diff --git a/app/components/Views/confirmations/utils/send-address-validations.test.ts b/app/components/Views/confirmations/utils/send-address-validations.test.ts index 09f5e60e463..48d3c05ae5d 100644 --- a/app/components/Views/confirmations/utils/send-address-validations.test.ts +++ b/app/components/Views/confirmations/utils/send-address-validations.test.ts @@ -1,7 +1,8 @@ -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as ConfusablesUtils from '../../../../util/confusables'; import { getConfusableCharacterInfo, + validateBitcoinAddress, validateHexAddress, validateSolanaAddress, validateTronAddress, @@ -63,6 +64,12 @@ describe('validateHexAddress', () => { "You are sending tokens to the token's contract address. This may result in the loss of these tokens.", }); }); + it('returns empty object when chainId is undefined', async () => { + expect( + await validateHexAddress('0xdB055877e6c13b6A6B25aBcAA29B393777dD0a73'), + ).toStrictEqual({}); + }); + it('returns warning if address is contract address', async () => { mockMemoizedGetTokenStandardAndDetails.mockResolvedValue({ standard: 'ERC20', @@ -73,6 +80,7 @@ describe('validateHexAddress', () => { '0x1', ), ).toStrictEqual({ + allowAcknowledge: true, error: 'This address is a token contract address. If you send tokens to this address, you will lose them.', }); @@ -122,6 +130,20 @@ describe('validateTronAddress', () => { }); }); +describe('validateBitcoinAddress', () => { + it('returns error if address is not a valid Bitcoin mainnet address', () => { + expect(validateBitcoinAddress('not-a-bitcoin-address')).toStrictEqual({ + error: 'Invalid address', + }); + }); + + it('returns empty object for valid Bitcoin mainnet address', () => { + expect( + validateBitcoinAddress('bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'), + ).toStrictEqual({}); + }); +}); + describe('getConfusableCharacterInfo', () => { it('returns empty object if there is no error', async () => { expect(getConfusableCharacterInfo('test.eth')).toStrictEqual({}); diff --git a/app/components/Views/confirmations/utils/send-address-validations.ts b/app/components/Views/confirmations/utils/send-address-validations.ts index 13b9cc1e947..d3cb04483c8 100644 --- a/app/components/Views/confirmations/utils/send-address-validations.ts +++ b/app/components/Views/confirmations/utils/send-address-validations.ts @@ -38,6 +38,7 @@ export const validateHexAddress = async ( ): Promise<{ error?: string; warning?: string; + allowAcknowledge?: boolean; }> => { if (LOWER_CASED_BURN_ADDRESSES.includes(toAddress?.toLowerCase())) { return { @@ -68,6 +69,7 @@ export const validateHexAddress = async ( if (token?.standard) { return { error: strings('send.token_contract_warning'), + allowAcknowledge: true, }; } } catch { diff --git a/app/components/Views/confirmations/utils/send.test.ts b/app/components/Views/confirmations/utils/send.test.ts index 713e3b3973c..d165e16b5c5 100644 --- a/app/components/Views/confirmations/utils/send.test.ts +++ b/app/components/Views/confirmations/utils/send.test.ts @@ -5,9 +5,9 @@ import { } from '@metamask/transaction-controller'; import ppomUtil from '../../../../lib/ppom/ppom-util'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as TransactionUtils from '../../../../util/transaction-controller'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as EngineNetworkUtils from '../../../../util/networks/engineNetworkUtils'; import { AssetType, TokenStandard } from '../types/token'; import { InitSendLocation } from '../constants/send'; diff --git a/app/components/Views/transactions/SmartTransactionStatus/LoopingScrollAnimation.tsx b/app/components/Views/transactions/SmartTransactionStatus/LoopingScrollAnimation.tsx index d75346070b5..fd929a93070 100644 --- a/app/components/Views/transactions/SmartTransactionStatus/LoopingScrollAnimation.tsx +++ b/app/components/Views/transactions/SmartTransactionStatus/LoopingScrollAnimation.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { StyleSheet, View } from 'react-native'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as Animatable from 'react-native-animatable'; const styles = StyleSheet.create({ diff --git a/app/components/hooks/useAnalytics/useAnalytics.test.tsx b/app/components/hooks/useAnalytics/useAnalytics.test.tsx index 39d7c8b9446..2392d04b11a 100644 --- a/app/components/hooks/useAnalytics/useAnalytics.test.tsx +++ b/app/components/hooks/useAnalytics/useAnalytics.test.tsx @@ -4,7 +4,7 @@ import { DataDeleteStatus, type IDeleteRegulationResponse, type IDeleteRegulationStatus, -} from '../../../core/Analytics/MetaMetrics.types'; +} from '../../../util/analytics/analyticsDataDeletion.types'; import { AnalyticsEventBuilder, type AnalyticsTrackingEvent, diff --git a/app/components/hooks/useAnalytics/useAnalytics.types.ts b/app/components/hooks/useAnalytics/useAnalytics.types.ts index bb388e7f23c..9290757bf47 100644 --- a/app/components/hooks/useAnalytics/useAnalytics.types.ts +++ b/app/components/hooks/useAnalytics/useAnalytics.types.ts @@ -1,10 +1,12 @@ import { - DataDeleteDate, - IDeleteRegulationResponse, - IDeleteRegulationStatus, - type IMetaMetricsEvent, - type ITrackingEvent, -} from '../../../core/Analytics/MetaMetrics.types'; + type DataDeleteDate, + type IDeleteRegulationResponse, + type IDeleteRegulationStatus, +} from '../../../util/analytics/analyticsDataDeletion.types'; +import type { + IMetaMetricsEvent, + ITrackingEvent, +} from '../../../util/analytics/analytics.types'; import { AnalyticsEventBuilder, type AnalyticsTrackingEvent, diff --git a/app/components/hooks/useStyles.ts b/app/components/hooks/useStyles.ts index 60ec75c6c1a..6c0a1857fa3 100644 --- a/app/components/hooks/useStyles.ts +++ b/app/components/hooks/useStyles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { useMemo } from 'react'; import { useAppThemeFromContext } from '../../util/theme'; import { Theme } from '../../util/theme/models'; diff --git a/app/components/hooks/useWalletCompliance.test.ts b/app/components/hooks/useWalletCompliance.test.ts new file mode 100644 index 00000000000..5141fe6772f --- /dev/null +++ b/app/components/hooks/useWalletCompliance.test.ts @@ -0,0 +1,205 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { + useWalletCompliance, + useComplianceGate, + useAccountGroupCompliance, +} from './useWalletCompliance'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +const mockCheckWalletCompliance = jest.fn(); +const mockCheckWalletsCompliance = jest.fn(); + +jest.mock('../../core/Engine', () => ({ + context: { + ComplianceController: { + checkWalletCompliance: (...args: unknown[]) => + mockCheckWalletCompliance(...args), + checkWalletsCompliance: (...args: unknown[]) => + mockCheckWalletsCompliance(...args), + }, + }, +})); + +jest.mock('../../selectors/multichainAccounts/accountTreeController', () => ({ + selectSelectedAccountGroupWithInternalAccountsAddresses: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +const BLOCKED_ADDRESS = '0xBLOCKED'; +const SAFE_ADDRESS = '0xSAFE'; +const SAFE_ADDRESS_2 = '0xSAFE2'; + +describe('useWalletCompliance', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns isBlocked=true for a blocked single address', () => { + mockUseSelector.mockReturnValue(true); + + const { result } = renderHook(() => useWalletCompliance(BLOCKED_ADDRESS)); + + expect(result.current.isBlocked).toBe(true); + }); + + it('returns isBlocked=false for a safe single address', () => { + mockUseSelector.mockReturnValue(false); + + const { result } = renderHook(() => useWalletCompliance(SAFE_ADDRESS)); + + expect(result.current.isBlocked).toBe(false); + }); + + it('calls checkWalletCompliance for a single address', async () => { + mockUseSelector.mockReturnValue(false); + mockCheckWalletCompliance.mockResolvedValue({ + address: SAFE_ADDRESS, + blocked: false, + checkedAt: '2025-01-01T00:00:00Z', + }); + + const { result } = renderHook(() => useWalletCompliance(SAFE_ADDRESS)); + + await result.current.checkCompliance(); + expect(mockCheckWalletCompliance).toHaveBeenCalledWith(SAFE_ADDRESS); + expect(mockCheckWalletsCompliance).not.toHaveBeenCalled(); + }); + + it('returns isBlocked=true when any address in an array is blocked', () => { + // 1st call: selectIsWalletBlocked (single, for addresses[0]) -> false + // 2nd call: selectAreAnyWalletsBlocked (batch) -> true + mockUseSelector.mockReturnValueOnce(false).mockReturnValueOnce(true); + + const { result } = renderHook(() => + useWalletCompliance([SAFE_ADDRESS, BLOCKED_ADDRESS]), + ); + + expect(result.current.isBlocked).toBe(true); + }); + + it('returns isBlocked=false when no address in an array is blocked', () => { + mockUseSelector.mockReturnValueOnce(false).mockReturnValueOnce(false); + + const { result } = renderHook(() => + useWalletCompliance([SAFE_ADDRESS, SAFE_ADDRESS_2]), + ); + + expect(result.current.isBlocked).toBe(false); + }); + + it('calls checkWalletsCompliance for an array of addresses', async () => { + mockUseSelector.mockReturnValue(false); + mockCheckWalletsCompliance.mockResolvedValue([ + { + address: SAFE_ADDRESS, + blocked: false, + checkedAt: '2025-01-01T00:00:00Z', + }, + { + address: SAFE_ADDRESS_2, + blocked: false, + checkedAt: '2025-01-01T00:00:00Z', + }, + ]); + + const { result } = renderHook(() => + useWalletCompliance([SAFE_ADDRESS, SAFE_ADDRESS_2]), + ); + + await result.current.checkCompliance(); + expect(mockCheckWalletsCompliance).toHaveBeenCalledWith([ + SAFE_ADDRESS, + SAFE_ADDRESS_2, + ]); + expect(mockCheckWalletCompliance).not.toHaveBeenCalled(); + }); +}); + +describe('useComplianceGate', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns isBlocked=false when compliance is disabled even if address is blocked', () => { + // useComplianceGate calls useSelector: + // 1st: selectComplianceEnabled -> false + // 2nd: selectIsWalletBlocked -> true + // 3rd: selectAreAnyWalletsBlocked -> false (empty array for single) + mockUseSelector + .mockReturnValueOnce(false) // selectComplianceEnabled + .mockReturnValueOnce(true) // selectIsWalletBlocked + .mockReturnValueOnce(false); // selectAreAnyWalletsBlocked (empty) + + const { result } = renderHook(() => useComplianceGate(BLOCKED_ADDRESS)); + + expect(result.current.isComplianceEnabled).toBe(false); + expect(result.current.isBlocked).toBe(false); + }); + + it('returns isBlocked=true when compliance is enabled and address is blocked', () => { + mockUseSelector + .mockReturnValueOnce(true) // selectComplianceEnabled + .mockReturnValueOnce(true) // selectIsWalletBlocked + .mockReturnValueOnce(false); // selectAreAnyWalletsBlocked (empty) + + const { result } = renderHook(() => useComplianceGate(BLOCKED_ADDRESS)); + + expect(result.current.isComplianceEnabled).toBe(true); + expect(result.current.isBlocked).toBe(true); + }); + + it('works with array of addresses', () => { + mockUseSelector + .mockReturnValueOnce(true) // selectComplianceEnabled + .mockReturnValueOnce(false) // selectIsWalletBlocked (single for [0]) + .mockReturnValueOnce(true); // selectAreAnyWalletsBlocked (batch) + + const { result } = renderHook(() => + useComplianceGate([SAFE_ADDRESS, BLOCKED_ADDRESS]), + ); + + expect(result.current.isComplianceEnabled).toBe(true); + expect(result.current.isBlocked).toBe(true); + }); +}); + +describe('useAccountGroupCompliance', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('checks all addresses from the selected account group', () => { + // useAccountGroupCompliance calls useSelector: + // 1st: selectSelectedAccountGroupWithInternalAccountsAddresses + // 2nd: selectComplianceEnabled + // 3rd: selectIsWalletBlocked (single for [0]) + // 4th: selectAreAnyWalletsBlocked (batch) + mockUseSelector + .mockReturnValueOnce(['0xEVM', 'bc1qBTC', 'So1SOL']) // group addresses + .mockReturnValueOnce(true) // selectComplianceEnabled + .mockReturnValueOnce(false) // selectIsWalletBlocked + .mockReturnValueOnce(true); // selectAreAnyWalletsBlocked + + const { result } = renderHook(() => useAccountGroupCompliance()); + + expect(result.current.isComplianceEnabled).toBe(true); + expect(result.current.isBlocked).toBe(true); + }); + + it('returns isBlocked=false when account group has no addresses', () => { + mockUseSelector + .mockReturnValueOnce([]) // empty group + .mockReturnValueOnce(true) // selectComplianceEnabled + .mockReturnValueOnce(false) // selectIsWalletBlocked + .mockReturnValueOnce(false); // selectAreAnyWalletsBlocked + + const { result } = renderHook(() => useAccountGroupCompliance()); + + expect(result.current.isBlocked).toBe(false); + }); +}); diff --git a/app/components/hooks/useWalletCompliance.ts b/app/components/hooks/useWalletCompliance.ts new file mode 100644 index 00000000000..ba6082ae6c8 --- /dev/null +++ b/app/components/hooks/useWalletCompliance.ts @@ -0,0 +1,129 @@ +import { useSelector } from 'react-redux'; +import { useCallback, useMemo } from 'react'; +import Engine from '../../core/Engine'; +import { + selectIsWalletBlocked, + selectAreAnyWalletsBlocked, +} from '../../selectors/complianceController'; +import { selectComplianceEnabled } from '../../selectors/featureFlagController/compliance'; +import { selectSelectedAccountGroupWithInternalAccountsAddresses } from '../../selectors/multichainAccounts/accountTreeController'; + +type AddressInput = string | string[]; + +function normalizeAddresses(input: AddressInput): string[] { + return Array.isArray(input) ? input : [input]; +} + +/** + * Hook that provides compliance state and actions for one or more wallet addresses. + * + * Reads from the cached blocklist (synchronous) and exposes an imperative + * `checkCompliance` function for on-demand API checks. + * + * When given an array of addresses (e.g. from a multichain account group), + * `isBlocked` returns `true` if ANY address in the array is blocked. + * + * @param address - A single wallet address or array of addresses to check. + * @returns Object with `isBlocked` boolean and `checkCompliance` async function. + * + * @example + * ```tsx + * // Single address + * const { isBlocked } = useWalletCompliance(recipientAddress); + * + * // Multiple addresses (multichain account group) + * const { isBlocked } = useWalletCompliance(['0xEVM...', 'bc1q...', 'So1...']); + * ``` + */ +export function useWalletCompliance(address: AddressInput) { + const addressKey = Array.isArray(address) ? address.join(',') : address; + // eslint-disable-next-line react-hooks/exhaustive-deps -- addressKey is a stable scalar derived from address + const addresses = useMemo(() => normalizeAddresses(address), [addressKey]); + const isSingle = addresses.length === 1; + + const singleBlocked = useSelector(selectIsWalletBlocked(addresses[0] ?? '')); + const batchBlocked = useSelector( + selectAreAnyWalletsBlocked(isSingle ? [] : addresses), + ); + + const isBlocked = isSingle ? singleBlocked : batchBlocked; + + const checkCompliance = useCallback(async () => { + if (isSingle) { + return Engine.context.ComplianceController.checkWalletCompliance( + addresses[0], + ); + } + return Engine.context.ComplianceController.checkWalletsCompliance( + addresses, + ); + }, [addresses, isSingle]); + + return useMemo( + () => ({ isBlocked, checkCompliance }), + [isBlocked, checkCompliance], + ); +} + +/** + * Convenience hook that combines the compliance feature flag check with + * the wallet blocked status. Use this as a guard in transaction flows. + * + * When compliance is disabled via feature flag, `isBlocked` always returns + * `false` regardless of the cached blocklist. + * + * @param address - A single wallet address or array of addresses to check. + * @returns Object with `isComplianceEnabled`, `isBlocked`, and `checkCompliance`. + * + * @example + * ```tsx + * const { isComplianceEnabled, isBlocked } = useComplianceGate(recipientAddress); + * + * if (isComplianceEnabled && isBlocked) { + * // Block the transaction + * } + * ``` + */ +export function useComplianceGate(address: AddressInput) { + const isComplianceEnabled = useSelector(selectComplianceEnabled); + const { isBlocked: rawIsBlocked, checkCompliance } = + useWalletCompliance(address); + + const isBlocked = isComplianceEnabled && rawIsBlocked; + + return useMemo( + () => ({ isComplianceEnabled, isBlocked, checkCompliance }), + [isComplianceEnabled, isBlocked, checkCompliance], + ); +} + +/** + * Zero-config hook that checks compliance for all addresses in the + * currently selected account group. In multichain wallets, one group + * can contain EVM, Solana, Bitcoin, and other chain-specific addresses. + * + * Returns `isBlocked: true` if ANY address in the group is blocked. + * + * @returns Object with `isComplianceEnabled`, `isBlocked`, and `checkCompliance`. + * + * @example + * ```tsx + * const { isBlocked } = useAccountGroupCompliance(); + * + * if (isBlocked) { + * // Current account group contains a sanctioned address + * } + * ``` + */ +export function useAccountGroupCompliance() { + const addresses = useSelector( + selectSelectedAccountGroupWithInternalAccountsAddresses, + ); + const filteredAddresses = useMemo( + () => addresses.filter((addr): addr is string => addr != null), + [addresses], + ); + return useComplianceGate( + filteredAddresses.length > 0 ? filteredAddresses : [], + ); +} diff --git a/app/constants/deeplinks.ts b/app/constants/deeplinks.ts index 78feb23b9f6..72e923949e1 100644 --- a/app/constants/deeplinks.ts +++ b/app/constants/deeplinks.ts @@ -31,6 +31,7 @@ export enum ACTIONS { BUY_CRYPTO = 'buy-crypto', SELL = 'sell', SELL_CRYPTO = 'sell-crypto', + /** @deprecated Cash deposit deeplink (`metamask://deposit`, `/deposit`) is no longer handled. */ DEPOSIT = 'deposit', HOME = 'home', ASSET = 'asset', diff --git a/app/constants/featureFlags.ts b/app/constants/featureFlags.ts index 7cd64426b8e..67da83992fe 100644 --- a/app/constants/featureFlags.ts +++ b/app/constants/featureFlags.ts @@ -14,6 +14,9 @@ export enum FeatureFlagNames { assetsDefiPositionsEnabled = 'assetsDefiPositionsEnabled', tokenDetailsV2Buttons = 'tokenDetailsV2Buttons', tokenDetailsV2ButtonLayout = 'tokenDetailsV2ButtonLayout', + complianceEnabled = 'complianceEnabled', + legacyIosGoogleConfigEnabled = 'legacyIosGoogleConfigEnabled', + tronClaimUnstakedTrxButtonEnabled = 'tronClaimUnstakedTrxButtonEnabled', } export const DEFAULT_FEATURE_FLAG_VALUES: Partial< @@ -22,4 +25,5 @@ export const DEFAULT_FEATURE_FLAG_VALUES: Partial< [FeatureFlagNames.assetsDefiPositionsEnabled]: true, [FeatureFlagNames.tokenDetailsV2Buttons]: false, [FeatureFlagNames.tokenDetailsV2ButtonLayout]: false, + [FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: false, }; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index f8c9313f56e..114181a6f3b 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -99,6 +99,10 @@ const Routes = { REFERRAL_REWARDS_VIEW: 'ReferralRewardsView', REWARDS_SETTINGS_VIEW: 'RewardsSettingsView', REWARDS_DASHBOARD: 'RewardsDashboard', + CAMPAIGNS_VIEW: 'CampaignsView', + PREVIOUS_SEASON_VIEW: 'PreviousSeasonView', + CAMPAIGN_DETAILS: 'CampaignDetails', + CAMPAIGN_MECHANICS: 'CampaignMechanics', TRENDING_VIEW: 'TrendingView', TRENDING_FEED: 'TrendingFeed', SITES_FULL_VIEW: 'SitesFullView', @@ -123,6 +127,7 @@ const Routes = { TRADE_WALLET_ACTIONS: 'TradeWalletActions', FUND_ACTION_MENU: 'FundActionMenu', MORE_TOKEN_ACTIONS_MENU: 'MoreTokenActionsMenu', + SECURITY_BADGE_BOTTOM_SHEET: 'SecurityBadgeBottomSheet', NFT_AUTO_DETECTION_MODAL: 'NFTAutoDetectionModal', MULTI_RPC_MIGRATION_MODAL: 'MultiRPcMigrationModal', MAX_BROWSER_TABS_MODAL: 'MaxBrowserTabsModal', @@ -280,7 +285,6 @@ const Routes = { DEFAULT_SLIPPAGE_MODAL: 'DefaultSlippageModal', CUSTOM_SLIPPAGE_MODAL: 'CustomSlippageModal', TRANSACTION_DETAILS_BLOCK_EXPLORER: 'TransactionDetailsBlockExplorer', - QUOTE_EXPIRED_MODAL: 'QuoteExpiredModal', BLOCKAID_MODAL: 'BlockaidModal', RECIPIENT_SELECTOR_MODAL: 'RecipientSelectorModal', MARKET_CLOSED_MODAL: 'MarketClosedModal', @@ -471,6 +475,7 @@ const Routes = { RETURN_TO_DAPP_NOTIFICATION: 'ReturnToDappToast', }, FEATURE_FLAG_OVERRIDE: 'FeatureFlagOverride', + SECURITY_TRUST: 'SecurityTrust', }; export default Routes; diff --git a/app/constants/permissions.ts b/app/constants/permissions.ts index a03ab8f9d97..690897abba2 100644 --- a/app/constants/permissions.ts +++ b/app/constants/permissions.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ export enum USER_INTENT { None, Create, diff --git a/app/constants/snaps.ts b/app/constants/snaps.ts index 60f181ddf50..d7125f7d7bb 100644 --- a/app/constants/snaps.ts +++ b/app/constants/snaps.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import type { SupportedCurve } from '@metamask/key-tree'; export type SnapsDerivationPathType = ['m', ...string[]]; diff --git a/app/constants/transaction.ts b/app/constants/transaction.ts index 0bbeb31c605..b691c760456 100644 --- a/app/constants/transaction.ts +++ b/app/constants/transaction.ts @@ -25,6 +25,7 @@ export const PREFIX_HEX_STRING = '0x'; export const INTERNAL_ORIGINS = [ process.env.MM_FOX_CODE, TransactionTypes.MMM, + TransactionTypes.MMM_CARD, ORIGIN_METAMASK, ]; diff --git a/app/controllers/perps/PerpsController-method-action-types.ts b/app/controllers/perps/PerpsController-method-action-types.ts index f42cd24cbb3..6231b294c9f 100644 --- a/app/controllers/perps/PerpsController-method-action-types.ts +++ b/app/controllers/perps/PerpsController-method-action-types.ts @@ -170,6 +170,11 @@ export type PerpsControllerResetSelectedPaymentTokenAction = { handler: PerpsController['resetSelectedPaymentToken']; }; +export type PerpsControllerStartEligibilityMonitoringAction = { + type: 'PerpsController:startEligibilityMonitoring'; + handler: PerpsController['startEligibilityMonitoring']; +}; + export type PerpsControllerMethodActions = | PerpsControllerPlaceOrderAction | PerpsControllerEditOrderAction @@ -204,4 +209,5 @@ export type PerpsControllerMethodActions = | PerpsControllerGetOrderBookGroupingAction | PerpsControllerSaveOrderBookGroupingAction | PerpsControllerSetSelectedPaymentTokenAction - | PerpsControllerResetSelectedPaymentTokenAction; + | PerpsControllerResetSelectedPaymentTokenAction + | PerpsControllerStartEligibilityMonitoringAction; diff --git a/app/controllers/perps/PerpsController.test.ts b/app/controllers/perps/PerpsController.test.ts index 3fd6857d49a..9d190be6321 100644 --- a/app/controllers/perps/PerpsController.test.ts +++ b/app/controllers/perps/PerpsController.test.ts @@ -681,6 +681,127 @@ describe('PerpsController', () => { }); }); + describe('deferEligibilityCheck', () => { + it('skips refreshEligibility when eligibility check is deferred', async () => { + // Arrange + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + if (action === 'GeolocationController:getGeolocation') { + return 'US'; + } + return undefined; + }); + + const deferredController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + deferEligibilityCheck: true, + }); + + // Act + await deferredController.refreshEligibility(); + + // Assert — geolocation was never called because refreshEligibility returned early + expect(testMockCall).not.toHaveBeenCalledWith( + 'GeolocationController:getGeolocation', + ); + }); + + it('resumes eligibility checks after startEligibilityMonitoring is called', () => { + // Arrange + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US'], + }, + }, + }; + } + return undefined; + }); + + const deferredController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + deferEligibilityCheck: true, + }); + + // Reset mocks after construction to isolate startEligibilityMonitoring behavior + testMockCall.mockClear(); + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockClear(); + + // Re-wire the mock so it still returns flags when called again + testMockCall.mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US'], + }, + }, + }; + } + return undefined; + }); + + // Act + deferredController.startEligibilityMonitoring(); + + // Assert — startEligibilityMonitoring itself reads remote flags and triggers eligibility + expect(testMockCall).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + expect( + mockFeatureFlagConfigurationServiceInstance.refreshEligibility, + ).toHaveBeenCalled(); + }); + + it('logs error when RemoteFeatureFlagController throws during startEligibilityMonitoring', () => { + // Arrange + const testInfrastructure = createMockInfrastructure(); + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + throw new Error('Controller not ready'); + } + return undefined; + }); + + const deferredController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: testInfrastructure, + deferEligibilityCheck: true, + }); + + // Reset mock to isolate startEligibilityMonitoring errors from constructor errors + (testInfrastructure.logger.error as jest.Mock).mockClear(); + + // Act — should not throw + expect(() => + deferredController.startEligibilityMonitoring(), + ).not.toThrow(); + + // Assert — error was logged + expect(testInfrastructure.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + method: 'startEligibilityMonitoring', + operation: 'readRemoteFeatureFlags', + }), + }), + }), + ); + }); + }); + describe('HIP-3 Configuration Integration', () => { it('delegates HIP-3 config updates to FeatureFlagConfigurationService', () => { const remoteFlags = { @@ -5036,7 +5157,7 @@ describe('PerpsController', () => { await jest.advanceTimersByTimeAsync(500); const userCache = preloadController.state.cachedUserDataByProvider; - const cacheKey = Object.keys(userCache)[0] as string; + const cacheKey = Object.keys(userCache)[0]; expect(cacheKey).toBeDefined(); const entry = userCache[cacheKey]; expect(entry.positions).toEqual(mockPositions); @@ -5112,7 +5233,7 @@ describe('PerpsController', () => { await jest.advanceTimersByTimeAsync(500); const freshCache = preloadController.state.cachedUserDataByProvider; - const freshKey = Object.keys(freshCache)[0] as string; + const freshKey = Object.keys(freshCache)[0]; expect(freshKey).toBeDefined(); expect(freshCache[freshKey].address).toBe(mockEvmAccount.address); expect(freshCache[freshKey].timestamp).toBeGreaterThan(0); diff --git a/app/controllers/perps/PerpsController.ts b/app/controllers/perps/PerpsController.ts index 6cabef5957c..904c8130860 100644 --- a/app/controllers/perps/PerpsController.ts +++ b/app/controllers/perps/PerpsController.ts @@ -8,7 +8,6 @@ import type { StateChangeListener } from '@metamask/base-controller'; import { ORIGIN_METAMASK } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; -import { addBreadcrumb } from '@sentry/react-native'; import { v4 as uuidv4 } from 'uuid'; import { CandlePeriod } from './constants/chartConfig'; @@ -591,6 +590,12 @@ export type PerpsControllerOptions = { * Must be provided by the platform (mobile/extension) at instantiation time. */ infrastructure: PerpsPlatformDependencies; + /** + * When true, defers the initial eligibility (geolocation) check until + * `startEligibilityMonitoring()` is called. This prevents the eager + * geolocation fetch from firing during wallet onboarding (privacy compliance). + */ + deferEligibilityCheck?: boolean; }; type BlockedRegionList = { @@ -633,6 +638,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'saveOrderBookGrouping', 'setSelectedPaymentToken', 'resetSelectedPaymentToken', + 'startEligibilityMonitoring', ] as const; /** @@ -747,6 +753,8 @@ export class PerpsController extends BaseController< #standaloneProviderHip3Version: number | null = null; + #eligibilityCheckDeferred: boolean; + // Store options for dependency injection (allows core package to inject platform-specific services) readonly #options: PerpsControllerOptions; @@ -772,6 +780,7 @@ export class PerpsController extends BaseController< state = {}, clientConfig = {}, infrastructure, + deferEligibilityCheck = false, }: PerpsControllerOptions) { super({ name: 'PerpsController', @@ -780,6 +789,8 @@ export class PerpsController extends BaseController< state: { ...getDefaultPerpsControllerState(), ...state }, }); + this.#eligibilityCheckDeferred = deferEligibilityCheck; + // Store options for dependency injection this.#options = { messenger, @@ -2019,7 +2030,7 @@ export class PerpsController extends BaseController< skipInitialGasEstimate: true, }; - addBreadcrumb({ + this.#options.infrastructure.tracer.addBreadcrumb({ category: 'perps', message: 'Deposit action started', level: 'info', @@ -3919,7 +3930,33 @@ export class PerpsController extends BaseController< /** * Refresh eligibility status */ + /** + * Resume eligibility monitoring after onboarding completes. + * Clears the deferred flag and triggers an immediate eligibility check + * using the current remote feature flag state. + */ + startEligibilityMonitoring(): void { + this.#eligibilityCheckDeferred = false; + try { + const currentState = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + this.refreshEligibilityOnFeatureFlagChange(currentState); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.startEligibilityMonitoring'), + this.#getErrorContext('startEligibilityMonitoring', { + operation: 'readRemoteFeatureFlags', + }), + ); + } + } + async refreshEligibility(): Promise { + if (this.#eligibilityCheckDeferred) { + return; + } + // Capture the current version before starting the async operation. // This prevents race conditions where stale eligibility checks // (started with fallback config) overwrite results from newer checks diff --git a/app/controllers/perps/constants/eventNames.ts b/app/controllers/perps/constants/eventNames.ts index 2fe7d4e156a..5740e8a26bc 100644 --- a/app/controllers/perps/constants/eventNames.ts +++ b/app/controllers/perps/constants/eventNames.ts @@ -34,6 +34,7 @@ export const PERPS_EVENT_PROPERTY = { // Position properties OPEN_POSITION: 'open_position', + OPEN_ORDER: 'open_order', OPEN_POSITION_SIZE: 'open_position_size', UNREALIZED_PNL_DOLLAR: 'unrealized_dollar_pnl', UNREALIZED_PNL_PERCENT: 'unrealized_percent_pnl', @@ -136,6 +137,12 @@ export const PERPS_EVENT_PROPERTY = { // Scroll tracking properties SECTION_VIEWED: 'section_viewed', + // Order value (USD $ value of the order) + ORDER_VALUE: 'order_value', + + // Market category filter (for market list screen) + MARKET_CATEGORY: 'market_category', + // Pay with any token (PERPS_TRADE_TRANSACTION) TRADE_WITH_TOKEN: 'trade_with_token', MM_PAY_TOKEN_SELECTED: 'mm_pay_token_selected', @@ -203,6 +210,8 @@ export const PERPS_EVENT_VALUE = { PERPS_HOME_EXPLORE_CRYPTO: 'perps_home_explore_crypto', PERPS_HOME_EXPLORE_STOCKS: 'perps_home_explore_stocks', PERPS_HOME_ACTIVITY: 'perps_home_activity', + // Explore/Trending page source + EXPLORE: 'explore', // Market list tab sources PERPS_MARKET_LIST_ALL: 'perps_market_list_all', PERPS_MARKET_LIST_CRYPTO: 'perps_market_list_crypto', @@ -402,9 +411,16 @@ export const PERPS_EVENT_VALUE = { REMOVE_MARGIN: 'remove_margin', EDIT_TP_SL: 'edit_tp_sl', CREATE_TP_SL: 'create_tp_sl', + // TP/SL specific actions for risk management events + TP: 'tp', + SL: 'sl', + TPSL: 'tpsl', // Trade transaction actions - differentiates new position from adding to existing CREATE_POSITION: 'create_position', INCREASE_EXPOSURE: 'increase_exposure', + // Flip position actions with direction specificity + FLIP_LONG_TO_SHORT: 'flip_long_to_short', + FLIP_SHORT_TO_LONG: 'flip_short_to_long', }, // Risk management sources RISK_MANAGEMENT_SOURCE: { diff --git a/app/controllers/perps/constants/hyperLiquidConfig.ts b/app/controllers/perps/constants/hyperLiquidConfig.ts index 9e56137b13b..1159c2277c1 100644 --- a/app/controllers/perps/constants/hyperLiquidConfig.ts +++ b/app/controllers/perps/constants/hyperLiquidConfig.ts @@ -30,8 +30,6 @@ export const USDC_SYMBOL = 'USDC'; export const USDC_NAME = 'USD Coin'; export const USDC_DECIMALS = 6; export const TOKEN_DECIMALS = 18; -export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; -export const ZERO_BALANCE = '0x0'; // Network constants export const ARBITRUM_SEPOLIA_CHAIN_ID = '0x66eee'; // 421614 in decimal @@ -337,6 +335,19 @@ export const HIP3_ASSET_MARKET_TYPES: Record< 'xyz:CRWV': 'equity', 'xyz:SMSN': 'equity', + 'xyz:GME': 'equity', + 'xyz:SOFTBANK': 'equity', + 'xyz:HYUNDAI': 'equity', + 'xyz:KIOXIA': 'equity', + 'xyz:HIMS': 'equity', + 'xyz:URNM': 'equity', + 'xyz:EWY': 'equity', + 'xyz:EWJ': 'equity', + 'xyz:SP500': 'equity', + 'xyz:JP225': 'equity', + 'xyz:KR200': 'equity', + 'xyz:VIX': 'equity', + // xyz DEX - Commodities 'xyz:GOLD': 'commodity', 'xyz:SILVER': 'commodity', @@ -347,10 +358,13 @@ export const HIP3_ASSET_MARKET_TYPES: Record< 'xyz:USAR': 'commodity', 'xyz:NATGAS': 'commodity', 'xyz:PLATINUM': 'commodity', + 'xyz:PALLADIUM': 'commodity', + 'xyz:BRENTOIL': 'commodity', // xyz DEX - Forex 'xyz:EUR': 'forex', 'xyz:JPY': 'forex', + 'xyz:DXY': 'forex', } as const; /** diff --git a/app/controllers/perps/constants/perpsConfig.ts b/app/controllers/perps/constants/perpsConfig.ts index 32b95b447d8..b1b76be7e71 100644 --- a/app/controllers/perps/constants/perpsConfig.ts +++ b/app/controllers/perps/constants/perpsConfig.ts @@ -8,6 +8,9 @@ * UI-only constants (layout, display, navigation) live in: * app/components/UI/Perps/constants/perpsConfig.ts */ +export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +export const ZERO_BALANCE = '0x0'; + export const PERPS_CONSTANTS = { FeatureFlagKey: 'perpsEnabled', FeatureName: 'perps', // Constant for Sentry error filtering - enables "feature:perps" dashboard queries diff --git a/app/controllers/perps/services/AccountService.ts b/app/controllers/perps/services/AccountService.ts index d60f8bc9bc4..62feed585a1 100644 --- a/app/controllers/perps/services/AccountService.ts +++ b/app/controllers/perps/services/AccountService.ts @@ -1,5 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; +import type { ServiceContext } from './ServiceContext'; import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, @@ -21,7 +22,6 @@ import type { WithdrawResult, PerpsPlatformDependencies, } from '../types'; -import type { ServiceContext } from './ServiceContext'; import type { PerpsControllerMessengerBase } from '../types/messenger'; import type { TransactionStatus } from '../types/transactionTypes'; import { getSelectedEvmAccount } from '../utils/accountUtils'; diff --git a/app/controllers/perps/services/DataLakeService.ts b/app/controllers/perps/services/DataLakeService.ts index 18c0162d1e1..8e7035ea57a 100644 --- a/app/controllers/perps/services/DataLakeService.ts +++ b/app/controllers/perps/services/DataLakeService.ts @@ -1,5 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; +import type { ServiceContext } from './ServiceContext'; import { PerpsMeasurementName } from '../constants/performanceMetrics'; import { DATA_LAKE_API_CONFIG, @@ -7,7 +8,6 @@ import { } from '../constants/perpsConfig'; import { PerpsTraceNames, PerpsTraceOperations } from '../types'; import type { PerpsPlatformDependencies } from '../types'; -import type { ServiceContext } from './ServiceContext'; import type { PerpsControllerMessengerBase } from '../types/messenger'; import { getSelectedEvmAccount } from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; diff --git a/app/controllers/perps/services/FeatureFlagConfigurationService.ts b/app/controllers/perps/services/FeatureFlagConfigurationService.ts index ee029503549..b96fe5ee458 100644 --- a/app/controllers/perps/services/FeatureFlagConfigurationService.ts +++ b/app/controllers/perps/services/FeatureFlagConfigurationService.ts @@ -1,12 +1,12 @@ import { hasProperty } from '@metamask/utils'; +import type { ServiceContext } from './ServiceContext'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import { isVersionGatedFeatureFlag } from '../types'; import type { PerpsPlatformDependencies, PerpsRemoteFeatureFlagState, } from '../types'; -import type { ServiceContext } from './ServiceContext'; import { ensureError } from '../utils/errorUtils'; import { validateMarketPattern } from '../utils/marketUtils'; import { diff --git a/app/controllers/perps/services/HyperLiquidSubscriptionService.ts b/app/controllers/perps/services/HyperLiquidSubscriptionService.ts index 2b3e64732e1..5cedb6be657 100644 --- a/app/controllers/perps/services/HyperLiquidSubscriptionService.ts +++ b/app/controllers/perps/services/HyperLiquidSubscriptionService.ts @@ -15,6 +15,8 @@ import type { OpenOrdersWsEvent, } from '@nktkas/hyperliquid'; +import type { HyperLiquidClientService } from './HyperLiquidClientService'; +import type { HyperLiquidWalletService } from './HyperLiquidWalletService'; import { TP_SL_CONFIG, PERPS_CONSTANTS } from '../constants/perpsConfig'; import { WebSocketConnectionState } from '../types'; import type { @@ -35,8 +37,6 @@ import type { PerpsPlatformDependencies, PerpsLogger, } from '../types'; -import type { HyperLiquidClientService } from './HyperLiquidClientService'; -import type { HyperLiquidWalletService } from './HyperLiquidWalletService'; import { calculateWeightedReturnOnEquity } from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; import { diff --git a/app/controllers/perps/services/MYXClientService.ts b/app/controllers/perps/services/MYXClientService.ts index 0a2e2b804b5..e8bbbc47328 100644 --- a/app/controllers/perps/services/MYXClientService.ts +++ b/app/controllers/perps/services/MYXClientService.ts @@ -15,13 +15,12 @@ import type { } from '@myx-trade/sdk'; import { MyxClient } from '@myx-trade/sdk'; -import AppConstants from '../../../core/AppConstants'; import { MYX_PRICE_POLLING_INTERVAL_MS, getMYXChainId, getMYXHttpEndpoint, } from '../constants/myxConfig'; -import { PERPS_CONSTANTS } from '../constants/perpsConfig'; +import { PERPS_CONSTANTS, ZERO_ADDRESS } from '../constants/perpsConfig'; import type { PerpsPlatformDependencies } from '../types'; import type { MYXAuthConfig, @@ -113,10 +112,9 @@ export class MYXClientService { brokerAddress: '', }; - const brokerAddress = - this.#authConfig.brokerAddress || AppConstants.ZERO_ADDRESS; + const brokerAddress = this.#authConfig.brokerAddress || ZERO_ADDRESS; - if (brokerAddress === AppConstants.ZERO_ADDRESS) { + if (brokerAddress === ZERO_ADDRESS) { this.#deps.debugLogger.log( '[MYXClientService] brokerAddress not configured, using zero address', ); @@ -139,9 +137,7 @@ export class MYXClientService { chainId: this.#chainId, wsConnected: true, brokerAddress: - brokerAddress === AppConstants.ZERO_ADDRESS - ? 'zero (not configured)' - : 'configured', + brokerAddress === ZERO_ADDRESS ? 'zero (not configured)' : 'configured', }); } diff --git a/app/controllers/perps/services/MarketDataService.ts b/app/controllers/perps/services/MarketDataService.ts index 3da87699504..806ed504aa2 100644 --- a/app/controllers/perps/services/MarketDataService.ts +++ b/app/controllers/perps/services/MarketDataService.ts @@ -1,5 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; +import type { ServiceContext } from './ServiceContext'; import type { CandlePeriod } from '../constants/chartConfig'; import { PerpsMeasurementName } from '../constants/performanceMetrics'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; @@ -31,7 +32,6 @@ import type { AssetRoute, PerpsPlatformDependencies, } from '../types'; -import type { ServiceContext } from './ServiceContext'; import type { CandleData } from '../types/perps-types'; import { ensureError } from '../utils/errorUtils'; diff --git a/app/controllers/perps/services/TradingService.ts b/app/controllers/perps/services/TradingService.ts index a87894212d2..4f3530372fa 100644 --- a/app/controllers/perps/services/TradingService.ts +++ b/app/controllers/perps/services/TradingService.ts @@ -1,4 +1,3 @@ -import { addBreadcrumb } from '@sentry/react-native'; import { v4 as uuidv4 } from 'uuid'; import type { RewardsIntegrationService } from './RewardsIntegrationService'; @@ -182,6 +181,15 @@ export class TradingService { PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE; } + // Calculate order value in USD (size * price) + const orderSize = parseFloat(result?.filledSize ?? params.size); + const assetPrice = result?.averagePrice + ? parseFloat(result.averagePrice) + : params.trackingData?.marketPrice; + if (assetPrice && orderSize) { + properties[PERPS_EVENT_PROPERTY.ORDER_VALUE] = orderSize * assetPrice; + } + // Add success-specific properties if (status === PERPS_EVENT_VALUE.STATUS.EXECUTED) { if (params.trackingData?.metamaskFee !== undefined) { @@ -370,7 +378,7 @@ export class TradingService { : 'perps_balance'; try { - addBreadcrumb({ + this.#deps.tracer.addBreadcrumb({ category: 'perps', message: 'Order execution started', level: 'info', @@ -675,13 +683,28 @@ export class TradingService { [PERPS_EVENT_PROPERTY.RECEIVED_AMOUNT]: params.trackingData.receivedAmount, }), + ...(params.trackingData?.source && { + [PERPS_EVENT_PROPERTY.SOURCE]: params.trackingData.source, + }), }; + // Calculate and add order value in USD (size * price) + const closeAssetPrice = result?.averagePrice + ? parseFloat(result.averagePrice) + : params.trackingData?.marketPrice; + const orderValue = + closeAssetPrice && metrics.requestedSize + ? metrics.requestedSize * closeAssetPrice + : undefined; + // Add success-specific properties if (status === PERPS_EVENT_VALUE.STATUS.EXECUTED) { return { ...baseProperties, [PERPS_EVENT_PROPERTY.CLOSE_TYPE]: metrics.closeType, + ...(orderValue !== undefined && { + [PERPS_EVENT_PROPERTY.ORDER_VALUE]: orderValue, + }), }; } @@ -689,6 +712,9 @@ export class TradingService { return { ...baseProperties, ...(error && { [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: error }), + ...(orderValue !== undefined && { + [PERPS_EVENT_PROPERTY.ORDER_VALUE]: orderValue, + }), }; } @@ -1711,6 +1737,16 @@ export class TradingService { const hasTakeProfit = Boolean(params.takeProfitPrice); const hasStopLoss = Boolean(params.stopLossPrice); + // Determine TP/SL action type + let tpslAction: string | undefined; + if (hasTakeProfit && hasStopLoss) { + tpslAction = PERPS_EVENT_VALUE.ACTION.TPSL; + } else if (hasTakeProfit) { + tpslAction = PERPS_EVENT_VALUE.ACTION.TP; + } else if (hasStopLoss) { + tpslAction = PERPS_EVENT_VALUE.ACTION.SL; + } + // Build comprehensive event properties const eventProperties = { [PERPS_EVENT_PROPERTY.STATUS]: result?.success @@ -1722,6 +1758,9 @@ export class TradingService { [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: screenType, [PERPS_EVENT_PROPERTY.HAS_TAKE_PROFIT]: hasTakeProfit, [PERPS_EVENT_PROPERTY.HAS_STOP_LOSS]: hasStopLoss, + ...(tpslAction && { + [PERPS_EVENT_PROPERTY.ACTION]: tpslAction, + }), ...(direction && { [PERPS_EVENT_PROPERTY.DIRECTION]: direction === 'long' @@ -1949,7 +1988,11 @@ export class TradingService { }); } - // Track success analytics + // Track success analytics with direction-specific flip action + const flipAction = isCurrentlyLong + ? PERPS_EVENT_VALUE.ACTION.FLIP_LONG_TO_SHORT + : PERPS_EVENT_VALUE.ACTION.FLIP_SHORT_TO_LONG; + this.#deps.metrics.trackPerpsEvent( PerpsAnalyticsEvent.TradeTransaction, { @@ -1962,7 +2005,9 @@ export class TradingService { [PERPS_EVENT_PROPERTY.LEVERAGE]: position.leverage?.value || 1, [PERPS_EVENT_PROPERTY.ORDER_SIZE]: positionSize, [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, - [PERPS_EVENT_PROPERTY.ACTION]: 'flip_position', + [PERPS_EVENT_PROPERTY.ACTION]: flipAction, + [PERPS_EVENT_PROPERTY.ORDER_VALUE]: + positionSize * parseFloat(position.entryPrice), }, ); @@ -1988,11 +2033,16 @@ export class TradingService { this.#getErrorContext('flipPosition', { symbol: position.symbol }), ); - // Track failure analytics + // Track failure analytics with direction-specific flip action + const wasLong = parseFloat(position.size) > 0; + const failFlipAction = wasLong + ? PERPS_EVENT_VALUE.ACTION.FLIP_LONG_TO_SHORT + : PERPS_EVENT_VALUE.ACTION.FLIP_SHORT_TO_LONG; + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, [PERPS_EVENT_PROPERTY.ASSET]: position.symbol, - [PERPS_EVENT_PROPERTY.ACTION]: 'flip_position', + [PERPS_EVENT_PROPERTY.ACTION]: failFlipAction, [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, }); diff --git a/app/controllers/perps/types/index.ts b/app/controllers/perps/types/index.ts index a8bd656aa31..7b096606d1c 100644 --- a/app/controllers/perps/types/index.ts +++ b/app/controllers/perps/types/index.ts @@ -1405,6 +1405,13 @@ export type PerpsTracer = { }): void; setMeasurement(name: string, value: number, unit: string): void; + + addBreadcrumb(breadcrumb: { + category: string; + message: string; + level: 'fatal' | 'error' | 'warning' | 'log' | 'info' | 'debug'; + data?: Record; + }): void; }; // ============================================================================ diff --git a/app/controllers/perps/utils/hyperLiquidAdapter.ts b/app/controllers/perps/utils/hyperLiquidAdapter.ts index f3d8b90ddae..8cbe2a2614f 100644 --- a/app/controllers/perps/utils/hyperLiquidAdapter.ts +++ b/app/controllers/perps/utils/hyperLiquidAdapter.ts @@ -1,5 +1,9 @@ import { Hex, isHexString } from '@metamask/utils'; +import { + countSignificantFigures, + roundToSignificantFigures, +} from './significantFigures'; import { HIP3_ASSET_ID_CONFIG } from '../constants/hyperLiquidConfig'; import { DECIMAL_PRECISION_CONFIG } from '../constants/perpsConfig'; import type { @@ -11,10 +15,6 @@ import type { RawLedgerUpdate, UserHistoryItem, } from '../types'; -import { - countSignificantFigures, - roundToSignificantFigures, -} from './significantFigures'; import type { AssetPosition, FrontendOrder, diff --git a/app/controllers/perps/utils/marketDataTransform.ts b/app/controllers/perps/utils/marketDataTransform.ts index 46ef2b4515c..428a645c134 100644 --- a/app/controllers/perps/utils/marketDataTransform.ts +++ b/app/controllers/perps/utils/marketDataTransform.ts @@ -4,6 +4,7 @@ * Portable: no mobile-specific imports. * Formatters are injected via MarketDataFormatters interface. */ +import { parseAssetName } from './hyperLiquidAdapter'; import { HYPERLIQUID_CONFIG } from '../constants/hyperLiquidConfig'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import type { @@ -11,7 +12,6 @@ import type { MarketType, MarketDataFormatters, } from '../types'; -import { parseAssetName } from './hyperLiquidAdapter'; import type { AllMidsResponse, PerpsUniverse, diff --git a/app/controllers/perps/utils/myxAdapter.ts b/app/controllers/perps/utils/myxAdapter.ts index aaeee8a92fd..ee9b348ad9b 100644 --- a/app/controllers/perps/utils/myxAdapter.ts +++ b/app/controllers/perps/utils/myxAdapter.ts @@ -455,12 +455,10 @@ export function adaptAccountStateFromMYX( // accountInfo structure varies; extract what we can // TODO: Verify SDK semantics — if totalCollateral already includes unrealizedPnl, // the totalBalance formula below double-counts. Needs SDK documentation check. - const marginUsed = accountInfo - ? fromMYXCollateral(String(accountInfo.totalCollateral ?? '0')) - : 0; - const unrealizedPnl = accountInfo - ? fromMYXCollateral(String(accountInfo.unrealizedPnl ?? '0')) - : 0; + const rawCollateral = accountInfo?.totalCollateral ?? '0'; + const rawPnl = accountInfo?.unrealizedPnl ?? '0'; + const marginUsed = accountInfo ? fromMYXCollateral(String(rawCollateral)) : 0; + const unrealizedPnl = accountInfo ? fromMYXCollateral(String(rawPnl)) : 0; const balance = walletBalance ? fromMYXCollateral(walletBalance) : 0; const totalBalance = balance + marginUsed + unrealizedPnl; diff --git a/app/controllers/perps/utils/orderCalculations.ts b/app/controllers/perps/utils/orderCalculations.ts index 3c8aeb7984d..d6dffbed5d3 100644 --- a/app/controllers/perps/utils/orderCalculations.ts +++ b/app/controllers/perps/utils/orderCalculations.ts @@ -1,12 +1,12 @@ import type { Hex } from '@metamask/utils'; -import { ORDER_SLIPPAGE_CONFIG } from '../constants/perpsConfig'; -import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; -import type { PerpsDebugLogger } from '../types'; import { formatHyperLiquidPrice, formatHyperLiquidSize, } from './hyperLiquidAdapter'; +import { ORDER_SLIPPAGE_CONFIG } from '../constants/perpsConfig'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; +import type { PerpsDebugLogger } from '../types'; import type { SDKOrderParams } from '../types/hyperliquid-types'; /** diff --git a/app/controllers/perps/utils/rewardsUtils.ts b/app/controllers/perps/utils/rewardsUtils.ts index afb4f2c37aa..864e3be2be1 100644 --- a/app/controllers/perps/utils/rewardsUtils.ts +++ b/app/controllers/perps/utils/rewardsUtils.ts @@ -12,8 +12,8 @@ import { parseCaipChainId, } from '@metamask/utils'; -import type { PerpsLogger } from '../types'; import { ensureError } from './errorUtils'; +import type { PerpsLogger } from '../types'; /** * Converts a numeric or hex chain ID to a CAIP-2 chain ID string. diff --git a/app/core/AgenticService/AgenticService.test.ts b/app/core/AgenticService/AgenticService.test.ts index e7429058e00..9fd496d3998 100644 --- a/app/core/AgenticService/AgenticService.test.ts +++ b/app/core/AgenticService/AgenticService.test.ts @@ -69,6 +69,13 @@ jest.mock('../../actions/security', () => ({ setDataCollectionForMarketing: () => ({ type: 'SET_DATA_COLLECTION_FOR_MARKETING', }), + setOsAuthEnabled: (enabled: boolean) => ({ + type: 'SET_OS_AUTH_ENABLED', + enabled, + }), +})); +jest.mock('../../actions/settings', () => ({ + setLockTime: (lockTime: number) => ({ type: 'SET_LOCK_TIME', lockTime }), })); jest.mock('@metamask/key-tree', () => ({ mnemonicPhraseToBytes: jest.fn((s: string) => new Uint8Array(s.length)), @@ -96,6 +103,16 @@ jest.mock('../NavigationService', () => ({ jest.mock('../../constants/navigation/Routes', () => ({ ONBOARDING: { HOME_NAV: 'HomeNav' }, })); +jest.mock('../SecureKeychain', () => ({ + setGenericPassword: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('../../constants/userProperties', () => ({ + __esModule: true, + default: { DEVICE_AUTHENTICATION: 'device_authentication' }, +})); +jest.mock('../SDKConnect/utils/DevLogger', () => ({ + log: jest.fn(), +})); const MockEngine = jest.mocked(Engine); @@ -111,6 +128,7 @@ function makeFiber( return { child: null, sibling: null, + return: null, memoizedProps: testID || onPress ? { testID, onPress } : null, stateNode: null, ...rest, @@ -705,5 +723,51 @@ describe('AgenticService.install', () => { }); expect(mockMarkTutorial).not.toHaveBeenCalled(); }); + + it('dispatches setLockTime(-1) when autoLockNever is true', async () => { + mockDispatch.mockClear(); + await bridge().setupWallet({ + password: 'test123', + accounts: [], + settings: { autoLockNever: true }, + }); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'SET_LOCK_TIME', lockTime: -1 }), + ); + }); + + it('does not dispatch setLockTime when autoLockNever is not set', async () => { + mockDispatch.mockClear(); + await bridge().setupWallet({ + password: 'test123', + accounts: [], + }); + expect(mockDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'SET_LOCK_TIME' }), + ); + }); + + it('dispatches setOsAuthEnabled(true) when deviceAuthEnabled is true', async () => { + mockDispatch.mockClear(); + await bridge().setupWallet({ + password: 'test123', + accounts: [], + settings: { deviceAuthEnabled: true }, + }); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'SET_OS_AUTH_ENABLED', enabled: true }), + ); + }); + + it('does not dispatch setOsAuthEnabled when deviceAuthEnabled is not set', async () => { + mockDispatch.mockClear(); + await bridge().setupWallet({ + password: 'test123', + accounts: [], + }); + expect(mockDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'SET_OS_AUTH_ENABLED' }), + ); + }); }); }); diff --git a/app/core/AgenticService/AgenticService.ts b/app/core/AgenticService/AgenticService.ts index 014815c58c6..568833c4aa8 100644 --- a/app/core/AgenticService/AgenticService.ts +++ b/app/core/AgenticService/AgenticService.ts @@ -29,10 +29,17 @@ import { REWARDS_GTM_MODAL_SHOWN, } from '../../constants/storage'; import { analytics } from '../../util/analytics/analytics'; -import { setDataCollectionForMarketing } from '../../actions/security'; +import { + setDataCollectionForMarketing, + setOsAuthEnabled, +} from '../../actions/security'; +import { setLockTime } from '../../actions/settings'; import AccountTreeInitService from '../../multichain-accounts/AccountTreeInitService'; import NavigationService from '../NavigationService'; import Routes from '../../constants/navigation/Routes'; +import SecureKeychain from '../SecureKeychain'; +import AUTHENTICATION_TYPE from '../../constants/userProperties'; +import DevLogger from '../SDKConnect/utils/DevLogger'; // ─── Fiber tree types ────────────────────────────────────────────────────── @@ -43,6 +50,7 @@ import Routes from '../../constants/navigation/Routes'; interface FiberNode { child: FiberNode | null; sibling: FiberNode | null; + return: FiberNode | null; memoizedProps: { testID?: string; onPress?: (...args: unknown[]) => unknown; @@ -91,6 +99,15 @@ interface AgenticBridge { offset?: number; animated?: boolean; }; + setInput: ( + testId: string, + value: string, + ) => { + ok: boolean; + testId?: string; + value?: string; + error?: string; + }; switchAccount: (address: string) => { switched: boolean; id: string; @@ -108,6 +125,8 @@ interface AgenticBridge { metametrics?: boolean; skipGtmModals?: boolean; skipPerpsTutorial?: boolean; + autoLockNever?: boolean; + deviceAuthEnabled?: boolean; }; }) => Promise<{ ok: boolean; @@ -317,6 +336,41 @@ const AgenticService = { return { ok: false, error: String(e) }; } }, + setInput: (testId: string, value: string) => { + try { + const result: { + ok: boolean; + testId?: string; + value?: string; + error?: string; + } = { + ok: false, + error: `No component with testID="${testId}" found`, + }; + walkFiberRoots((rootFiber) => { + const target = findFiberByTestId(rootFiber, testId); + if (!target) return false; + // Walk the found fiber and its parents looking for onChangeText + let current: FiberNode | null = target; + while (current) { + if (typeof current.memoizedProps?.onChangeText === 'function') { + current.memoizedProps.onChangeText(value); + result.ok = true; + result.testId = testId; + result.value = value; + result.error = undefined; + return true; + } + current = current.return; + } + result.error = `Component with testID="${testId}" has no onChangeText prop`; + return true; + }); + return result; + } catch (e) { + return { ok: false, error: String(e) }; + } + }, switchAccount: (address: string) => { const accounts = Engine.context.AccountsController.listAccounts(); const target = accounts.find( @@ -399,19 +453,44 @@ const AgenticService = { Engine.context.PerpsController?.markTutorialCompleted(); } - // 7. Configure MetaMetrics if specified + // 7. Set auto-lock to "Never" (-1) for agentic workflows + if (settings.autoLockNever === true) { + ReduxService.store.dispatch(setLockTime(-1)); + } + + // 8. Enable device authentication (biometrics/passcode bypass) + if (settings.deviceAuthEnabled === true) { + ReduxService.store.dispatch(setOsAuthEnabled(true)); + } + + // 8b. Store password in SecureKeychain for device-auth auto-unlock on reload (Android only — iOS already handles this) + if ( + settings.deviceAuthEnabled === true && + Platform.OS === 'android' + ) { + DevLogger.log('[AUTO-UNLOCK] Storing password in SecureKeychain', { + authType: AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION, + }); + await SecureKeychain.setGenericPassword( + fixture.password, + AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION, + ); + DevLogger.log('[AUTO-UNLOCK] SecureKeychain password stored'); + } + + // 9. Configure MetaMetrics if specified if (settings.metametrics === false) { await analytics.optOut(); } else if (settings.metametrics === true) { await analytics.optIn(); } - // 8. Navigate to wallet (same as Authentication.unlockWallet) + // 10. Navigate to wallet (same as Authentication.unlockWallet) NavigationService.navigation?.reset({ routes: [{ name: Routes.ONBOARDING.HOME_NAV }], }); - // 9. Collect all ETH accounts for the summary + // 11. Collect all ETH accounts for the summary const ethAccs = ( Object.values( AccountsController.state.internalAccounts.accounts, diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index a535ad52190..745311015fd 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -77,6 +77,8 @@ enum EVENT_NAME { NFT_DETAILS_OPENED = 'NFT Details Opened', TOKEN_LIST_ITEM_CLICKED = 'Token List Item Clicked', TOKEN_DETAILS_OPENED = 'Token Details Opened', + SECURITY_TRUST_BOTTOM_SHEET_OPENED = 'Security Trust BottomSheet Opened', + SECURITY_TRUST_BOTTOM_SHEET_ACTION_TAKEN = 'Security Trust BottomSheet Action Taken', DEFI_TAB_SELECTED = 'DeFi Tab Selected', DEFI_PROTOCOL_DETAILS_OPENED = 'DeFi Protocol Details Opened', VIEW_ALL_ASSETS_CLICKED = 'View All Assets Clicked', @@ -122,6 +124,7 @@ enum EVENT_NAME { // Analytics ANALYTICS_PREFERENCE_SELECTED = 'Analytics Preference Selected', + METRICS_OPT_IN = 'Metrics Opt In', METRICS_OPT_OUT = 'Metrics Opt Out', ANALYTICS_REQUEST_DATA_DELETION = 'Delete MetaMetrics Data Request Submitted', EXPERIMENT_VIEWED = 'Experiment Viewed', @@ -144,6 +147,7 @@ enum EVENT_NAME { WALLET_CREATION_ATTEMPTED = 'Wallet Creation Attempted', WALLET_CREATED = 'Wallet Created', WALLET_SETUP_FAILURE = 'Wallet Setup Failure', + WALLET_GOOGLE_IOS_WARNING_VIEWED = 'Wallet Google Ios Warning Viewed', WALLET_CREATION_ERROR_SCREEN_VIEWED = 'Wallet Creation Error Screen Viewed', WALLET_CREATION_ERROR_RETRY_CLICKED = 'Wallet Creation Error Retry Clicked', WALLET_CREATION_ERROR_REPORT_SENT = 'Wallet Creation Error Report Sent', @@ -827,6 +831,7 @@ const events = { ANALYTICS_PREFERENCE_SELECTED: generateOpt( EVENT_NAME.ANALYTICS_PREFERENCE_SELECTED, ), + METRICS_OPT_IN: generateOpt(EVENT_NAME.METRICS_OPT_IN), METRICS_OPT_OUT: generateOpt(EVENT_NAME.METRICS_OPT_OUT), ANALYTICS_REQUEST_DATA_DELETION: generateOpt( EVENT_NAME.ANALYTICS_REQUEST_DATA_DELETION, @@ -855,6 +860,9 @@ const events = { WALLET_CREATION_ATTEMPTED: generateOpt(EVENT_NAME.WALLET_CREATION_ATTEMPTED), WALLET_CREATED: generateOpt(EVENT_NAME.WALLET_CREATED), WALLET_SETUP_FAILURE: generateOpt(EVENT_NAME.WALLET_SETUP_FAILURE), + WALLET_GOOGLE_IOS_WARNING_VIEWED: generateOpt( + EVENT_NAME.WALLET_GOOGLE_IOS_WARNING_VIEWED, + ), WALLET_CREATION_ERROR_SCREEN_VIEWED: generateOpt( EVENT_NAME.WALLET_CREATION_ERROR_SCREEN_VIEWED, ), @@ -1471,6 +1479,12 @@ const events = { EVENT_NAME.EARN_TOKEN_LIST_ITEM_CLICKED, ), TOKEN_DETAILS_OPENED: generateOpt(EVENT_NAME.TOKEN_DETAILS_OPENED), + SECURITY_TRUST_BOTTOM_SHEET_OPENED: generateOpt( + EVENT_NAME.SECURITY_TRUST_BOTTOM_SHEET_OPENED, + ), + SECURITY_TRUST_BOTTOM_SHEET_ACTION_TAKEN: generateOpt( + EVENT_NAME.SECURITY_TRUST_BOTTOM_SHEET_ACTION_TAKEN, + ), // Bridge SWAP_PAGE_VIEWED: generateOpt(EVENT_NAME.SWAP_PAGE_VIEWED), // Temporary event until unified swap/bridge is done diff --git a/app/core/Authentication/Authentication.test.ts b/app/core/Authentication/Authentication.test.ts index aa11b80d96a..85154e4ca2b 100644 --- a/app/core/Authentication/Authentication.test.ts +++ b/app/core/Authentication/Authentication.test.ts @@ -8,7 +8,7 @@ import { } from '../../constants/storage'; import { Authentication } from './Authentication'; import AUTHENTICATION_TYPE from '../../constants/userProperties'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as Keychain from 'react-native-keychain'; import SecureKeychain from '../SecureKeychain'; import ReduxService, { ReduxStore } from '../redux'; diff --git a/app/core/BackgroundBridge/BackgroundBridge.js b/app/core/BackgroundBridge/BackgroundBridge.js index 9c9959f8138..d9976cb94e9 100644 --- a/app/core/BackgroundBridge/BackgroundBridge.js +++ b/app/core/BackgroundBridge/BackgroundBridge.js @@ -1,4 +1,4 @@ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ import URL from 'url-parse'; import { createSelectedNetworkMiddleware, @@ -50,7 +50,7 @@ const createFilterMiddleware = require('@metamask/eth-json-rpc-filters'); const createSubscriptionManager = require('@metamask/eth-json-rpc-filters/subscriptionManager'); import { providerAsMiddleware } from '@metamask/eth-json-rpc-middleware'; const pump = require('pump'); -// eslint-disable-next-line import/no-nodejs-modules +// eslint-disable-next-line import-x/no-nodejs-modules const EventEmitter = require('events').EventEmitter; const { NOTIFICATION_NAMES } = AppConstants; import DevLogger from '../SDKConnect/utils/DevLogger'; diff --git a/app/core/BackgroundBridge/Port.ts b/app/core/BackgroundBridge/Port.ts index da9065ecd6b..c9d0f8d6482 100644 --- a/app/core/BackgroundBridge/Port.ts +++ b/app/core/BackgroundBridge/Port.ts @@ -2,7 +2,7 @@ import { JS_POST_MESSAGE_TO_PROVIDER, JS_IFRAME_POST_MESSAGE_TO_PROVIDER, } from '../../util/browserScripts'; -// eslint-disable-next-line import/no-nodejs-modules, import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +// eslint-disable-next-line import-x/no-nodejs-modules, import-x/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports const EventEmitter = require('events').EventEmitter; /** diff --git a/app/core/BackgroundBridge/RemotePort.ts b/app/core/BackgroundBridge/RemotePort.ts index 00a1944bd46..d26db6f695d 100644 --- a/app/core/BackgroundBridge/RemotePort.ts +++ b/app/core/BackgroundBridge/RemotePort.ts @@ -1,4 +1,4 @@ -// eslint-disable-next-line import/no-nodejs-modules, import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +// eslint-disable-next-line import-x/no-nodejs-modules, import-x/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports const EventEmitter = require('events').EventEmitter; class RemotePort extends EventEmitter { diff --git a/app/core/BackgroundBridge/WalletConnectPort.ts b/app/core/BackgroundBridge/WalletConnectPort.ts index 9c14217007c..fbec59e7349 100644 --- a/app/core/BackgroundBridge/WalletConnectPort.ts +++ b/app/core/BackgroundBridge/WalletConnectPort.ts @@ -3,7 +3,7 @@ import AppConstants from '../AppConstants'; import { selectEvmChainId } from '../../selectors/networkController'; import { store } from '../../store'; -// eslint-disable-next-line import/no-nodejs-modules, import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +// eslint-disable-next-line import-x/no-nodejs-modules, import-x/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports const EventEmitter = require('events').EventEmitter; const { NOTIFICATION_NAMES } = AppConstants; diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleApproveUrl.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleApproveUrl.test.ts index 803b33e667d..1536ad27f37 100644 --- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleApproveUrl.test.ts +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleApproveUrl.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/no-namespace */ +/* eslint-disable import-x/no-namespace */ import * as AddressUtilsModule from '../../../../../util/address'; import * as NetworksUtilsModule from '../../../../../util/networks'; import * as TransactionsUtilsModule from '../../../../../util/transactions'; diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleAssetUrl.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleAssetUrl.test.ts index 9566268be1d..516970719a1 100644 --- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleAssetUrl.test.ts +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleAssetUrl.test.ts @@ -1,7 +1,7 @@ import { handleAssetUrl } from '../handleAssetUrl'; import NavigationService from '../../../../NavigationService'; import Routes from '../../../../../constants/navigation/Routes'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as UseAssetMetadataModule from '../../../../../components/UI/Bridge/hooks/useAssetMetadata/utils'; import { Hex } from '@metamask/utils'; diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleCardKycNotification.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleCardKycNotification.test.ts index 070ec5cf1ff..bf0b64bbca2 100644 --- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleCardKycNotification.test.ts +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleCardKycNotification.test.ts @@ -6,7 +6,6 @@ import Logger from '../../../../../util/Logger'; import { selectIsAuthenticatedCard, selectOnboardingId, - selectSelectedCountry, selectUserCardLocation, selectCardGeoLocation, selectAlwaysShowCardButton, @@ -17,7 +16,6 @@ import { selectCardFeatureFlag, } from '../../../../../selectors/featureFlagController/card'; import { CardSDK } from '../../../../../components/UI/Card/sdk/CardSDK'; -import { mapCountryToLocation } from '../../../../../components/UI/Card/util/mapCountryToLocation'; jest.mock('../../../../redux', () => ({ __esModule: true, @@ -39,7 +37,6 @@ describe('handleCardKycNotification', () => { const mockNavigate = jest.fn(); const mockLoggerError = Logger.error as jest.Mock; const mockLoggerLog = Logger.log as jest.Mock; - const mockMapCountryToLocation = mapCountryToLocation as jest.Mock; const mockCardFeatureFlag = { chains: { @@ -71,7 +68,6 @@ describe('handleCardKycNotification', () => { // Default mocks - feature disabled (selectOnboardingId as unknown as jest.Mock).mockReturnValue(null); (selectIsAuthenticatedCard as unknown as jest.Mock).mockReturnValue(false); - (selectSelectedCountry as unknown as jest.Mock).mockReturnValue(null); (selectUserCardLocation as unknown as jest.Mock).mockReturnValue( 'international', ); @@ -90,11 +86,6 @@ describe('handleCardKycNotification', () => { getRegistrationStatus: mockGetRegistrationStatus, getUserDetails: mockGetUserDetails, })); - - // Mock mapCountryToLocation - mockMapCountryToLocation.mockImplementation((countryCode: string | null) => - countryCode === 'US' ? 'us' : 'international', - ); }); afterEach(() => { @@ -279,51 +270,30 @@ describe('handleCardKycNotification', () => { }); describe('location handling', () => { - it('uses US location when selectedCountry is US', async () => { - (selectSelectedCountry as unknown as jest.Mock).mockReturnValue({ - key: 'US', - name: 'United States', - }); + it('uses US location when userCardLocation is us', async () => { + (selectUserCardLocation as unknown as jest.Mock).mockReturnValue('us'); mockGetRegistrationStatus.mockResolvedValue({ verificationState: 'VERIFIED', }); await handleCardKycNotification(); - expect(mockMapCountryToLocation).toHaveBeenCalledWith('US'); expect(CardSDK).toHaveBeenCalledWith({ cardFeatureFlag: mockCardFeatureFlag, userCardLocation: 'us', }); }); - it('uses international location when selectedCountry is not US', async () => { - (selectSelectedCountry as unknown as jest.Mock).mockReturnValue({ - key: 'GB', - name: 'United Kingdom', - }); - mockGetRegistrationStatus.mockResolvedValue({ - verificationState: 'VERIFIED', - }); - - await handleCardKycNotification(); - - expect(mockMapCountryToLocation).toHaveBeenCalledWith('GB'); - expect(CardSDK).toHaveBeenCalledWith({ - cardFeatureFlag: mockCardFeatureFlag, - userCardLocation: 'international', - }); - }); - - it('uses international location when selectedCountry is null', async () => { - (selectSelectedCountry as unknown as jest.Mock).mockReturnValue(null); + it('uses international location when userCardLocation is international', async () => { + (selectUserCardLocation as unknown as jest.Mock).mockReturnValue( + 'international', + ); mockGetRegistrationStatus.mockResolvedValue({ verificationState: 'VERIFIED', }); await handleCardKycNotification(); - expect(mockMapCountryToLocation).toHaveBeenCalledWith(null); expect(CardSDK).toHaveBeenCalledWith({ cardFeatureFlag: mockCardFeatureFlag, userCardLocation: 'international', diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleMetaMaskDeeplink.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleMetaMaskDeeplink.test.ts index 0ebf3e6d4b0..ffadc2dc8a7 100644 --- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleMetaMaskDeeplink.test.ts +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleMetaMaskDeeplink.test.ts @@ -9,14 +9,12 @@ import WC2Manager from '../../../../WalletConnect/WalletConnectV2'; import extractURLParams from '../../../utils/extractURLParams'; import handleMetaMaskDeeplink from '../handleMetaMaskDeeplink'; import handleRampUrl from '../handleRampUrl'; -import handleDepositCashUrl from '../handleDepositCashUrl'; jest.mock('../../../../AppConstants'); jest.mock('../../../../SDKConnect/handlers/handleDeeplink'); jest.mock('../../../../SDKConnect/SDKConnect'); jest.mock('../../../../WalletConnect/WalletConnectV2'); jest.mock('../handleRampUrl'); -jest.mock('../handleDepositCashUrl'); jest.mock('../../../../NativeModules', () => ({ Minimizer: { goBack: jest.fn(), @@ -34,9 +32,6 @@ describe('handleMetaMaskProtocol', () => { const mockHandleRampUrl = handleRampUrl as jest.MockedFunction< typeof handleRampUrl >; - const mockHandleDepositCashUrl = handleDepositCashUrl as jest.MockedFunction< - typeof handleDepositCashUrl - >; const handled = jest.fn(); @@ -488,12 +483,12 @@ describe('handleMetaMaskProtocol', () => { }); }); - describe('when url start with ${PREFIXES.METAMASK}${ACTIONS.DEPOSIT}', () => { + describe('when url starts with deprecated deposit scheme', () => { beforeEach(() => { url = `${PREFIXES.METAMASK}${ACTIONS.DEPOSIT}`; }); - it('calls handleDepositCashUrl', () => { + it('does not invoke ramp or deposit navigation handlers', () => { handleMetaMaskDeeplink({ handled, params, @@ -502,11 +497,7 @@ describe('handleMetaMaskProtocol', () => { wcURL, }); - expect(mockHandleDepositCashUrl).toHaveBeenCalledWith( - expect.objectContaining({ - depositPath: expect.any(String), // RampType.DEPOSIT - }), - ); + expect(mockHandleRampUrl).not.toHaveBeenCalled(); }); }); }); diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts index cc13afbf57b..2d77ad014de 100644 --- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts @@ -16,7 +16,7 @@ import handleBrowserUrl from '../handleBrowserUrl'; import { DeepLinkModalLinkType } from '../../../../../components/UI/DeepLinkModal'; import handleMetaMaskDeeplink from '../handleMetaMaskDeeplink'; import { SHIELD_WEBSITE_URL } from '../../../../../constants/shield'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as signatureUtils from '../../../utils/verifySignature'; jest.mock('../handleMetaMaskDeeplink'); @@ -31,7 +31,6 @@ jest.mock('../../../../NativeModules', () => ({ })); jest.mock('../handleDeepLinkModalDisplay'); jest.mock('../handleRampUrl'); -jest.mock('../handleDepositCashUrl'); jest.mock('../handleHomeUrl'); jest.mock('../handleSwapUrl'); jest.mock('../handleBrowserUrl'); @@ -228,14 +227,14 @@ describe('handleUniversalLink', () => { }); }); - describe('ACTIONS.DEPOSIT', () => { - it('calls instance._handleDepositCash if action is ACTIONS.DEPOSIT', async () => { + describe('deprecated deposit universal link', () => { + it('treats deposit path as unsupported without signature', async () => { + url = `https://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.DEPOSIT}?x=1`; urlObj = { hostname: AppConstants.MM_UNIVERSAL_LINK_HOST, - pathname: `/${ACTIONS.DEPOSIT}/additional/path`, - href: 'test-href', + pathname: `/${ACTIONS.DEPOSIT}`, + href: url, } as ReturnType['urlObj']; - url = `https://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.DEPOSIT}/additional/path/additional/path`; await handleUniversalLink({ instance, @@ -245,6 +244,12 @@ describe('handleUniversalLink', () => { url, source: 'test-source', }); + + expect(mockHandleDeepLinkModalDisplay).toHaveBeenCalledWith({ + linkType: DeepLinkModalLinkType.INVALID, + onContinue: expect.any(Function), + onBack: expect.any(Function), + }); expect(handled).toHaveBeenCalled(); }); }); @@ -1702,7 +1707,7 @@ describe('handleUniversalLink', () => { }); }); - describe('skips handling deeplinks without pathname and query params', () => { + describe('skips handling deeplinks that should exit early', () => { // Link cases to test for skipping handling const testLinkCases = [ { @@ -1733,6 +1738,14 @@ describe('handleUniversalLink', () => { link: 'metamask://action?query=value', shouldSkip: false, }, + { + link: `https://link.metamask.io/${ACTIONS.OAUTH_REDIRECT}`, + shouldSkip: true, + }, + { + link: `https://link.metamask.io/${ACTIONS.OAUTH_REDIRECT}?code=test-code&state=test-state`, + shouldSkip: true, + }, ]; testLinkCases.forEach((testCase) => { diff --git a/app/core/DeeplinkManager/handlers/legacy/handleCardKycNotification.ts b/app/core/DeeplinkManager/handlers/legacy/handleCardKycNotification.ts index d4c04a42f87..9badee2aa64 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handleCardKycNotification.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handleCardKycNotification.ts @@ -5,7 +5,6 @@ import Routes from '../../../../constants/navigation/Routes'; import { selectIsAuthenticatedCard, selectOnboardingId, - selectSelectedCountry, selectUserCardLocation, selectCardGeoLocation, selectAlwaysShowCardButton, @@ -18,11 +17,7 @@ import { } from '../../../../selectors/featureFlagController/card'; import { isBaanxLoginEnabled } from '../../../../components/UI/Card/hooks/isBaanxLoginEnabled'; import { CardSDK } from '../../../../components/UI/Card/sdk/CardSDK'; -import { mapCountryToLocation } from '../../../../components/UI/Card/util/mapCountryToLocation'; -import { - CardLocation, - CardVerificationState, -} from '../../../../components/UI/Card/types'; +import { CardVerificationState } from '../../../../components/UI/Card/types'; /** * Card KYC notification deeplink handler @@ -149,13 +144,9 @@ async function handleOnboardingFlow( ); // Get location from selectedCountry - const selectedCountry = selectSelectedCountry(state); - const location: CardLocation = mapCountryToLocation( - selectedCountry?.key ?? null, - ); + const location = selectUserCardLocation(state); Logger.log('[handleCardKycNotification] Determined location:', { - selectedCountryKey: selectedCountry?.key, location, }); diff --git a/app/core/DeeplinkManager/handlers/legacy/handleDepositCashUrl.ts b/app/core/DeeplinkManager/handlers/legacy/handleDepositCashUrl.ts deleted file mode 100644 index 64b5aa1408e..00000000000 --- a/app/core/DeeplinkManager/handlers/legacy/handleDepositCashUrl.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from '../../../../components/UI/Ramp/Deposit/deeplink/handleDepositUrl'; diff --git a/app/core/DeeplinkManager/handlers/legacy/handleMetaMaskDeeplink.ts b/app/core/DeeplinkManager/handlers/legacy/handleMetaMaskDeeplink.ts index 666af0f98b5..010dde35b17 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handleMetaMaskDeeplink.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handleMetaMaskDeeplink.ts @@ -10,7 +10,6 @@ import WC2Manager from '../../../WalletConnect/WalletConnectV2'; import parseOriginatorInfo from '../../utils/parseOriginatorInfo'; import extractURLParams from '../../utils/extractURLParams'; import handleRampUrl from './handleRampUrl'; -import handleDepositCashUrl from './handleDepositCashUrl'; import { RampType } from '../../../../reducers/fiatOrders/types'; import { INTERNAL_ORIGINS } from '../../../../constants/transaction'; @@ -158,14 +157,6 @@ export function handleMetaMaskDeeplink({ rampPath, rampType: RampType.SELL, }); - } else if (url.startsWith(`${PREFIXES.METAMASK}${ACTIONS.DEPOSIT}`)) { - const depositCashPath = url.replace( - `${PREFIXES.METAMASK}${ACTIONS.DEPOSIT}`, - '', - ); - handleDepositCashUrl({ - depositPath: depositCashPath, - }); } } diff --git a/app/core/DeeplinkManager/handlers/legacy/handleRampUrl.ts b/app/core/DeeplinkManager/handlers/legacy/handleRampUrl.ts index 22b27a21c34..42016b39ff5 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handleRampUrl.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handleRampUrl.ts @@ -1 +1 @@ -export { default } from '../../../../components/UI/Ramp/Aggregator/deeplink/handleRampUrl'; +export { default } from '../../../../components/UI/Ramp/deeplink/handleRampUrl'; diff --git a/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts b/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts index e82b3472119..eaed35e2d54 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts @@ -18,7 +18,6 @@ import handleDeepLinkModalDisplay from './handleDeepLinkModalDisplay'; import handleMetaMaskDeeplink from './handleMetaMaskDeeplink'; import { capitalize } from '../../../../util/general'; import handleRampUrl from './handleRampUrl'; -import handleDepositCashUrl from './handleDepositCashUrl'; import { navigateToHomeUrl } from './handleHomeUrl'; import { handleSwapUrl } from './handleSwapUrl'; import handleBrowserUrl from './handleBrowserUrl'; @@ -68,7 +67,6 @@ const SUPPORTED_ACTIONS = { BUY_CRYPTO: ACTIONS.BUY_CRYPTO, SELL: ACTIONS.SELL, SELL_CRYPTO: ACTIONS.SELL_CRYPTO, - DEPOSIT: ACTIONS.DEPOSIT, HOME: ACTIONS.HOME, ASSET: ACTIONS.ASSET, SWAP: ACTIONS.SWAP, @@ -183,9 +181,18 @@ async function handleUniversalLink({ throw new Error('Invalid hostname'); } + const action: SUPPORTED_ACTIONS | ACTIONS.OAUTH_REDIRECT = + validatedUrl.pathname.split('/')[1] as + | SUPPORTED_ACTIONS + | ACTIONS.OAUTH_REDIRECT; + // Skip handling deeplinks that do not have a pathname or query + // Skip handling oauth-login universal links (it is handled by the OAuthService) // Ex. It's common for third party apps to open MetaMask using only the scheme (metamask://) - if (!validatedUrl.pathname.replace('/', '') && !validatedUrl.search) { + if ( + (!validatedUrl.pathname.replace('/', '') && !validatedUrl.search) || + action === ACTIONS.OAUTH_REDIRECT + ) { handled(); return; } @@ -193,10 +200,6 @@ async function handleUniversalLink({ let isPrivateLink = false; let isInvalidLink = false; - const action: SUPPORTED_ACTIONS = validatedUrl.pathname.split( - '/', - )[1] as SUPPORTED_ACTIONS; - // Intercept SDK actions and handle them in handleMetaMaskDeeplink if (METAMASK_SDK_ACTIONS.includes(action)) { const mappedUrl = url.replace( @@ -505,11 +508,6 @@ async function handleUniversalLink({ }); break; } - case SUPPORTED_ACTIONS.DEPOSIT: - handleDepositCashUrl({ - depositPath: actionBasedRampPath, - }); - break; case SUPPORTED_ACTIONS.HOME: navigateToHomeUrl({ homePath: actionBasedRampPath }); return; diff --git a/app/core/DeeplinkManager/types/deepLink.types.ts b/app/core/DeeplinkManager/types/deepLink.types.ts index e4aa317a876..d32ce8b078a 100644 --- a/app/core/DeeplinkManager/types/deepLink.types.ts +++ b/app/core/DeeplinkManager/types/deepLink.types.ts @@ -119,7 +119,6 @@ export const SUPPORTED_ACTIONS = [ ACTIONS.BUY_CRYPTO, ACTIONS.SELL, ACTIONS.SELL_CRYPTO, - ACTIONS.DEPOSIT, ACTIONS.HOME, ACTIONS.ASSET, ACTIONS.SWAP, diff --git a/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts b/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts index 2a86e1282df..9039fc65cc5 100644 --- a/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts +++ b/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts @@ -40,7 +40,6 @@ export enum DeepLinkRoute { ASSET = 'asset', SWAP = 'swap', PERPS = 'perps', - DEPOSIT = 'deposit', TRANSACTION = 'transaction', BUY = 'buy', SELL = 'sell', diff --git a/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.test.ts b/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.test.ts index 9aa29d00143..ee4486c3a1a 100644 --- a/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.test.ts +++ b/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.test.ts @@ -171,18 +171,6 @@ describe('deepLinkAnalytics', () => { expect(result.slippage).toBeUndefined(); }); - it('extracts deposit-specific properties', () => { - const result = extractSensitiveProperties( - DeepLinkRoute.DEPOSIT, - mockUrlParams, - ); - - expect(result.provider).toBe('ramp'); - expect(result.payment_method).toBe('card'); - expect(result.fiat_currency).toBe('USD'); - expect(result.fiat_quantity).toBe('100'); - }); - it('extracts transaction-specific properties', () => { const result = extractSensitiveProperties( DeepLinkRoute.TRANSACTION, @@ -424,12 +412,6 @@ describe('deepLinkAnalytics', () => { }, ); - it('maps deposit action to DEPOSIT route', () => { - const depositAction = ACTIONS.DEPOSIT; - const result = mapSupportedActionToRoute(depositAction); - expect(result).toBe(DeepLinkRoute.DEPOSIT); - }); - it('maps send action to TRANSACTION route', () => { const sendAction = ACTIONS.SEND; const result = mapSupportedActionToRoute(sendAction); @@ -497,11 +479,11 @@ describe('deepLinkAnalytics', () => { expect(result).toBe(DeepLinkRoute.PERPS); }); - it('extract deposit route', () => { + it('maps deprecated deposit path to invalid route', () => { const result = extractRouteFromUrl( 'https://link.metamask.io/deposit?provider=ramp', ); - expect(result).toBe(DeepLinkRoute.DEPOSIT); + expect(result).toBe(DeepLinkRoute.INVALID); }); it('extract transaction route', () => { diff --git a/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts b/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts index 3168a0fbfac..402c2a1cc89 100644 --- a/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts +++ b/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts @@ -190,43 +190,6 @@ const extractPerpsProperties = ( addPropertyIfExists(sensitiveProps, 'tab', getStringValue(urlParams, 'tab')); }; -/** - * Extract properties specific to DEPOSIT route - * @param urlParams - URL parameters - * @param sensitiveProps - Object to add properties to - */ -const extractDepositProperties = ( - urlParams: UrlParamValues, - sensitiveProps: Record, -): void => { - extractCommonProperties(urlParams, sensitiveProps); - addPropertyIfExists( - sensitiveProps, - 'provider', - getStringValue(urlParams, 'provider'), - ); - addPropertyIfExists( - sensitiveProps, - 'payment_method', - getStringValue(urlParams, 'payment_method'), - ); - addPropertyIfExists( - sensitiveProps, - 'sub_payment_method', - getStringValue(urlParams, 'sub_payment_method'), - ); - addPropertyIfExists( - sensitiveProps, - 'fiat_currency', - getStringValue(urlParams, 'fiat_currency'), - ); - addPropertyIfExists( - sensitiveProps, - 'fiat_quantity', - getStringValue(urlParams, 'fiat_quantity'), - ); -}; - /** * Extract properties specific to TRANSACTION route * @param urlParams - URL parameters @@ -491,7 +454,6 @@ const routeExtractors: Record< > = { [DeepLinkRoute.SWAP]: extractSwapProperties, [DeepLinkRoute.PERPS]: extractPerpsProperties, - [DeepLinkRoute.DEPOSIT]: extractDepositProperties, [DeepLinkRoute.TRANSACTION]: extractTransactionProperties, [DeepLinkRoute.BUY]: extractBuyProperties, [DeepLinkRoute.SELL]: extractSellProperties, @@ -607,8 +569,6 @@ export const mapSupportedActionToRoute = ( case ACTIONS.PERPS_MARKETS: case ACTIONS.PERPS_ASSET: return DeepLinkRoute.PERPS; - case ACTIONS.DEPOSIT: - return DeepLinkRoute.DEPOSIT; case ACTIONS.SEND: return DeepLinkRoute.TRANSACTION; case ACTIONS.BUY: @@ -666,7 +626,7 @@ export const extractRouteFromUrl = (url: string): DeepLinkRoute => { case 'perps': return DeepLinkRoute.PERPS; case 'deposit': - return DeepLinkRoute.DEPOSIT; + return DeepLinkRoute.INVALID; case 'transaction': return DeepLinkRoute.TRANSACTION; case 'buy': diff --git a/app/core/DrawerStatusTracker.js b/app/core/DrawerStatusTracker.js index 33e81701173..54ae7eb7a78 100644 --- a/app/core/DrawerStatusTracker.js +++ b/app/core/DrawerStatusTracker.js @@ -1,6 +1,6 @@ 'use strict'; -// eslint-disable-next-line import/no-nodejs-modules +// eslint-disable-next-line import-x/no-nodejs-modules import { EventEmitter } from 'events'; const hub = new EventEmitter(); diff --git a/app/core/Engine/Engine.test.ts b/app/core/Engine/Engine.test.ts index ccc39500783..5edecb14061 100644 --- a/app/core/Engine/Engine.test.ts +++ b/app/core/Engine/Engine.test.ts @@ -1221,10 +1221,11 @@ describe('Engine', () => { Engine.init(TEST_ANALYTICS_ID, {}); const controllersWithState = Object.entries(Engine.context) .filter( - ([_, controller]) => + ([controllerName, controller]) => 'state' in controller && Boolean(controller.state) && - !isEmpty(controller.state), + (!isEmpty(controller.state) || + controllerName === 'ComplianceController'), ) .map(([controllerName]) => controllerName); diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 4438d373f8b..cef7f9339da 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -181,6 +181,8 @@ import { rampsControllerInit } from './controllers/ramps-controller/ramps-contro import { aiDigestControllerInit } from './controllers/ai-digest-controller-init'; import { cardControllerInit } from './controllers/card-controller'; import { transakServiceInit } from './controllers/ramps-controller/transak-service-init'; +import { complianceServiceInit } from './controllers/compliance/compliance-service-init'; +import { complianceControllerInit } from './controllers/compliance/compliance-controller-init'; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -377,6 +379,8 @@ export class Engine { RampsController: rampsControllerInit, AiDigestController: aiDigestControllerInit, CardController: cardControllerInit, + ComplianceService: complianceServiceInit, + ComplianceController: complianceControllerInit, }, persistedState: initialState as EngineState, baseControllerMessenger: this.controllerMessenger, @@ -419,6 +423,8 @@ export class Engine { const rampsController = controllersByName.RampsController; const aiDigestController = controllersByName.AiDigestController; const cardController = controllersByName.CardController; + const complianceService = controllersByName.ComplianceService; + const complianceController = controllersByName.ComplianceController; // Backwards compatibility for existing references this.accountsController = accountsController; @@ -579,6 +585,8 @@ export class Engine { RampsController: rampsController, AiDigestController: aiDigestController, CardController: cardController, + ComplianceService: complianceService, + ComplianceController: complianceController, }; const childControllers = Object.assign({}, this.context); @@ -1167,12 +1175,12 @@ export class Engine { ) { const { ApprovalController } = this.context; - if (opts.ignoreMissing && !ApprovalController.has({ id })) { + if (opts.ignoreMissing && !ApprovalController.hasRequest({ id })) { return; } try { - ApprovalController.reject(id, reason); + ApprovalController.rejectRequest(id, reason); // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { @@ -1197,7 +1205,7 @@ export class Engine { const { ApprovalController } = this.context; try { - return await ApprovalController.accept(id, requestData, { + return await ApprovalController.acceptRequest(id, requestData, { waitForResult: opts.waitForResult, deleteAfterResult: opts.deleteAfterResult, }); @@ -1341,6 +1349,7 @@ export default { TransactionPayController, RampsController, AiDigestController, + ComplianceController, ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) AuthenticationController, CronjobController, @@ -1412,6 +1421,7 @@ export default { RampsController: RampsController.state, AiDigestController: AiDigestController.state, CardController: CardController.state, + ComplianceController: ComplianceController.state, ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) AuthenticationController: AuthenticationController.state, CronjobController: CronjobController.state, diff --git a/app/core/Engine/constants.ts b/app/core/Engine/constants.ts index ce7a9cbdd3a..242f5afd475 100644 --- a/app/core/Engine/constants.ts +++ b/app/core/Engine/constants.ts @@ -19,6 +19,7 @@ export const STATELESS_NON_CONTROLLER_NAMES = [ 'ProfileMetricsService', 'RampsService', 'TransakService', + 'ComplianceService', ] as const; export const BACKGROUND_STATE_CHANGE_EVENT_NAMES = [ @@ -88,6 +89,7 @@ export const BACKGROUND_STATE_CHANGE_EVENT_NAMES = [ 'CardController:stateChange', 'DelegationController:stateChange', 'ProfileMetricsController:stateChange', + 'ComplianceController:stateChange', ] as const; export const swapsSupportedChainIds = [ diff --git a/app/core/Engine/controllers/accounts-controller/constants.ts b/app/core/Engine/controllers/accounts-controller/constants.ts index a54b1ad8c87..9af99929c2c 100644 --- a/app/core/Engine/controllers/accounts-controller/constants.ts +++ b/app/core/Engine/controllers/accounts-controller/constants.ts @@ -6,4 +6,5 @@ export const defaultAccountsControllerState: AccountsControllerState = { accounts: {}, selectedAccount: '', }, + accountIdByAddress: {}, }; diff --git a/app/core/Engine/controllers/card-controller/CardController.test.ts b/app/core/Engine/controllers/card-controller/CardController.test.ts index b13b98ef09b..bb2ab718064 100644 --- a/app/core/Engine/controllers/card-controller/CardController.test.ts +++ b/app/core/Engine/controllers/card-controller/CardController.test.ts @@ -1,6 +1,19 @@ import { Messenger } from '@metamask/messenger'; import { CardController, defaultCardControllerState } from './CardController'; import { type CardControllerActions, type CardControllerEvents } from './types'; +import { + CardProviderError, + CardProviderErrorCode, + type ICardProvider, + type CardAuthSession, + type CardAuthTokens, +} from './provider-types'; +import { CardTokenStore } from './CardTokenStore'; + +jest.mock('./CardTokenStore'); +jest.mock('../../../../util/Logger'); + +const mockTokenStore = CardTokenStore as jest.Mocked; function buildMessenger() { return new Messenger< @@ -10,10 +23,64 @@ function buildMessenger() { >({ namespace: 'CardController' }); } +function buildMockProvider( + overrides: Partial = {}, +): jest.Mocked { + return { + id: 'baanx', + capabilities: {} as ICardProvider['capabilities'], + initiateAuth: jest.fn(), + submitCredentials: jest.fn(), + executeStepAction: jest.fn(), + refreshTokens: jest.fn(), + validateTokens: jest.fn(), + logout: jest.fn(), + getCardHomeData: jest.fn(), + getCardDetails: jest.fn(), + freezeCard: jest.fn(), + unfreezeCard: jest.fn(), + ...overrides, + } as jest.Mocked; +} + +function buildController( + provider: ICardProvider, + stateOverrides: Partial = {}, +) { + return new CardController({ + messenger: buildMessenger(), + providers: { baanx: provider }, + state: { + activeProviderId: 'baanx', + ...stateOverrides, + }, + }); +} + +const mockSession: CardAuthSession = { + id: 'session-1', + currentStep: { type: 'email_password' }, + _metadata: { + initiateToken: 'tok', + location: 'international', + state: 's', + codeVerifier: 'cv', + }, +}; + +const mockTokenSet: CardAuthTokens = { + accessToken: 'at', + refreshToken: 'rt', + accessTokenExpiresAt: Date.now() + 3_600_000, + refreshTokenExpiresAt: Date.now() + 86_400_000, + location: 'international', +}; + describe('CardController', () => { it('initializes with default state when no state is provided', () => { const controller = new CardController({ messenger: buildMessenger(), + providers: {}, }); expect(controller.state).toStrictEqual(defaultCardControllerState); @@ -22,6 +89,7 @@ describe('CardController', () => { it('initializes with provided state merged over defaults', () => { const controller = new CardController({ messenger: buildMessenger(), + providers: {}, state: { selectedCountry: 'US', activeProviderId: 'baanx', @@ -41,13 +109,14 @@ describe('CardController', () => { it('preserves default values for fields not in partial state', () => { const controller = new CardController({ messenger: buildMessenger(), + providers: {}, state: { selectedCountry: 'GB', }, }); expect(controller.state.selectedCountry).toBe('GB'); - expect(controller.state.activeProviderId).toBeNull(); + expect(controller.state.activeProviderId).toBe('baanx'); expect(controller.state.isAuthenticated).toBe(false); expect(controller.state.cardholderAccounts).toStrictEqual([]); expect(controller.state.providerData).toStrictEqual({}); @@ -56,6 +125,7 @@ describe('CardController', () => { it('initializes with full persisted state including providerData', () => { const controller = new CardController({ messenger: buildMessenger(), + providers: {}, state: { selectedCountry: 'US', activeProviderId: 'baanx', @@ -75,3 +145,293 @@ describe('CardController', () => { }); }); }); + +describe('CardController — auth methods', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('initiateAuth', () => { + it('delegates to the active provider and stores the session internally', async () => { + const provider = buildMockProvider(); + provider.initiateAuth.mockResolvedValue(mockSession); + const controller = buildController(provider); + + await controller.initiateAuth('US'); + + expect(provider.initiateAuth).toHaveBeenCalledWith('US'); + expect(controller.getCurrentAuthStep()).toStrictEqual( + mockSession.currentStep, + ); + }); + + it('throws CardProviderError when there is no active provider', async () => { + const controller = new CardController({ + messenger: buildMessenger(), + providers: {}, + state: { activeProviderId: null }, + }); + + await expect(controller.initiateAuth('US')).rejects.toBeInstanceOf( + CardProviderError, + ); + }); + }); + + describe('submitCredentials', () => { + it('throws when no session has been initiated', async () => { + const provider = buildMockProvider(); + const controller = buildController(provider); + + await expect( + controller.submitCredentials({ + type: 'email_password', + email: 'a@b.com', + password: 'pass', + }), + ).rejects.toMatchObject({ code: CardProviderErrorCode.Unknown }); + }); + + it('stores tokens, sets isAuthenticated and providerData on done:true', async () => { + const provider = buildMockProvider(); + provider.initiateAuth.mockResolvedValue(mockSession); + provider.submitCredentials.mockResolvedValue({ + done: true, + tokenSet: mockTokenSet, + }); + mockTokenStore.set.mockResolvedValue(true); + const controller = buildController(provider); + + await controller.initiateAuth('US'); + const result = await controller.submitCredentials({ + type: 'email_password', + email: 'a@b.com', + password: 'pass', + }); + + expect(mockTokenStore.set).toHaveBeenCalledWith('baanx', mockTokenSet); + expect(controller.state.isAuthenticated).toBe(true); + expect(controller.state.providerData.baanx).toStrictEqual({ + location: 'international', + }); + expect(result.done).toBe(true); + }); + + it('still sets isAuthenticated when token store write fails', async () => { + const provider = buildMockProvider(); + provider.initiateAuth.mockResolvedValue(mockSession); + provider.submitCredentials.mockResolvedValue({ + done: true, + tokenSet: mockTokenSet, + }); + mockTokenStore.set.mockResolvedValue(false); + const controller = buildController(provider); + + await controller.initiateAuth('US'); + await controller.submitCredentials({ + type: 'email_password', + email: 'a@b.com', + password: 'pass', + }); + + expect(controller.state.isAuthenticated).toBe(true); + }); + + it('updates currentSession step when OTP step is required', async () => { + const provider = buildMockProvider(); + provider.initiateAuth.mockResolvedValue(mockSession); + provider.submitCredentials.mockResolvedValue({ + done: false, + nextStep: { type: 'otp', destination: '+1555****90' }, + }); + const controller = buildController(provider); + + await controller.initiateAuth('US'); + const result = await controller.submitCredentials({ + type: 'email_password', + email: 'a@b.com', + password: 'pass', + }); + + expect(controller.state.isAuthenticated).toBe(false); + expect(result.done).toBe(false); + expect(controller.getCurrentAuthStep()).toStrictEqual({ + type: 'otp', + destination: '+1555****90', + }); + }); + + it('clears session when onboarding is required', async () => { + const provider = buildMockProvider(); + provider.initiateAuth.mockResolvedValue(mockSession); + provider.submitCredentials.mockResolvedValue({ + done: false, + onboardingRequired: { sessionId: 'ob-session', phase: 'kyc' }, + }); + const controller = buildController(provider); + + await controller.initiateAuth('US'); + const result = await controller.submitCredentials({ + type: 'email_password', + email: 'a@b.com', + password: 'pass', + }); + + expect(controller.state.isAuthenticated).toBe(false); + expect(result.onboardingRequired?.phase).toBe('kyc'); + expect(controller.getCurrentAuthStep()).toBeNull(); + }); + }); + + describe('executeStepAction', () => { + it('throws when no session has been initiated', async () => { + const provider = buildMockProvider(); + const controller = buildController(provider); + + await expect(controller.executeStepAction()).rejects.toMatchObject({ + code: CardProviderErrorCode.Unknown, + }); + }); + + it('delegates to the provider with the current session', async () => { + const provider = buildMockProvider(); + provider.initiateAuth.mockResolvedValue(mockSession); + (provider.executeStepAction as jest.Mock).mockResolvedValue(undefined); + const controller = buildController(provider); + + await controller.initiateAuth('US'); + await controller.executeStepAction(); + + expect(provider.executeStepAction).toHaveBeenCalledWith(mockSession); + }); + + it('is a no-op when the provider does not implement executeStepAction', async () => { + const provider = buildMockProvider({ executeStepAction: undefined }); + provider.initiateAuth.mockResolvedValue(mockSession); + const controller = buildController(provider); + + await controller.initiateAuth('US'); + await expect(controller.executeStepAction()).resolves.toBeUndefined(); + }); + }); + + describe('logout', () => { + it('calls provider.logout, removes tokens, sets isAuthenticated to false', async () => { + const provider = buildMockProvider(); + provider.logout.mockResolvedValue(undefined); + mockTokenStore.get.mockResolvedValue(mockTokenSet); + mockTokenStore.remove.mockResolvedValue(true); + const controller = buildController(provider, { isAuthenticated: true }); + + await controller.logout(); + + expect(provider.logout).toHaveBeenCalledWith(mockTokenSet); + expect(mockTokenStore.remove).toHaveBeenCalledWith('baanx'); + expect(controller.state.isAuthenticated).toBe(false); + }); + + it('still clears local state when provider.logout throws', async () => { + const provider = buildMockProvider(); + provider.logout.mockRejectedValue(new Error('Server error')); + mockTokenStore.get.mockResolvedValue(mockTokenSet); + mockTokenStore.remove.mockResolvedValue(true); + const controller = buildController(provider, { isAuthenticated: true }); + + await controller.logout(); + + expect(mockTokenStore.remove).toHaveBeenCalledWith('baanx'); + expect(controller.state.isAuthenticated).toBe(false); + }); + + it('skips provider.logout call when no tokens exist', async () => { + const provider = buildMockProvider(); + mockTokenStore.get.mockResolvedValue(null); + mockTokenStore.remove.mockResolvedValue(true); + const controller = buildController(provider); + + await controller.logout(); + + expect(provider.logout).not.toHaveBeenCalled(); + expect(mockTokenStore.remove).toHaveBeenCalledWith('baanx'); + }); + }); + + describe('validateAndRefreshSession', () => { + it('returns isAuthenticated:false when no tokens exist', async () => { + const provider = buildMockProvider(); + mockTokenStore.get.mockResolvedValue(null); + const controller = buildController(provider); + + const result = await controller.validateAndRefreshSession(); + + expect(result).toStrictEqual({ isAuthenticated: false }); + expect(controller.state.isAuthenticated).toBe(false); + }); + + it('returns isAuthenticated:true with location when tokens are valid', async () => { + const provider = buildMockProvider(); + mockTokenStore.get.mockResolvedValue(mockTokenSet); + provider.validateTokens.mockReturnValue('valid'); + const controller = buildController(provider); + + const result = await controller.validateAndRefreshSession(); + + expect(result).toStrictEqual({ + isAuthenticated: true, + location: 'international', + }); + expect(controller.state.isAuthenticated).toBe(true); + }); + + it('refreshes tokens and returns authenticated when needs_refresh', async () => { + const provider = buildMockProvider(); + const refreshedTokens: CardAuthTokens = { + ...mockTokenSet, + accessToken: 'new-at', + }; + mockTokenStore.get.mockResolvedValue(mockTokenSet); + provider.validateTokens.mockReturnValue('needs_refresh'); + provider.refreshTokens.mockResolvedValue(refreshedTokens); + mockTokenStore.set.mockResolvedValue(true); + const controller = buildController(provider); + + const result = await controller.validateAndRefreshSession(); + + expect(provider.refreshTokens).toHaveBeenCalledWith(mockTokenSet); + expect(mockTokenStore.set).toHaveBeenCalledWith('baanx', refreshedTokens); + expect(result).toStrictEqual({ + isAuthenticated: true, + location: 'international', + }); + }); + + it('clears tokens and returns unauthenticated when refresh fails', async () => { + const provider = buildMockProvider(); + mockTokenStore.get.mockResolvedValue(mockTokenSet); + provider.validateTokens.mockReturnValue('needs_refresh'); + provider.refreshTokens.mockRejectedValue(new Error('Refresh failed')); + mockTokenStore.remove.mockResolvedValue(true); + const controller = buildController(provider); + + const result = await controller.validateAndRefreshSession(); + + expect(mockTokenStore.remove).toHaveBeenCalledWith('baanx'); + expect(result).toStrictEqual({ isAuthenticated: false }); + expect(controller.state.isAuthenticated).toBe(false); + }); + + it('clears tokens and returns unauthenticated when tokens are expired', async () => { + const provider = buildMockProvider(); + mockTokenStore.get.mockResolvedValue(mockTokenSet); + provider.validateTokens.mockReturnValue('expired'); + mockTokenStore.remove.mockResolvedValue(true); + const controller = buildController(provider); + + const result = await controller.validateAndRefreshSession(); + + expect(mockTokenStore.remove).toHaveBeenCalledWith('baanx'); + expect(result).toStrictEqual({ isAuthenticated: false }); + expect(controller.state.isAuthenticated).toBe(false); + }); + }); +}); diff --git a/app/core/Engine/controllers/card-controller/CardController.ts b/app/core/Engine/controllers/card-controller/CardController.ts index 4095077945a..680873d39c5 100644 --- a/app/core/Engine/controllers/card-controller/CardController.ts +++ b/app/core/Engine/controllers/card-controller/CardController.ts @@ -1,9 +1,20 @@ import { BaseController, type StateMetadata } from '@metamask/base-controller'; +import Logger from '../../../../util/Logger'; import { CARD_CONTROLLER_NAME, type CardControllerMessenger, type CardControllerState, } from './types'; +import { + CardProviderError, + CardProviderErrorCode, + type CardAuthSession, + type CardAuthResult, + type CardAuthStep, + type CardCredentials, + type ICardProvider, +} from './provider-types'; +import { CardTokenStore } from './CardTokenStore'; const metadata: StateMetadata = { selectedCountry: { @@ -40,7 +51,7 @@ const metadata: StateMetadata = { export const defaultCardControllerState: CardControllerState = { selectedCountry: null, - activeProviderId: null, + activeProviderId: 'baanx', isAuthenticated: false, cardholderAccounts: [], providerData: {}, @@ -59,12 +70,17 @@ export class CardController extends BaseController< CardControllerState, CardControllerMessenger > { + private readonly providers: Record; + private currentSession: CardAuthSession | null = null; + constructor({ messenger, state, + providers, }: { messenger: CardControllerMessenger; state?: Partial; + providers: Record; }) { super({ name: CARD_CONTROLLER_NAME, @@ -75,5 +91,181 @@ export class CardController extends BaseController< ...state, }, }); + this.providers = providers; + } + + private getActiveProvider(): ICardProvider { + const pid = this.state.activeProviderId; + const provider = pid ? this.providers[pid] : undefined; + if (!provider) { + throw new CardProviderError( + CardProviderErrorCode.Unknown, + `No active provider: ${pid}`, + ); + } + return provider; + } + + private markAuthenticated(): void { + this.update((s) => { + s.isAuthenticated = true; + }); + } + + private markUnauthenticated(): void { + this.update((s) => { + s.isAuthenticated = false; + }); + } + + private async clearTokens(): Promise { + const pid = this.state.activeProviderId; + if (pid) { + await CardTokenStore.remove(pid); + } + } + + async initiateAuth(country: string): Promise { + this.currentSession = await this.getActiveProvider().initiateAuth(country); + } + + getCurrentAuthStep(): CardAuthStep | null { + return this.currentSession?.currentStep ?? null; + } + + async submitCredentials( + credentials: CardCredentials, + ): Promise { + if (!this.currentSession) { + throw new CardProviderError( + CardProviderErrorCode.Unknown, + 'submitCredentials: no active auth session', + ); + } + + const provider = this.getActiveProvider(); + const pid = this.state.activeProviderId as string; + const result = await provider.submitCredentials( + this.currentSession, + credentials, + ); + + if (result.nextStep) { + this.currentSession = { + ...this.currentSession, + currentStep: result.nextStep, + }; + } else { + this.currentSession = null; + } + + if (result.done && result.tokenSet) { + const { tokenSet } = result; + const stored = await CardTokenStore.set(pid, tokenSet); + if (!stored) { + Logger.error(new Error('Token store write failed after auth'), { + tags: { feature: 'card', provider: pid }, + context: { + name: 'CardController', + data: { method: 'submitCredentials' }, + }, + }); + } + this.update((s) => { + s.isAuthenticated = true; + (s.providerData as unknown as Record>)[ + pid + ] = { location: tokenSet.location }; + }); + } + + return result; + } + + async executeStepAction(): Promise { + if (!this.currentSession) { + throw new CardProviderError( + CardProviderErrorCode.Unknown, + 'executeStepAction: no active auth session', + ); + } + const provider = this.getActiveProvider(); + await provider.executeStepAction?.(this.currentSession); + } + + async logout(): Promise { + const pid = this.state.activeProviderId; + if (!pid) return; + const tokens = await CardTokenStore.get(pid); + + if (tokens) { + try { + await this.getActiveProvider().logout(tokens); + } catch (error) { + Logger.error(error as Error, { + tags: { feature: 'card', provider: pid }, + context: { name: 'CardController', data: { method: 'logout' } }, + }); + } + } + + this.currentSession = null; + await this.clearTokens(); + this.update((s) => { + s.isAuthenticated = false; + (s.providerData as unknown as Record>)[ + pid + ] = {}; + }); + } + + async validateAndRefreshSession(): Promise<{ + isAuthenticated: boolean; + location?: string; + }> { + const pid = this.state.activeProviderId; + if (!pid) { + this.markUnauthenticated(); + return { isAuthenticated: false }; + } + const tokens = await CardTokenStore.get(pid); + + if (!tokens) { + this.markUnauthenticated(); + return { isAuthenticated: false }; + } + + const provider = this.getActiveProvider(); + const validity = provider.validateTokens(tokens); + + if (validity === 'valid') { + this.markAuthenticated(); + return { isAuthenticated: true, location: tokens.location }; + } + + if (validity === 'needs_refresh') { + try { + const newTokens = await provider.refreshTokens(tokens); + await CardTokenStore.set(pid, newTokens); + this.markAuthenticated(); + return { isAuthenticated: true, location: newTokens.location }; + } catch (error) { + Logger.error(error as Error, { + tags: { feature: 'card', provider: pid }, + context: { + name: 'CardController', + data: { method: 'validateAndRefreshSession' }, + }, + }); + await this.clearTokens(); + this.markUnauthenticated(); + return { isAuthenticated: false }; + } + } + + // expired + await this.clearTokens(); + this.markUnauthenticated(); + return { isAuthenticated: false }; } } diff --git a/app/core/Engine/controllers/card-controller/index.ts b/app/core/Engine/controllers/card-controller/index.ts index f5b1d1f04ce..a81cb60f33b 100644 --- a/app/core/Engine/controllers/card-controller/index.ts +++ b/app/core/Engine/controllers/card-controller/index.ts @@ -1,6 +1,9 @@ import type { ControllerInitFunction } from '../../types'; import { CardController, defaultCardControllerState } from './CardController'; import type { CardControllerMessenger } from './types'; +import { BaanxService } from './services/BaanxService'; +import { BaanxProvider } from './providers/BaanxProvider'; +import { resolveBaanxConfig } from './services/baanx-config'; /** * Initialize the CardController. @@ -14,9 +17,18 @@ export const cardControllerInit: ControllerInitFunction< > = (request) => { const { controllerMessenger, persistedState } = request; + const baanxConfig = resolveBaanxConfig(); + const baanxProvider = new BaanxProvider({ + service: new BaanxService(baanxConfig), + }); + const controller = new CardController({ messenger: controllerMessenger, - state: persistedState.CardController ?? defaultCardControllerState, + state: { + ...(persistedState.CardController ?? defaultCardControllerState), + activeProviderId: 'baanx', + }, + providers: { baanx: baanxProvider }, }); return { controller }; diff --git a/app/core/Engine/controllers/card-controller/provider-types.ts b/app/core/Engine/controllers/card-controller/provider-types.ts index 6704f11676c..9a636242835 100644 --- a/app/core/Engine/controllers/card-controller/provider-types.ts +++ b/app/core/Engine/controllers/card-controller/provider-types.ts @@ -275,7 +275,7 @@ export interface ICardProvider { session: CardAuthSession, credentials: CardCredentials, ): Promise; - sendOtp?(session: CardAuthSession): Promise; + executeStepAction?(session: CardAuthSession): Promise; refreshTokens(tokens: CardAuthTokens): Promise; validateTokens(tokens: CardAuthTokens): AuthTokenValidity; logout(tokens: CardAuthTokens): Promise; diff --git a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts index 52145ea8779..5f9e9ead476 100644 --- a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts +++ b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts @@ -728,7 +728,7 @@ describe('BaanxProvider', () => { }); }); - describe('sendOtp', () => { + describe('executeStepAction', () => { it('posts userId to the OTP trigger endpoint', async () => { service.get.mockResolvedValue({ token: 'init-token', url: '' }); const session = await provider.initiateAuth('US'); @@ -736,7 +736,7 @@ describe('BaanxProvider', () => { session._metadata.otpUserId = 'user-1'; service.post.mockResolvedValue({}); - await provider.sendOtp(session); + await provider.executeStepAction(session); expect(service.post).toHaveBeenCalledWith('/v1/auth/login/otp', { userId: 'user-1', @@ -747,8 +747,8 @@ describe('BaanxProvider', () => { service.get.mockResolvedValue({ token: 'init-token', url: '' }); const session = await provider.initiateAuth('US'); - await expect(provider.sendOtp(session)).rejects.toThrow( - 'No userId in session', + await expect(provider.executeStepAction(session)).rejects.toThrow( + 'executeStepAction: session missing otpUserId', ); }); }); diff --git a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts index bf493425dc4..04df1557cc1 100644 --- a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts +++ b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts @@ -270,11 +270,12 @@ export class BaanxProvider implements ICardProvider { throw new Error(`Unsupported credential type: ${credentials.type}`); } - async sendOtp(session: CardAuthSession): Promise { + async executeStepAction(session: CardAuthSession): Promise { const userId = session._metadata.otpUserId as string | undefined; if (!userId) { - throw new Error( - 'No userId in session — initiateAuth or login must be called first', + throw new CardProviderError( + CardProviderErrorCode.Unknown, + 'executeStepAction: session missing otpUserId', ); } await this.service.post('/v1/auth/login/otp', { userId }); diff --git a/app/core/Engine/controllers/card-controller/services/BaanxService.test.ts b/app/core/Engine/controllers/card-controller/services/BaanxService.test.ts index eadb748feb9..fbe29517fbb 100644 --- a/app/core/Engine/controllers/card-controller/services/BaanxService.test.ts +++ b/app/core/Engine/controllers/card-controller/services/BaanxService.test.ts @@ -172,4 +172,74 @@ describe('BaanxService', () => { expect(service.location).toBe('us'); }); }); + + describe('per-request location override', () => { + it('uses x-us-env:true when location:us is passed to get(), regardless of currentLocation', async () => { + mockRequest.mockResolvedValue({ data: {} }); + const service = createService(); + // currentLocation is 'international' (default) + + await service.get('/v1/test', undefined, 'us'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ 'x-us-env': 'true' }), + }), + ); + }); + + it('uses x-us-env:false when location:international is passed to get(), even after setLocation(us)', async () => { + mockRequest.mockResolvedValue({ data: {} }); + const service = createService(); + service.setLocation('us'); + + await service.get('/v1/test', undefined, 'international'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ 'x-us-env': 'false' }), + }), + ); + }); + + it('falls back to currentLocation when no per-request location is given', async () => { + mockRequest.mockResolvedValue({ data: {} }); + const service = createService(); + service.setLocation('us'); + + await service.get('/v1/test'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ 'x-us-env': 'true' }), + }), + ); + }); + + it('post() threads per-request location through correctly', async () => { + mockRequest.mockResolvedValue({ data: {} }); + const service = createService(); + + await service.post('/v1/test', {}, undefined, 'us'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ 'x-us-env': 'true' }), + }), + ); + }); + + it('put() threads per-request location through correctly', async () => { + mockRequest.mockResolvedValue({ data: {} }); + const service = createService(); + + await service.put('/v1/test', {}, undefined, 'us'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ 'x-us-env': 'true' }), + }), + ); + }); + }); }); diff --git a/app/core/Engine/controllers/card-controller/services/BaanxService.ts b/app/core/Engine/controllers/card-controller/services/BaanxService.ts index 1d238cb75a1..66c0daba947 100644 --- a/app/core/Engine/controllers/card-controller/services/BaanxService.ts +++ b/app/core/Engine/controllers/card-controller/services/BaanxService.ts @@ -25,6 +25,7 @@ interface RequestOptions { tokenSet?: CardAuthTokens; timeout?: number; headers?: Record; + location?: CardLocation; } export class BaanxService { @@ -57,8 +58,9 @@ export class BaanxService { } async request(path: string, opts: RequestOptions = {}): Promise { + const effectiveLocation = opts.location ?? this.currentLocation; const headers: Record = { - 'x-us-env': String(this.currentLocation === 'us'), + 'x-us-env': String(effectiveLocation === 'us'), ...opts.headers, }; @@ -108,23 +110,29 @@ export class BaanxService { } } - async get(path: string, tokenSet?: CardAuthTokens): Promise { - return this.request(path, { tokenSet }); + async get( + path: string, + tokenSet?: CardAuthTokens, + location?: CardLocation, + ): Promise { + return this.request(path, { tokenSet, location }); } async post( path: string, body: unknown, tokenSet?: CardAuthTokens, + location?: CardLocation, ): Promise { - return this.request(path, { method: 'POST', body, tokenSet }); + return this.request(path, { method: 'POST', body, tokenSet, location }); } async put( path: string, body: unknown, tokenSet?: CardAuthTokens, + location?: CardLocation, ): Promise { - return this.request(path, { method: 'PUT', body, tokenSet }); + return this.request(path, { method: 'PUT', body, tokenSet, location }); } } diff --git a/app/core/Engine/controllers/card-controller/services/baanx-config.test.ts b/app/core/Engine/controllers/card-controller/services/baanx-config.test.ts index eab8bdb6325..cabe31acdb4 100644 --- a/app/core/Engine/controllers/card-controller/services/baanx-config.test.ts +++ b/app/core/Engine/controllers/card-controller/services/baanx-config.test.ts @@ -29,7 +29,21 @@ describe('resolveBaanxConfig', () => { }); describe('baseUrl', () => { - it('delegates to getDefaultBaanxApiBaseUrlForMetaMaskEnv', () => { + beforeEach(() => { + (getDefaultBaanxApiBaseUrlForMetaMaskEnv as jest.Mock).mockClear(); + }); + + it('uses BAANX_API_URL directly when set', () => { + process.env.BAANX_API_URL = 'https://override-url'; + + const config = resolveBaanxConfig(); + + expect(config.baseUrl).toBe('https://override-url'); + expect(getDefaultBaanxApiBaseUrlForMetaMaskEnv).not.toHaveBeenCalled(); + }); + + it('delegates to getDefaultBaanxApiBaseUrlForMetaMaskEnv when BAANX_API_URL is not set', () => { + delete process.env.BAANX_API_URL; process.env.METAMASK_ENVIRONMENT = 'dev'; const config = resolveBaanxConfig(); diff --git a/app/core/Engine/controllers/card-controller/services/baanx-config.ts b/app/core/Engine/controllers/card-controller/services/baanx-config.ts index b8dbc4f7675..c31bf35e071 100644 --- a/app/core/Engine/controllers/card-controller/services/baanx-config.ts +++ b/app/core/Engine/controllers/card-controller/services/baanx-config.ts @@ -13,8 +13,8 @@ import type { CardProviderConfig } from '../provider-config'; export function resolveBaanxConfig(): CardProviderConfig { return { apiKey: process.env.MM_CARD_BAANX_API_CLIENT_KEY ?? '', - baseUrl: getDefaultBaanxApiBaseUrlForMetaMaskEnv( - process.env.METAMASK_ENVIRONMENT, - ), + baseUrl: + process.env.BAANX_API_URL || + getDefaultBaanxApiBaseUrlForMetaMaskEnv(process.env.METAMASK_ENVIRONMENT), }; } diff --git a/app/core/Engine/controllers/compliance/compliance-controller-init.test.ts b/app/core/Engine/controllers/compliance/compliance-controller-init.test.ts new file mode 100644 index 00000000000..b0474864caf --- /dev/null +++ b/app/core/Engine/controllers/compliance/compliance-controller-init.test.ts @@ -0,0 +1,92 @@ +import { buildControllerInitRequestMock } from '../../utils/test-utils'; +import { ExtendedMessenger } from '../../../ExtendedMessenger'; +import { + getComplianceControllerMessenger, + getComplianceControllerInitMessenger, + ComplianceControllerInitMessenger, +} from '../../messengers/compliance/compliance-controller-messenger'; +import { ControllerInitRequest } from '../../types'; +import { complianceControllerInit } from './compliance-controller-init'; +import { + ComplianceController, + type ComplianceControllerMessenger, +} from '@metamask/compliance-controller'; +import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('99.0.0'), +})); + +function buildComplianceFlag(enabled: boolean) { + return { enabled, minimumVersion: '0.0.0' }; +} + +function getInitRequestMock( + overrides: { + complianceEnabled?: boolean; + persistedState?: Record; + } = {}, +): jest.Mocked< + ControllerInitRequest< + ComplianceControllerMessenger, + ComplianceControllerInitMessenger + > +> { + const { complianceEnabled = false, persistedState = {} } = overrides; + + const baseMessenger = new ExtendedMessenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + baseMessenger.registerActionHandler( + // @ts-expect-error: Partial mock for feature flag state + 'RemoteFeatureFlagController:getState', + () => ({ + remoteFeatureFlags: { + complianceEnabled: buildComplianceFlag(complianceEnabled), + }, + }), + ); + + const requestMock = { + ...buildControllerInitRequestMock(baseMessenger), + controllerMessenger: getComplianceControllerMessenger(baseMessenger), + initMessenger: getComplianceControllerInitMessenger(baseMessenger), + persistedState, + }; + + return requestMock; +} + +describe('complianceControllerInit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('instantiates the ComplianceController', () => { + const { controller } = complianceControllerInit(getInitRequestMock()); + expect(controller).toBeInstanceOf(ComplianceController); + }); + + it('calls init() when complianceEnabled feature flag is true', () => { + const initSpy = jest + .spyOn(ComplianceController.prototype, 'init') + .mockResolvedValue(undefined); + + complianceControllerInit(getInitRequestMock({ complianceEnabled: true })); + + expect(initSpy).toHaveBeenCalled(); + initSpy.mockRestore(); + }); + + it('does not call init() when complianceEnabled feature flag is false', () => { + const initSpy = jest + .spyOn(ComplianceController.prototype, 'init') + .mockResolvedValue(undefined); + + complianceControllerInit(getInitRequestMock({ complianceEnabled: false })); + + expect(initSpy).not.toHaveBeenCalled(); + initSpy.mockRestore(); + }); +}); diff --git a/app/core/Engine/controllers/compliance/compliance-controller-init.ts b/app/core/Engine/controllers/compliance/compliance-controller-init.ts new file mode 100644 index 00000000000..89667c5351c --- /dev/null +++ b/app/core/Engine/controllers/compliance/compliance-controller-init.ts @@ -0,0 +1,66 @@ +import { + ComplianceController, + type ComplianceControllerMessenger, +} from '@metamask/compliance-controller'; +import type { ControllerInitFunction } from '../../types'; +import type { ComplianceControllerInitMessenger } from '../../messengers/compliance/compliance-controller-messenger'; +import { FeatureFlagNames } from '../../../../constants/featureFlags'; +import Logger from '../../../../util/Logger'; +import { validatedVersionGatedFeatureFlag } from '../../../../util/remoteFeatureFlag'; + +/** + * Initialize the ComplianceController. + * + * The controller is always instantiated so its state slot exists, but + * `init()` (which fetches the blocked wallets list) only runs when the + * `complianceEnabled` feature flag is true. + * + * @param request - The request object. + * @param request.controllerMessenger - The messenger for the controller. + * @param request.initMessenger - The messenger for reading feature flags. + * @param request.persistedState - Persisted state to hydrate from. + * @returns The initialized ComplianceController. + */ +export const complianceControllerInit: ControllerInitFunction< + ComplianceController, + ComplianceControllerMessenger, + ComplianceControllerInitMessenger +> = ({ controllerMessenger, initMessenger, persistedState }) => { + const controller = new ComplianceController({ + messenger: controllerMessenger, + state: persistedState.ComplianceController, + }); + + const isComplianceEnabled = (): boolean => { + const remoteState = initMessenger.call( + 'RemoteFeatureFlagController:getState', + ); + const localOverride = + remoteState?.localOverrides?.[FeatureFlagNames.complianceEnabled]; + if (localOverride !== undefined) { + return Boolean(localOverride); + } + + const remoteFlag = + remoteState?.remoteFeatureFlags?.[FeatureFlagNames.complianceEnabled]; + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; + }; + + if (isComplianceEnabled()) { + controller + .init() + .then(() => { + Logger.log('ComplianceController initialized'); + }) + .catch((error: unknown) => + Logger.error( + error instanceof Error ? error : new Error(String(error)), + 'ComplianceController init failed — sanctions blocklist may be empty', + ), + ); + } else { + Logger.log('ComplianceController disabled via feature flag'); + } + + return { controller }; +}; diff --git a/app/core/Engine/controllers/compliance/compliance-service-init.test.ts b/app/core/Engine/controllers/compliance/compliance-service-init.test.ts new file mode 100644 index 00000000000..561d7a5c070 --- /dev/null +++ b/app/core/Engine/controllers/compliance/compliance-service-init.test.ts @@ -0,0 +1,34 @@ +import { buildControllerInitRequestMock } from '../../utils/test-utils'; +import { ExtendedMessenger } from '../../../ExtendedMessenger'; +import { getComplianceServiceMessenger } from '../../messengers/compliance/compliance-service-messenger'; +import { complianceServiceInit } from './compliance-service-init'; +import { + ComplianceService, + type ComplianceServiceMessenger, +} from '@metamask/compliance-controller'; +import { ControllerInitRequest } from '../../types'; +import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; + +function getInitRequestMock(): jest.Mocked< + ControllerInitRequest +> { + const baseMessenger = new ExtendedMessenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + return { + ...buildControllerInitRequestMock(baseMessenger), + controllerMessenger: getComplianceServiceMessenger(baseMessenger), + }; +} + +describe('complianceServiceInit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('instantiates the ComplianceService', () => { + const { controller } = complianceServiceInit(getInitRequestMock()); + expect(controller).toBeInstanceOf(ComplianceService); + }); +}); diff --git a/app/core/Engine/controllers/compliance/compliance-service-init.ts b/app/core/Engine/controllers/compliance/compliance-service-init.ts new file mode 100644 index 00000000000..050ebf47c6d --- /dev/null +++ b/app/core/Engine/controllers/compliance/compliance-service-init.ts @@ -0,0 +1,25 @@ +import { + ComplianceService, + type ComplianceServiceMessenger, +} from '@metamask/compliance-controller'; +import type { ControllerInitFunction } from '../../types'; + +/** + * Initialize the ComplianceService. + * + * @param request - The request object. + * @param request.controllerMessenger - The messenger to use for the service. + * @returns The initialized ComplianceService. + */ +export const complianceServiceInit: ControllerInitFunction< + ComplianceService, + ComplianceServiceMessenger +> = ({ controllerMessenger }) => { + const controller = new ComplianceService({ + messenger: controllerMessenger, + fetch, + env: __DEV__ ? 'development' : 'production', + }); + + return { controller }; +}; diff --git a/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts b/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts index 0ded9a4393a..0ab5aec79a5 100644 --- a/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts +++ b/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts @@ -4,7 +4,7 @@ import { onRpcEndpointUnavailable, } from './messenger-action-handlers'; // This is intentional so we can mock certain modules. -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as networkControllerUtilsModule from './utils'; describe('onRpcEndpointUnavailable', () => { diff --git a/app/core/Engine/controllers/network-controller/utils.test.ts b/app/core/Engine/controllers/network-controller/utils.test.ts index daa59dab5b9..2437a13b7b0 100644 --- a/app/core/Engine/controllers/network-controller/utils.test.ts +++ b/app/core/Engine/controllers/network-controller/utils.test.ts @@ -1,5 +1,5 @@ // Jest tests run in Node, so this is okay. -// eslint-disable-next-line import/no-nodejs-modules +// eslint-disable-next-line import-x/no-nodejs-modules import assert from 'assert'; import { generateDeterministicRandomNumber } from '@metamask/remote-feature-flag-controller'; diff --git a/app/core/Engine/controllers/notifications/create-notification-services-push-controller.test.ts b/app/core/Engine/controllers/notifications/create-notification-services-push-controller.test.ts index b1928c617ce..56128cb82c7 100644 --- a/app/core/Engine/controllers/notifications/create-notification-services-push-controller.test.ts +++ b/app/core/Engine/controllers/notifications/create-notification-services-push-controller.test.ts @@ -7,7 +7,7 @@ import { } from '@metamask/notification-services-controller/push-services'; import { ExtendedMessenger } from '../../../ExtendedMessenger'; import { createNotificationServicesPushController } from './create-notification-services-push-controller'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as PushUtilsModule from './push-utils'; import { getNotificationServicesPushControllerMessenger } from '../../messengers/notifications/notification-services-push-controller-messenger'; import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; diff --git a/app/core/Engine/controllers/notifications/notification-services-controller-init.test.ts b/app/core/Engine/controllers/notifications/notification-services-controller-init.test.ts index 4e56d6511f1..b075b796427 100644 --- a/app/core/Engine/controllers/notifications/notification-services-controller-init.test.ts +++ b/app/core/Engine/controllers/notifications/notification-services-controller-init.test.ts @@ -5,7 +5,7 @@ import { import Logger from '../../../../util/Logger'; import { buildControllerInitRequestMock } from '../../utils/test-utils'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as createNotificationServicesControllerModule from './create-notification-services-controller'; import { notificationServicesControllerInit } from './notification-services-controller-init'; import { ExtendedMessenger } from '../../../ExtendedMessenger'; diff --git a/app/core/Engine/controllers/notifications/notification-services-push-controller-init.test.ts b/app/core/Engine/controllers/notifications/notification-services-push-controller-init.test.ts index af112258ef1..84162ac4438 100644 --- a/app/core/Engine/controllers/notifications/notification-services-push-controller-init.test.ts +++ b/app/core/Engine/controllers/notifications/notification-services-push-controller-init.test.ts @@ -5,7 +5,7 @@ import { import Logger from '../../../../util/Logger'; import { buildControllerInitRequestMock } from '../../utils/test-utils'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as createNotificationServicesPushControllerModule from './create-notification-services-push-controller'; import { notificationServicesPushControllerInit } from './notification-services-push-controller-init'; import { ExtendedMessenger } from '../../../ExtendedMessenger'; diff --git a/app/core/Engine/controllers/predict-controller/index.test.ts b/app/core/Engine/controllers/predict-controller/index.test.ts index 5b212bb2bb3..d18368cacc3 100644 --- a/app/core/Engine/controllers/predict-controller/index.test.ts +++ b/app/core/Engine/controllers/predict-controller/index.test.ts @@ -71,6 +71,7 @@ describe('predict controller init', () => { pendingDeposits: {}, pendingClaims: {}, withdrawTransaction: null, + selectedPaymentToken: null, accountMeta: {}, }; diff --git a/app/core/Engine/controllers/profile-metrics-controller-init.test.ts b/app/core/Engine/controllers/profile-metrics-controller-init.test.ts index f31f807dbf1..fc9a0eb8c60 100644 --- a/app/core/Engine/controllers/profile-metrics-controller-init.test.ts +++ b/app/core/Engine/controllers/profile-metrics-controller-init.test.ts @@ -133,6 +133,7 @@ describe.each([ state: undefined, assertUserOptedIn: expect.any(Function), getMetaMetricsId: expect.any(Function), + initialDelayDuration: 60_000, }); expect(controllerMock.mock.calls[0][0].assertUserOptedIn()).toBe( analyticsEnabled && remoteFeatureFlag && pna25Acknowledged, diff --git a/app/core/Engine/controllers/profile-metrics-controller-init.ts b/app/core/Engine/controllers/profile-metrics-controller-init.ts index ee2231cf87c..72255e1c849 100644 --- a/app/core/Engine/controllers/profile-metrics-controller-init.ts +++ b/app/core/Engine/controllers/profile-metrics-controller-init.ts @@ -48,6 +48,7 @@ export const profileMetricsControllerInit: ControllerInitFunction< state: persistedState.ProfileMetricsController, assertUserOptedIn, getMetaMetricsId: () => analyticsId, + initialDelayDuration: 60_000, // 1 minute delay }); return { diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts index a47f66344cf..89918d75251 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts @@ -67,17 +67,17 @@ jest.mock('react-native-device-info', () => ({ const createMockInitMessenger = ( overrides: { - active?: boolean; + enabled?: boolean; minimumVersion?: string | null; } = {}, ): RampsControllerInitMessenger => { - const { active = false, minimumVersion = null } = overrides; + const { enabled = false, minimumVersion = null } = overrides; return { call: jest.fn().mockReturnValue({ remoteFeatureFlags: { rampsUnifiedBuyV2: { - active, + enabled, minimumVersion, }, }, @@ -196,7 +196,7 @@ describe('ramps controller init', () => { describe('when V2 feature flag is enabled', () => { it('calls init at startup', async () => { initRequestMock.initMessenger = createMockInitMessenger({ - active: true, + enabled: true, minimumVersion: '1.0.0', }); @@ -207,9 +207,43 @@ describe('ramps controller init', () => { }); }); + it('calls init when remote flags were off at startup then V2 enables on RemoteFeatureFlagController:stateChange', async () => { + let remoteEnabled = false; + const subscribeMock = jest.fn(); + const initMessenger = { + call: jest.fn(() => ({ + remoteFeatureFlags: { + rampsUnifiedBuyV2: remoteEnabled + ? { enabled: true, minimumVersion: '1.0.0' } + : { enabled: false }, + }, + })), + subscribe: subscribeMock, + } as unknown as RampsControllerInitMessenger; + + initRequestMock.initMessenger = initMessenger; + + rampsControllerInit(initRequestMock); + + expect(mockInit).not.toHaveBeenCalled(); + + const stateChangeHandler = subscribeMock.mock.calls.find( + (call) => call[0] === 'RemoteFeatureFlagController:stateChange', + )?.[1] as () => void; + + expect(stateChangeHandler).toBeDefined(); + + remoteEnabled = true; + stateChangeHandler(); + + await waitFor(() => { + expect(mockInit).toHaveBeenCalledTimes(1); + }); + }); + it('handles init failure gracefully', async () => { initRequestMock.initMessenger = createMockInitMessenger({ - active: true, + enabled: true, minimumVersion: '1.0.0', }); mockInit.mockRejectedValue(new Error('Network error')); @@ -225,7 +259,7 @@ describe('ramps controller init', () => { describe('when V2 feature flag is disabled', () => { it('does not call init at startup', async () => { initRequestMock.initMessenger = createMockInitMessenger({ - active: false, + enabled: false, }); rampsControllerInit(initRequestMock); @@ -235,9 +269,9 @@ describe('ramps controller init', () => { }); }); - it('does not call init when active is true but minimumVersion is missing', async () => { + it('does not call init when enabled is true but minimumVersion is missing', async () => { initRequestMock.initMessenger = createMockInitMessenger({ - active: true, + enabled: true, minimumVersion: null, }); @@ -253,6 +287,7 @@ describe('ramps controller init', () => { call: jest.fn().mockImplementation(() => { throw new Error('Controller not ready'); }), + subscribe: jest.fn(), } as unknown as RampsControllerInitMessenger; rampsControllerInit(initRequestMock); @@ -265,7 +300,7 @@ describe('ramps controller init', () => { it('always returns the controller instance regardless of flag state', () => { initRequestMock.initMessenger = createMockInitMessenger({ - active: false, + enabled: false, }); const result = rampsControllerInit(initRequestMock); diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts index a419253e8fb..09ebbe7a9f1 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts @@ -5,20 +5,13 @@ import { getDefaultRampsControllerState, } from '@metamask/ramps-controller'; import type { RampsControllerInitMessenger } from '../../messengers/ramps-controller-messenger'; -import { hasMinimumRequiredVersion } from '../../../../components/UI/Ramp/utils/hasMinimumRequiredVersion'; +import { validatedVersionGatedFeatureFlag } from '../../../../util/remoteFeatureFlag'; +import { RAMPS_UNIFIED_BUY_V2_FLAG_KEY } from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2'; import { handleOrderStatusChangedForNotifications } from './event-handlers/notification'; import { handleOrderStatusChangedForMetrics } from './event-handlers/analytics'; -interface RampsUnifiedBuyV2Config { - active?: boolean; - minimumVersion?: string; -} - -const RAMPS_UNIFIED_BUY_V2_FLAG_KEY = 'rampsUnifiedBuyV2'; - /** - * Determines whether the ramps unified buy V2 feature is enabled - * by reading the remote feature flag state. + * Whether Unified Buy V2 is enabled per RemoteFeatureFlagController state. * * @param initMessenger - The init messenger to read RemoteFeatureFlagController state. * @returns Whether V2 is enabled. @@ -30,14 +23,9 @@ function getIsRampsUnifiedBuyV2Enabled( const remoteState = initMessenger.call( 'RemoteFeatureFlagController:getState', ); - const config = (remoteState?.remoteFeatureFlags?.[ - RAMPS_UNIFIED_BUY_V2_FLAG_KEY - ] ?? {}) as RampsUnifiedBuyV2Config; - - return hasMinimumRequiredVersion( - config.minimumVersion, - config.active ?? false, - ); + const remoteFlag = + remoteState?.remoteFeatureFlags?.[RAMPS_UNIFIED_BUY_V2_FLAG_KEY]; + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; } catch { return false; } @@ -65,21 +53,28 @@ export const rampsControllerInit: ControllerInitFunction< state: rampsControllerState, }); - const isV2Enabled = getIsRampsUnifiedBuyV2Enabled(initMessenger); + let orderSubscriptionsRegistered = false; - if (isV2Enabled) { + const registerUnifiedBuyV2OrderSubscriptions = (): void => { + if (orderSubscriptionsRegistered) { + return; + } + orderSubscriptionsRegistered = true; initMessenger.subscribe( 'RampsController:orderStatusChanged', handleOrderStatusChangedForNotifications, ); - initMessenger.subscribe( 'RampsController:orderStatusChanged', handleOrderStatusChangedForMetrics, ); + }; - // Start init immediately so tokens (and providers) load on app start. - // init() is async and does not block controller creation. + const startUnifiedBuyV2IfEnabled = (): void => { + if (!getIsRampsUnifiedBuyV2Enabled(initMessenger)) { + return; + } + registerUnifiedBuyV2OrderSubscriptions(); controller .init() .then(() => { @@ -88,7 +83,20 @@ export const rampsControllerInit: ControllerInitFunction< .catch(() => { // Initialization failed - error state will be available via selectors }); - } + }; + + startUnifiedBuyV2IfEnabled(); + + // Remote flags can be empty on first Engine init and fill in once the + // controller has fetched; re-check so RampsController.init() runs then. + // + // This event fires for any RemoteFeatureFlagController state update — not + // only rampsUnifiedBuyV2. When V2 is off, startUnifiedBuyV2IfEnabled returns + // immediately. When V2 is on, order subscriptions register once; init() and + // startOrderPolling() are idempotent, so repeat invocations are safe. + initMessenger.subscribe('RemoteFeatureFlagController:stateChange', () => { + startUnifiedBuyV2IfEnabled(); + }); return { controller, diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index a2802bcccb4..e6a56e5da07 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -15835,7 +15835,6 @@ describe('RewardsController', () => { "pointsEvents": {}, "seasonStatuses": {}, "seasons": {}, - "snapshots": {}, "subscriptionReferralDetails": {}, "subscriptions": {}, "unlockedRewards": {}, @@ -15859,7 +15858,6 @@ describe('RewardsController', () => { "rewardsEnvUrl": null, "seasonStatuses": {}, "seasons": {}, - "snapshots": {}, "subscriptionReferralDetails": {}, "subscriptions": {}, "unlockedRewards": {}, @@ -15886,7 +15884,6 @@ describe('RewardsController', () => { "rewardsEnvUrl": null, "seasonStatuses": {}, "seasons": {}, - "snapshots": {}, "subscriptionReferralDetails": {}, "subscriptions": {}, "unlockedRewards": {}, @@ -18205,540 +18202,6 @@ describe('RewardsController', () => { }); }); - describe('getSnapshots', () => { - let controller: RewardsController; - let mockMessenger: jest.Mocked; - const mockSeasonId = 'season123'; - const mockSubscriptionId = 'sub123'; - - // Helper function to create test snapshot data - const createTestSnapshot = ( - overrides: Partial<{ - id: string; - seasonId: string; - name: string; - description: string; - tokenSymbol: string; - tokenAmount: string; - tokenChainId: string; - tokenAddress: string; - receivingBlockchain: string; - opensAt: string; - closesAt: string; - calculatedAt: string; - backgroundImage: { lightModeUrl: string; darkModeUrl: string }; - }> = {}, - ) => ({ - id: 'snapshot-1', - seasonId: mockSeasonId, - name: 'Monad 50000', - description: 'Earn MONAD tokens by participating in the airdrop', - tokenSymbol: 'MONAD', - tokenAmount: '50000000000000000000000', - tokenChainId: '1', - tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', - receivingBlockchain: 'Monad', - opensAt: '2025-01-01T00:00:00.000Z', - closesAt: '2025-01-15T00:00:00.000Z', - calculatedAt: '2025-01-16T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/snapshot-light.png', - darkModeUrl: 'https://example.com/snapshot-dark.png', - }, - ...overrides, - }); - - beforeEach(() => { - mockMessenger = { - subscribe: jest.fn(), - call: jest.fn(), - registerActionHandler: jest.fn(), - unregisterActionHandler: jest.fn(), - publish: jest.fn(), - clearEventSubscriptions: jest.fn(), - registerInitialEventPayload: jest.fn(), - unsubscribe: jest.fn(), - } as unknown as jest.Mocked; - }); - - it('returns empty array when rewards feature flag is disabled', async () => { - const disabledController = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isDisabled: () => true, - isSnapshotsEnabled: () => true, - }); - - const result = await disabledController.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - expect(result).toEqual([]); - expect(mockMessenger.call).not.toHaveBeenCalledWith( - 'RewardsDataService:getSnapshots', - expect.anything(), - expect.anything(), - ); - }); - - it('throws error when snapshots feature is not enabled', async () => { - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isDisabled: () => false, - isSnapshotsEnabled: () => false, - }); - - await expect( - controller.getSnapshots(mockSeasonId, mockSubscriptionId), - ).rejects.toThrow('Snapshots feature is not enabled'); - - expect(mockMessenger.call).not.toHaveBeenCalledWith( - 'RewardsDataService:getSnapshots', - expect.anything(), - expect.anything(), - ); - }); - - it('returns cached snapshots when cache is fresh', async () => { - const recentTime = Date.now() - 60000; // 1 minute ago (within 5 minute threshold) - - const mockCachedSnapshots = [ - createTestSnapshot({ id: 'snapshot-1', name: 'Monad 50000' }), - createTestSnapshot({ id: 'snapshot-2', name: 'Linea 25000' }), - ]; - - controller = new RewardsController({ - messenger: mockMessenger, - state: { - activeAccount: null, - accounts: {}, - subscriptions: {}, - seasons: {}, - subscriptionReferralDetails: {}, - seasonStatuses: {}, - activeBoosts: {}, - pointsEvents: {}, - unlockedRewards: {}, - snapshots: { - [mockSeasonId]: { - snapshots: mockCachedSnapshots, - lastFetched: recentTime, - }, - }, - pointsEstimateHistory: [], - }, - isSnapshotsEnabled: () => true, - }); - - const result = await controller.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - expect(result).toEqual(mockCachedSnapshots); - expect(result).toHaveLength(2); - expect(result[0].id).toBe('snapshot-1'); - expect(result[0].name).toBe('Monad 50000'); - expect(result[1].id).toBe('snapshot-2'); - expect(result[1].name).toBe('Linea 25000'); - expect(mockMessenger.call).not.toHaveBeenCalledWith( - 'RewardsDataService:getSnapshots', - expect.anything(), - expect.anything(), - ); - }); - - it('fetches fresh snapshots when cache is stale', async () => { - const staleTime = Date.now() - 360000; // 6 minutes ago (beyond 5 minute threshold) - - const mockStaleSnapshots = [ - createTestSnapshot({ id: 'stale-snapshot', name: 'Stale 10000' }), - ]; - - const mockFreshSnapshots = [ - createTestSnapshot({ id: 'fresh-snapshot-1', name: 'Arbitrum 75000' }), - createTestSnapshot({ id: 'fresh-snapshot-2', name: 'Optimism 60000' }), - createTestSnapshot({ id: 'fresh-snapshot-3', name: 'Base 45000' }), - ]; - - controller = new RewardsController({ - messenger: mockMessenger, - state: { - activeAccount: null, - accounts: {}, - subscriptions: {}, - seasons: {}, - subscriptionReferralDetails: {}, - seasonStatuses: {}, - activeBoosts: {}, - pointsEvents: {}, - unlockedRewards: {}, - snapshots: { - [mockSeasonId]: { - snapshots: mockStaleSnapshots, - lastFetched: staleTime, - }, - }, - pointsEstimateHistory: [], - }, - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockResolvedValue(mockFreshSnapshots); - - const result = await controller.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - expect(mockMessenger.call).toHaveBeenCalledWith( - 'RewardsDataService:getSnapshots', - mockSeasonId, - mockSubscriptionId, - ); - expect(result).toEqual(mockFreshSnapshots); - expect(result).toHaveLength(3); - expect(result[0].id).toBe('fresh-snapshot-1'); - expect(result[1].name).toBe('Optimism 60000'); - expect(result[2].id).toBe('fresh-snapshot-3'); - - // Verify state was updated with fresh data - const updatedCache = controller.state.snapshots[mockSeasonId]; - expect(updatedCache).toBeDefined(); - expect(updatedCache.snapshots).toEqual(mockFreshSnapshots); - expect(updatedCache.lastFetched).toBeGreaterThan(Date.now() - 1000); - }); - - it('handles cache miss and fetches fresh data', async () => { - const mockApiSnapshots = [ - createTestSnapshot({ - id: 'api-snapshot-1', - name: 'Polygon 30000', - tokenSymbol: 'MATIC', - }), - createTestSnapshot({ - id: 'api-snapshot-2', - name: 'zkSync 55000', - tokenSymbol: 'ZK', - }), - ]; - - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockResolvedValue(mockApiSnapshots); - - const result = await controller.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - expect(mockMessenger.call).toHaveBeenCalledWith( - 'RewardsDataService:getSnapshots', - mockSeasonId, - mockSubscriptionId, - ); - expect(result).toEqual(mockApiSnapshots); - expect(result).toHaveLength(2); - expect(result[0].tokenSymbol).toBe('MATIC'); - expect(result[1].tokenSymbol).toBe('ZK'); - - // Verify state was updated with cached data - const cachedData = controller.state.snapshots[mockSeasonId]; - expect(cachedData).toBeDefined(); - expect(cachedData.snapshots).toEqual(mockApiSnapshots); - expect(cachedData.lastFetched).toBeGreaterThan(Date.now() - 1000); - }); - - it('throws error when API fails', async () => { - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockRejectedValue(new Error('API error')); - - await expect( - controller.getSnapshots(mockSeasonId, mockSubscriptionId), - ).rejects.toThrow('API error'); - - expect(mockMessenger.call).toHaveBeenCalledWith( - 'RewardsDataService:getSnapshots', - mockSeasonId, - mockSubscriptionId, - ); - }); - - it('handles null API response by returning empty array', async () => { - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockResolvedValue(null); - - const result = await controller.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - expect(result).toEqual([]); - - // Verify state was updated with empty array - const cachedData = controller.state.snapshots[mockSeasonId]; - expect(cachedData).toBeDefined(); - expect(cachedData.snapshots).toEqual([]); - }); - - it('handles empty snapshots array from API', async () => { - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockResolvedValue([]); - - const result = await controller.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - expect(result).toEqual([]); - expect(result).toHaveLength(0); - - // Verify state was updated - const cachedData = controller.state.snapshots[mockSeasonId]; - expect(cachedData).toBeDefined(); - expect(cachedData.snapshots).toEqual([]); - expect(cachedData.lastFetched).toBeGreaterThan(Date.now() - 1000); - }); - - it('handles multiple calls with different season IDs using separate caches', async () => { - const seasonId1 = 'season-A'; - const seasonId2 = 'season-B'; - - const mockSnapshots1 = [ - createTestSnapshot({ - id: 'snapshot-A', - seasonId: seasonId1, - name: 'Monad 50000', - }), - ]; - - const mockSnapshots2 = [ - createTestSnapshot({ - id: 'snapshot-B-1', - seasonId: seasonId2, - name: 'Linea 25000', - }), - createTestSnapshot({ - id: 'snapshot-B-2', - seasonId: seasonId2, - name: 'Arbitrum 75000', - }), - ]; - - controller = new RewardsController({ - messenger: mockMessenger, - state: { - activeAccount: null, - accounts: {}, - subscriptions: {}, - seasons: {}, - subscriptionReferralDetails: {}, - seasonStatuses: {}, - activeBoosts: {}, - pointsEvents: {}, - unlockedRewards: {}, - snapshots: { - [seasonId1]: { - snapshots: mockSnapshots1, - lastFetched: Date.now() - 30000, // Fresh cache - }, - }, - pointsEstimateHistory: [], - }, - isSnapshotsEnabled: () => true, - }); - - // Clear any calls made during controller initialization - mockMessenger.call.mockClear(); - mockMessenger.call.mockResolvedValue(mockSnapshots2); - - // First call uses cache - const result1 = await controller.getSnapshots( - seasonId1, - mockSubscriptionId, - ); - - // Second call fetches fresh data - const result2 = await controller.getSnapshots( - seasonId2, - mockSubscriptionId, - ); - - // Assert - expect(result1).toEqual(mockSnapshots1); - expect(result1[0].id).toBe('snapshot-A'); - expect(result2).toEqual(mockSnapshots2); - expect(result2).toHaveLength(2); - expect(result2[0].name).toBe('Linea 25000'); - expect(result2[1].name).toBe('Arbitrum 75000'); - - // Verify API was called only once (for the second request) - expect(mockMessenger.call).toHaveBeenCalledTimes(1); - expect(mockMessenger.call).toHaveBeenCalledWith( - 'RewardsDataService:getSnapshots', - seasonId2, - mockSubscriptionId, - ); - - // Verify both caches exist - expect(controller.state.snapshots[seasonId1]).toBeDefined(); - expect(controller.state.snapshots[seasonId2]).toBeDefined(); - expect(controller.state.snapshots[seasonId2].snapshots).toEqual( - mockSnapshots2, - ); - }); - - it('uses seasonId as cache key (not composite with subscriptionId)', async () => { - const mockSnapshots = [ - createTestSnapshot({ id: 'cached-snapshot', name: 'Scroll 40000' }), - ]; - - // Pre-populate cache with seasonId as key - controller = new RewardsController({ - messenger: mockMessenger, - state: { - activeAccount: null, - accounts: {}, - subscriptions: {}, - seasons: {}, - subscriptionReferralDetails: {}, - seasonStatuses: {}, - activeBoosts: {}, - pointsEvents: {}, - unlockedRewards: {}, - snapshots: { - [mockSeasonId]: { - snapshots: mockSnapshots, - lastFetched: Date.now() - 30000, // Fresh cache - }, - }, - pointsEstimateHistory: [], - }, - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockClear(); - - // Call with different subscriptionId but same seasonId - const result = await controller.getSnapshots( - mockSeasonId, - 'different-subscription-id', - ); - - // Verify cached data was returned (same cache key since it uses seasonId only) - expect(result).toEqual(mockSnapshots); - expect(mockMessenger.call).not.toHaveBeenCalled(); - }); - - it('stores snapshot data with all properties correctly', async () => { - const mockSnapshotWithAllProps = createTestSnapshot({ - id: 'full-snapshot', - seasonId: mockSeasonId, - name: 'Starknet 80000', - description: 'Earn STRK tokens by participating in the airdrop', - tokenSymbol: 'STRK', - tokenAmount: '80000000000000000000000', - tokenChainId: '137', - tokenAddress: '0xabcdef1234567890abcdef1234567890abcdef12', - receivingBlockchain: 'Polygon', - opensAt: '2025-02-01T00:00:00.000Z', - closesAt: '2025-02-28T00:00:00.000Z', - calculatedAt: '2025-03-01T00:00:00.000Z', - }); - - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockResolvedValue([mockSnapshotWithAllProps]); - - const result = await controller.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - expect(result).toHaveLength(1); - const snapshot = result[0]; - expect(snapshot.id).toBe('full-snapshot'); - expect(snapshot.seasonId).toBe(mockSeasonId); - expect(snapshot.name).toBe('Starknet 80000'); - expect(snapshot.description).toBe( - 'Earn STRK tokens by participating in the airdrop', - ); - expect(snapshot.tokenSymbol).toBe('STRK'); - expect(snapshot.tokenAmount).toBe('80000000000000000000000'); - expect(snapshot.tokenChainId).toBe('137'); - expect(snapshot.tokenAddress).toBe( - '0xabcdef1234567890abcdef1234567890abcdef12', - ); - expect(snapshot.receivingBlockchain).toBe('Polygon'); - expect(snapshot.opensAt).toBe('2025-02-01T00:00:00.000Z'); - expect(snapshot.closesAt).toBe('2025-02-28T00:00:00.000Z'); - expect(snapshot.calculatedAt).toBe('2025-03-01T00:00:00.000Z'); - - // Verify stored in state correctly - const cachedData = controller.state.snapshots[mockSeasonId]; - expect(cachedData.snapshots[0]).toEqual(mockSnapshotWithAllProps); - }); - - it('logs error message when API call fails', async () => { - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isSnapshotsEnabled: () => true, - }); - - const apiError = new Error('Network timeout'); - mockMessenger.call.mockRejectedValue(apiError); - mockLogger.log.mockClear(); - - await expect( - controller.getSnapshots(mockSeasonId, mockSubscriptionId), - ).rejects.toThrow('Network timeout'); - }); - - it('logs when fetching fresh snapshots data', async () => { - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockResolvedValue([]); - mockLogger.log.mockClear(); - - await controller.getSnapshots(mockSeasonId, mockSubscriptionId); - - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Fetching fresh snapshots data via API call for seasonId', - mockSeasonId, - ); - }); - }); - describe('getOffDeviceSubscriptionAccounts', () => { let localController: RewardsController; let localMockMessenger: jest.Mocked; @@ -19148,6 +18611,7 @@ describe('RewardsController', () => { termsAndConditions: Json | null; excludedRegions: string[]; statusLabel: string; + details: null; }> = {}, ) => ({ id: 'campaign-1', @@ -19158,6 +18622,7 @@ describe('RewardsController', () => { termsAndConditions: null, excludedRegions: [], statusLabel: 'Active', + details: null, ...overrides, }); @@ -19331,7 +18796,7 @@ describe('RewardsController', () => { let mockMessenger: jest.Mocked; const mockSubscriptionId = 'sub123'; const mockCampaignId = 'campaign-456'; - const mockStatus = { optedIn: true }; + const mockStatus = { optedIn: true, participantCount: 42 }; beforeEach(() => { mockMessenger = { @@ -19346,7 +18811,7 @@ describe('RewardsController', () => { } as unknown as jest.Mocked; }); - it('returns { optedIn: false } when rewards feature flag is disabled', async () => { + it('returns { optedIn: false, participantCount: 0 } when rewards feature flag is disabled', async () => { const disabledController = new RewardsController({ messenger: mockMessenger, state: getRewardsControllerDefaultState(), @@ -19358,7 +18823,7 @@ describe('RewardsController', () => { mockSubscriptionId, ); - expect(result).toEqual({ optedIn: false }); + expect(result).toEqual({ optedIn: false, participantCount: 0 }); expect(mockMessenger.call).not.toHaveBeenCalledWith( 'RewardsDataService:optInToCampaign', expect.anything(), @@ -19366,7 +18831,7 @@ describe('RewardsController', () => { ); }); - it('returns { optedIn: false } when campaigns feature flag is disabled', async () => { + it('returns { optedIn: false, participantCount: 0 } when campaigns feature flag is disabled', async () => { const disabledController = new RewardsController({ messenger: mockMessenger, state: getRewardsControllerDefaultState(), @@ -19378,7 +18843,7 @@ describe('RewardsController', () => { mockSubscriptionId, ); - expect(result).toEqual({ optedIn: false }); + expect(result).toEqual({ optedIn: false, participantCount: 0 }); expect(mockMessenger.call).not.toHaveBeenCalled(); }); @@ -19429,7 +18894,11 @@ describe('RewardsController', () => { state: { ...getRewardsControllerDefaultState(), campaignParticipantStatus: { - [cacheKey]: { optedIn: false, lastFetched: Date.now() }, + [cacheKey]: { + optedIn: false, + participantCount: 0, + lastFetched: Date.now(), + }, }, }, isCampaignsEnabled: () => true, @@ -19447,7 +18916,7 @@ describe('RewardsController', () => { let mockMessenger: jest.Mocked; const mockSubscriptionId = 'sub123'; const mockCampaignId = 'campaign-456'; - const mockStatus = { optedIn: true }; + const mockStatus = { optedIn: true, participantCount: 42 }; beforeEach(() => { mockMessenger = { @@ -19462,7 +18931,7 @@ describe('RewardsController', () => { } as unknown as jest.Mocked; }); - it('returns { optedIn: false } when rewards feature flag is disabled', async () => { + it('returns { optedIn: false, participantCount: 0 } when rewards feature flag is disabled', async () => { const disabledController = new RewardsController({ messenger: mockMessenger, state: getRewardsControllerDefaultState(), @@ -19474,11 +18943,11 @@ describe('RewardsController', () => { mockSubscriptionId, ); - expect(result).toEqual({ optedIn: false }); + expect(result).toEqual({ optedIn: false, participantCount: 0 }); expect(mockMessenger.call).not.toHaveBeenCalled(); }); - it('returns { optedIn: false } when campaigns feature flag is disabled', async () => { + it('returns { optedIn: false, participantCount: 0 } when campaigns feature flag is disabled', async () => { const disabledController = new RewardsController({ messenger: mockMessenger, state: getRewardsControllerDefaultState(), @@ -19490,7 +18959,7 @@ describe('RewardsController', () => { mockSubscriptionId, ); - expect(result).toEqual({ optedIn: false }); + expect(result).toEqual({ optedIn: false, participantCount: 0 }); expect(mockMessenger.call).not.toHaveBeenCalled(); }); @@ -19531,7 +19000,11 @@ describe('RewardsController', () => { state: { ...getRewardsControllerDefaultState(), campaignParticipantStatus: { - [cacheKey]: { optedIn: false, lastFetched: recentTime }, + [cacheKey]: { + optedIn: false, + participantCount: 10, + lastFetched: recentTime, + }, }, }, isCampaignsEnabled: () => true, @@ -19542,7 +19015,7 @@ describe('RewardsController', () => { mockSubscriptionId, ); - expect(result).toEqual({ optedIn: false }); + expect(result).toEqual({ optedIn: false, participantCount: 10 }); expect(mockMessenger.call).not.toHaveBeenCalled(); }); @@ -19555,7 +19028,11 @@ describe('RewardsController', () => { state: { ...getRewardsControllerDefaultState(), campaignParticipantStatus: { - [cacheKey]: { optedIn: false, lastFetched: staleTime }, + [cacheKey]: { + optedIn: false, + participantCount: 0, + lastFetched: staleTime, + }, }, }, isCampaignsEnabled: () => true, diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index c95ae939a9d..fab99bd81ee 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -21,7 +21,6 @@ import { type PointsBoostDto, type PointsEventDto, type RewardDto, - type SnapshotDto, type CampaignDto, type CampaignParticipantStatusDto, type PointsEstimateHistoryEntry, @@ -94,9 +93,6 @@ const ACTIVE_BOOSTS_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute // Unlocked rewards cache threshold const UNLOCKED_REWARDS_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute -// Snapshots cache threshold -const SNAPSHOTS_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes - // Off-device subscription accounts cache threshold const OFF_DEVICE_SUBSCRIPTION_ACCOUNTS_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes @@ -173,12 +169,6 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, - snapshots: { - includeInStateLogs: true, - persist: true, - includeInDebugSnapshot: false, - usedInUi: true, - }, offDeviceSubscriptionAccounts: { includeInStateLogs: true, persist: true, @@ -224,7 +214,6 @@ export const getRewardsControllerDefaultState = (): RewardsControllerState => ({ activeBoosts: {}, unlockedRewards: {}, pointsEvents: {}, - snapshots: {}, offDeviceSubscriptionAccounts: {}, campaigns: {}, campaignParticipantStatus: {}, @@ -324,7 +313,6 @@ export class RewardsController extends BaseController< #isDisabled: () => boolean; #isBitcoinOptinEnabled: () => boolean; #isTronOptinEnabled: () => boolean; - #isSnapshotsEnabled: () => boolean; #isCampaignsEnabled: () => boolean; #reauthPromises: Map> = new Map(); @@ -478,7 +466,6 @@ export class RewardsController extends BaseController< isDisabled, isBitcoinOptinEnabled, isTronOptinEnabled, - isSnapshotsEnabled, isCampaignsEnabled, }: { messenger: RewardsControllerMessenger; @@ -486,7 +473,6 @@ export class RewardsController extends BaseController< isDisabled?: () => boolean; isBitcoinOptinEnabled?: () => boolean; isTronOptinEnabled?: () => boolean; - isSnapshotsEnabled?: () => boolean; isCampaignsEnabled?: () => boolean; }) { super({ @@ -502,7 +488,6 @@ export class RewardsController extends BaseController< this.#isDisabled = isDisabled ?? (() => false); this.#isBitcoinOptinEnabled = isBitcoinOptinEnabled ?? (() => false); this.#isTronOptinEnabled = isTronOptinEnabled ?? (() => false); - this.#isSnapshotsEnabled = isSnapshotsEnabled ?? (() => false); this.#isCampaignsEnabled = isCampaignsEnabled ?? (() => false); this.#registerActionHandlers(); @@ -597,10 +582,6 @@ export class RewardsController extends BaseController< 'RewardsController:getUnlockedRewards', this.getUnlockedRewards.bind(this), ); - this.messenger.registerActionHandler( - 'RewardsController:getSnapshots', - this.getSnapshots.bind(this), - ); this.messenger.registerActionHandler( 'RewardsController:getOffDeviceSubscriptionAccounts', this.getOffDeviceSubscriptionAccounts.bind(this), @@ -727,7 +708,7 @@ export class RewardsController extends BaseController< if (!this.canChangeRewardsEnvUrl()) { return; } - this.update((state: RewardsControllerState) => { + this.update((state) => { state.rewardsEnvUrl = url; }); this.messenger.call('RewardsDataService:setRewardsEnvUrl', url); @@ -872,7 +853,7 @@ export class RewardsController extends BaseController< const accountState = this.#getAccountState(caipAccount); if (!accountState) return; - this.update((state: RewardsControllerState) => { + this.update((state) => { state.activeAccount = accountState; }); } @@ -1158,7 +1139,7 @@ export class RewardsController extends BaseController< ): Promise { if (!internalAccount) { if (shouldBecomeActiveAccount) { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.activeAccount = null; }); } @@ -1177,7 +1158,7 @@ export class RewardsController extends BaseController< let accountState = this.#getAccountState(account as CaipAccountId); if (accountState) { // Update last authenticated account - this.update((state: RewardsControllerState) => { + this.update((state) => { if (shouldBecomeActiveAccount) { state.activeAccount = accountState; } @@ -1191,7 +1172,7 @@ export class RewardsController extends BaseController< perpsFeeDiscount: null, // Default value, will be updated when fetched lastPerpsDiscountRateFetched: null, }; - this.update((state: RewardsControllerState) => { + this.update((state) => { state.accounts[account as CaipAccountId] = accountState as RewardsAccountState; if (shouldBecomeActiveAccount) { @@ -1221,7 +1202,7 @@ export class RewardsController extends BaseController< // Account hasn't opted in, don't proceed with login subscription = null; // Update state to reflect not opted in - this.update((state: RewardsControllerState) => { + this.update((state) => { if (!account) { return; } @@ -1342,7 +1323,7 @@ export class RewardsController extends BaseController< } } finally { // Update state - this.update((state: RewardsControllerState) => { + this.update((state) => { if (!account) { return; } @@ -1414,7 +1395,7 @@ export class RewardsController extends BaseController< coercedAccount = account; } - this.update((state: RewardsControllerState) => { + this.update((state) => { // Create account state if it doesn't exist if (!state.accounts[coercedAccount]) { state.accounts[coercedAccount] = { @@ -1600,7 +1581,7 @@ export class RewardsController extends BaseController< this.convertInternalAccountToCaipAccountId(internalAccount); if (caipAccount) { const lastFreshOptInStatusCheck = Date.now(); - this.update((state: RewardsControllerState) => { + this.update((state) => { // Update or create account state with fresh opt-in status and subscription ID if (!state.accounts[caipAccount]) { state.accounts[caipAccount] = { @@ -1805,7 +1786,7 @@ export class RewardsController extends BaseController< return pointsEvents; }, params.subscriptionId), writeCache: (key, pointsEventsDto) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.pointsEvents[key] = this.#convertPointsEventsToState(pointsEventsDto); }); @@ -2024,7 +2005,7 @@ export class RewardsController extends BaseController< responseBonusBips: response.bonusBips, }; - this.update((state: RewardsControllerState) => { + this.update((state) => { // Add new entry at the beginning (most recent first) state.pointsEstimateHistory.unshift(entry); @@ -2156,7 +2137,7 @@ export class RewardsController extends BaseController< return seasonStateWithTimestamp; } - this.update((state: RewardsControllerState) => { + this.update((state) => { delete state.seasons[type]; }); @@ -2165,7 +2146,7 @@ export class RewardsController extends BaseController< ); }, writeCache: (key, value) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.seasons[key] = value; state.seasons[value.id] = value; }); @@ -2227,7 +2208,7 @@ export class RewardsController extends BaseController< return this.#convertSeasonStatusToSubscriptionState(seasonStatus); } catch (error) { if (error instanceof SeasonNotFoundError) { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.seasons = {}; }); throw error; @@ -2240,7 +2221,7 @@ export class RewardsController extends BaseController< } }, subscriptionId), writeCache: (key, subscriptionSeasonStatus) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { // Update season status with composite key state.seasonStatuses[key] = subscriptionSeasonStatus; }); @@ -2259,7 +2240,7 @@ export class RewardsController extends BaseController< async invalidateSubscriptionAndAccounts( subscriptionId: string, ): Promise { - this.update((state: RewardsControllerState) => { + this.update((state) => { // Remove the failing subscription delete state.subscriptions[subscriptionId]; @@ -2344,7 +2325,7 @@ export class RewardsController extends BaseController< }; }, subscriptionId), writeCache: (key, payload) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.subscriptionReferralDetails[key] = payload; }); }, @@ -2592,7 +2573,7 @@ export class RewardsController extends BaseController< try { const currentActiveAccount = this.state.activeAccount?.account; this.resetState(); - this.update((state: RewardsControllerState) => { + this.update((state) => { if (currentActiveAccount) { state.activeAccount = { account: currentActiveAccount, @@ -3034,7 +3015,7 @@ export class RewardsController extends BaseController< ); // Update store with accounts and subscriptions (but not activeAccount) - this.update((state: RewardsControllerState) => { + this.update((state) => { // Update accounts state state.accounts[caipAccount] = { account: caipAccount, @@ -3236,7 +3217,7 @@ export class RewardsController extends BaseController< return response.boosts; }, subscriptionId), writeCache: (key, payload) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.activeBoosts[key] = { boosts: payload, lastFetched: Date.now(), @@ -3289,7 +3270,7 @@ export class RewardsController extends BaseController< return response || []; }, subscriptionId), writeCache: (key, payload) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.unlockedRewards[key] = { rewards: payload, lastFetched: Date.now(), @@ -3301,60 +3282,6 @@ export class RewardsController extends BaseController< return result; } - /** - * Get snapshots for a season with caching - * @param seasonId - The season ID - * @param subscriptionId - The subscription ID for authentication - * @returns Promise - The snapshots data - */ - async getSnapshots( - seasonId: string, - subscriptionId: string, - ): Promise { - const rewardsEnabled = this.isRewardsFeatureEnabled(); - if (!rewardsEnabled) { - return []; - } - if (!this.#isSnapshotsEnabled()) { - throw new Error('Snapshots feature is not enabled'); - } - const result = await wrapWithCache({ - key: seasonId, - ttl: SNAPSHOTS_CACHE_THRESHOLD_MS, - readCache: (key) => { - const cachedSnapshots = this.state.snapshots[key] || undefined; - if (!cachedSnapshots) return; - return { - payload: cachedSnapshots.snapshots, - lastFetched: cachedSnapshots.lastFetched, - }; - }, - fetchFresh: async () => - this.#withAuthRetry(async () => { - Logger.log( - 'RewardsController: Fetching fresh snapshots data via API call for seasonId', - seasonId, - ); - const response = (await this.messenger.call( - 'RewardsDataService:getSnapshots', - seasonId, - subscriptionId, - )) as SnapshotDto[]; - return response || []; - }, subscriptionId), - writeCache: (key, payload) => { - this.update((state: RewardsControllerState) => { - state.snapshots[key] = { - snapshots: payload, - lastFetched: Date.now(), - }; - }); - }, - }); - - return result; - } - /** * Get CAIP-10 encoded accounts linked to a subscription with caching * @param subscriptionId - The subscription ID for authentication @@ -3430,7 +3357,7 @@ export class RewardsController extends BaseController< } }, writeCache: (key, payload) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { ( state.offDeviceSubscriptionAccounts as Record< string, @@ -3482,19 +3409,22 @@ export class RewardsController extends BaseController< )) as CampaignDto[]; return response || []; }, subscriptionId), - writeCache: (key, payload) => { - this.update((state: RewardsControllerState) => { - state.campaigns[key] = { - campaigns: payload, - lastFetched: Date.now(), - }; - }); + writeCache: (key: string, payload: CampaignDto[]) => { + this.#writeCampaignsCache(key, payload); }, }); return result; } + #writeCampaignsCache(key: string, campaigns: CampaignDto[]): void { + const cacheEntry = { campaigns, lastFetched: Date.now() }; + this.update((state) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (state.campaigns as Record)[key] = cacheEntry as any; + }); + } + /** * Opt a subscription into a campaign. * @param campaignId - The campaign ID to opt into. @@ -3506,8 +3436,11 @@ export class RewardsController extends BaseController< subscriptionId: string, ): Promise { if (!this.isRewardsFeatureEnabled() || !this.#isCampaignsEnabled()) { - return { optedIn: false }; + return { optedIn: false, participantCount: 0 }; } + const key = `${subscriptionId}:${campaignId}`; + const wasAlreadyOptedIn = + this.state.campaignParticipantStatus[key]?.optedIn === true; const result = await this.#withAuthRetry(async () => { Logger.log('RewardsController: Opting into campaign', campaignId); return (await this.messenger.call( @@ -3517,14 +3450,16 @@ export class RewardsController extends BaseController< )) as CampaignParticipantStatusDto; }, subscriptionId); // Invalidate the participant status cache so the next fetch gets fresh data - const key = `${subscriptionId}:${campaignId}`; - this.update((state: RewardsControllerState) => { + this.update((state) => { delete state.campaignParticipantStatus[key]; }); - this.messenger.publish('RewardsController:campaignOptedIn', { - campaignId, - subscriptionId, - }); + // Only emit if the user wasn't already opted in, to avoid redundant refetches + if (!wasAlreadyOptedIn) { + this.messenger.publish('RewardsController:campaignOptedIn', { + campaignId, + subscriptionId, + }); + } return result; } @@ -3539,7 +3474,7 @@ export class RewardsController extends BaseController< subscriptionId: string, ): Promise { if (!this.isRewardsFeatureEnabled() || !this.#isCampaignsEnabled()) { - return { optedIn: false }; + return { optedIn: false, participantCount: 0 }; } const key = `${subscriptionId}:${campaignId}`; const result = await wrapWithCache({ @@ -3549,7 +3484,10 @@ export class RewardsController extends BaseController< const cached = this.state.campaignParticipantStatus[k]; if (!cached) return undefined; return { - payload: { optedIn: cached.optedIn }, + payload: { + optedIn: cached.optedIn, + participantCount: cached.participantCount, + }, lastFetched: cached.lastFetched, }; }, @@ -3565,9 +3503,10 @@ export class RewardsController extends BaseController< )) as CampaignParticipantStatusDto; }, subscriptionId), writeCache: (k, payload) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.campaignParticipantStatus[k] = { optedIn: payload.optedIn, + participantCount: payload.participantCount, lastFetched: Date.now(), }; }); @@ -3748,7 +3687,7 @@ export class RewardsController extends BaseController< * @param subscriptionId - The subscription ID to invalidate cache for */ invalidateReferralDetailsCache(subscriptionId: string): void { - this.update((state: RewardsControllerState) => { + this.update((state) => { Object.keys(state.subscriptionReferralDetails).forEach((key) => { if (key.includes(subscriptionId)) { delete state.subscriptionReferralDetails[key]; @@ -3774,7 +3713,7 @@ export class RewardsController extends BaseController< seasonId, subscriptionId, ); - this.update((state: RewardsControllerState) => { + this.update((state) => { delete state.seasonStatuses[compositeKey]; delete state.unlockedRewards[compositeKey]; delete state.activeBoosts[compositeKey]; @@ -3783,7 +3722,7 @@ export class RewardsController extends BaseController< }); } else { // Invalidate all seasons for this subscription - this.update((state: RewardsControllerState) => { + this.update((state) => { Object.keys(state.seasonStatuses).forEach((key) => { if (key.includes(subscriptionId)) { delete state.seasonStatuses[key]; diff --git a/app/core/Engine/controllers/rewards-controller/index.ts b/app/core/Engine/controllers/rewards-controller/index.ts index d2977d0d2f1..52c0be1a1a6 100644 --- a/app/core/Engine/controllers/rewards-controller/index.ts +++ b/app/core/Engine/controllers/rewards-controller/index.ts @@ -2,7 +2,6 @@ import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings' import { selectBitcoinRewardsEnabledFlag, selectTronRewardsEnabledFlag, - selectSnapshotsRewardsEnabledFlag, selectCampaignsRewardsEnabledFlag, } from '../../../../selectors/featureFlagController/rewards/rewardsEnabled'; import type { ControllerInitFunction } from '../../types'; @@ -35,7 +34,6 @@ export const rewardsControllerInit: ControllerInitFunction< }, isBitcoinOptinEnabled: () => selectBitcoinRewardsEnabledFlag(getState()), isTronOptinEnabled: () => selectTronRewardsEnabledFlag(getState()), - isSnapshotsEnabled: () => selectSnapshotsRewardsEnabledFlag(getState()), isCampaignsEnabled: () => selectCampaignsRewardsEnabledFlag(getState()), }); diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index d2dd985b486..2c6629b2597 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -20,7 +20,6 @@ import type { DiscoverSeasonsDto, SeasonMetadataDto, LineaTokenRewardDto, - SnapshotDto, CampaignDto, } from '../types'; import { getSubscriptionToken } from '../utils/multi-subscription-token-vault'; @@ -1177,11 +1176,6 @@ describe('RewardsDataService', () => { service.getUnlockedRewards('season-1', 'sub-1'), ).rejects.toBeInstanceOf(AuthorizationFailedError); - mockFetch.mockResolvedValue(mockResponse); - await expect( - service.getSnapshots('season-1', 'sub-1'), - ).rejects.toBeInstanceOf(AuthorizationFailedError); - mockFetch.mockResolvedValue(mockResponse); await expect(service.optOut('sub-1')).rejects.toBeInstanceOf( AuthorizationFailedError, @@ -4220,178 +4214,6 @@ describe('RewardsDataService', () => { }); }); - describe('getSnapshots', () => { - const mockSeasonId = 'season-123'; - const mockSubscriptionId = 'sub-456'; - const mockToken = 'test-bearer-token'; - - const mockSnapshotsResponse: SnapshotDto[] = [ - { - id: '01974010-377f-7553-a365-0c33c8130980', - seasonId: mockSeasonId, - name: 'Monad Airdrop', - description: 'Earn Monad tokens by participating in the airdrop', - tokenSymbol: 'MONAD', - tokenAmount: '50000000000000000000000', - tokenChainId: '1', - tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - calculatedAt: '2025-03-16T00:00:00.000Z', - distributedAt: '2025-03-20T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - }, - { - id: '02985121-488g-8664-b476-1d44d9241091', - seasonId: mockSeasonId, - name: 'ETH Rewards', - tokenSymbol: 'ETH', - tokenAmount: '1000000000000000000', - tokenChainId: '1', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - receivingBlockchain: 'Ethereum', - opensAt: '2025-04-01T00:00:00.000Z', - closesAt: '2025-04-15T00:00:00.000Z', - }, - ]; - - beforeEach(() => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockSnapshotsResponse), - } as unknown as Response; - mockGetSubscriptionToken.mockResolvedValue({ - success: true, - token: mockToken, - }); - mockFetch.mockResolvedValue(mockResponse); - }); - - it('should successfully get snapshots', async () => { - // Act - const result = await service.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - // Assert - expect(mockGetSubscriptionToken).toHaveBeenCalledWith(mockSubscriptionId); - expect(mockFetch).toHaveBeenCalledWith( - `https://uat.rewards.test/v1/seasons/${mockSeasonId}/snapshots`, - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - 'Accept-Language': 'en-US', - 'Content-Type': 'application/json', - 'rewards-client-id': 'mobile-7.50.1', - }), - credentials: 'omit', - }), - ); - expect(result).toEqual(mockSnapshotsResponse); - expect(result).toHaveLength(2); - expect(result[0].id).toBe('01974010-377f-7553-a365-0c33c8130980'); - expect(result[0].name).toBe('Monad Airdrop'); - expect(result[1].name).toBe('ETH Rewards'); - }); - - it('should handle empty snapshots array', async () => { - // Arrange - const emptyResponse: SnapshotDto[] = []; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(emptyResponse), - } as unknown as Response; - mockFetch.mockResolvedValue(mockResponse); - - // Act - const result = await service.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - // Assert - expect(result).toEqual([]); - expect(result).toHaveLength(0); - }); - - it('should throw error when response is not ok', async () => { - // Arrange - const mockResponse = { - ok: false, - status: 404, - } as Response; - mockFetch.mockResolvedValue(mockResponse); - - // Act & Assert - await expect( - service.getSnapshots(mockSeasonId, mockSubscriptionId), - ).rejects.toThrow('Get snapshots failed: 404'); - }); - - it('should throw error when response is 500', async () => { - // Arrange - const mockResponse = { - ok: false, - status: 500, - } as Response; - mockFetch.mockResolvedValue(mockResponse); - - // Act & Assert - await expect( - service.getSnapshots(mockSeasonId, mockSubscriptionId), - ).rejects.toThrow('Get snapshots failed: 500'); - }); - - it('should throw error when fetch fails', async () => { - // Arrange - const fetchError = new Error('Network error'); - mockFetch.mockRejectedValue(fetchError); - - // Act & Assert - await expect( - service.getSnapshots(mockSeasonId, mockSubscriptionId), - ).rejects.toThrow('Network error'); - }); - - it('should handle different season IDs correctly', async () => { - // Arrange - const differentSeasonId = 'current-season'; - - // Act - await service.getSnapshots(differentSeasonId, mockSubscriptionId); - - // Assert - expect(mockFetch).toHaveBeenCalledWith( - `https://uat.rewards.test/v1/seasons/${differentSeasonId}/snapshots`, - expect.any(Object), - ); - }); - - it('should include subscription token in authentication', async () => { - // Act - await service.getSnapshots(mockSeasonId, mockSubscriptionId); - - // Assert - expect(mockGetSubscriptionToken).toHaveBeenCalledWith(mockSubscriptionId); - expect(mockFetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: expect.objectContaining({ - 'rewards-client-id': 'mobile-7.50.1', - }), - }), - ); - }); - }); - describe('getSubscriptionAccounts', () => { const mockSubscriptionId = 'sub-456'; const mockToken = 'test-bearer-token'; @@ -4500,6 +4322,7 @@ describe('RewardsDataService', () => { termsAndConditions: null, excludedRegions: [], statusLabel: 'Active', + details: null, }, ]; @@ -4564,7 +4387,7 @@ describe('RewardsDataService', () => { const mockSubscriptionId = 'sub-456'; const mockCampaignId = 'campaign-789'; const mockToken = 'test-bearer-token'; - const mockStatusResponse = { optedIn: true }; + const mockStatusResponse = { optedIn: true, participantCount: 42 }; beforeEach(() => { mockGetSubscriptionToken.mockResolvedValue({ @@ -4595,12 +4418,29 @@ describe('RewardsDataService', () => { expect(result).toEqual(mockStatusResponse); }); - it('throws when response is not ok', async () => { - mockFetch.mockResolvedValue({ ok: false, status: 409 } as Response); + it('returns participant status when 409 response (already opted in)', async () => { + const mockParticipantStatus = { optedIn: true, participantCount: 10 }; + mockFetch + .mockResolvedValueOnce({ ok: false, status: 409 } as Response) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockParticipantStatus), + } as unknown as Response); + + const result = await service.optInToCampaign( + mockSubscriptionId, + mockCampaignId, + ); + + expect(result).toEqual(mockParticipantStatus); + }); + + it('throws when response is not ok with non-409 status', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 } as Response); await expect( service.optInToCampaign(mockSubscriptionId, mockCampaignId), - ).rejects.toThrow('Opt-in to campaign failed: 409'); + ).rejects.toThrow('Opt-in to campaign failed: 500'); }); }); @@ -4608,7 +4448,7 @@ describe('RewardsDataService', () => { const mockSubscriptionId = 'sub-456'; const mockCampaignId = 'campaign-789'; const mockToken = 'test-bearer-token'; - const mockStatusResponse = { optedIn: false }; + const mockStatusResponse = { optedIn: false, participantCount: 0 }; beforeEach(() => { mockGetSubscriptionToken.mockResolvedValue({ diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index 8745cb6f7a1..8c3a7113a20 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -25,7 +25,6 @@ import type { LineaTokenRewardDto, ApplyReferralDto, ApplyBonusCodeDto, - SnapshotDto, CampaignDto, CampaignParticipantStatusDto, } from '../types'; @@ -195,11 +194,6 @@ export interface RewardsDataServiceApplyBonusCodeAction { handler: RewardsDataService['applyBonusCode']; } -export interface RewardsDataServiceGetSnapshotsAction { - type: `${typeof SERVICE_NAME}:getSnapshots`; - handler: RewardsDataService['getSnapshots']; -} - export interface RewardsDataServiceGetSubscriptionAccountsAction { type: `${typeof SERVICE_NAME}:getSubscriptionAccounts`; handler: RewardsDataService['getSubscriptionAccounts']; @@ -262,7 +256,6 @@ export type RewardsDataServiceActions = | RewardsDataServiceGetSeasonMetadataAction | RewardsDataServiceGetSeasonOneLineaRewardTokensAction | RewardsDataServiceApplyReferralCodeAction - | RewardsDataServiceGetSnapshotsAction | RewardsDataServiceGetRewardsEnvUrlAction | RewardsDataServiceCanChangeRewardsEnvUrlAction | RewardsDataServiceSetRewardsEnvUrlAction @@ -403,10 +396,6 @@ export class RewardsDataService { `${SERVICE_NAME}:applyBonusCode`, this.applyBonusCode.bind(this), ); - this.#messenger.registerActionHandler( - `${SERVICE_NAME}:getSnapshots`, - this.getSnapshots.bind(this), - ); this.#messenger.registerActionHandler( `${SERVICE_NAME}:getSubscriptionAccounts`, this.getSubscriptionAccounts.bind(this), @@ -1272,31 +1261,6 @@ export class RewardsDataService { } } - /** - * Get snapshots for a specific season. - * @param seasonId - The ID of the season to get snapshots for. - * @param subscriptionId - The subscription ID for authentication. - * @returns The list of snapshots for the season. - */ - async getSnapshots( - seasonId: string, - subscriptionId: string, - ): Promise { - const response = await this.makeRequest( - `/v1/seasons/${seasonId}/snapshots`, - { - method: 'GET', - }, - subscriptionId, - ); - - if (!response.ok) { - throw new Error(`Get snapshots failed: ${response.status}`); - } - - return (await response.json()) as SnapshotDto[]; - } - /** * Get CAIP-10 encoded account addresses linked to the current subscription. * @param subscriptionId - The subscription ID for authentication. @@ -1360,6 +1324,11 @@ export class RewardsDataService { subscriptionId, ); + if (response.status === 409) { + // Already opted in — fetch and return current status as a graceful success + return this.getCampaignParticipantStatus(subscriptionId, campaignId); + } + if (!response.ok) { throw new Error(`Opt-in to campaign failed: ${response.status}`); } diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts index 374e1176b14..26d83d51553 100644 --- a/app/core/Engine/controllers/rewards-controller/types.ts +++ b/app/core/Engine/controllers/rewards-controller/types.ts @@ -144,6 +144,12 @@ export interface CampaignDto { * @example 'Active' */ statusLabel: string; + + /** + * The details of the campaign + * @example { image: { lightModeUrl: 'https://example.com/image.png', darkModeUrl: 'https://example.com/image-dark.png' }, howItWorks: { title: 'How it works', description: 'How it works', phases: [{ name: 'Phase 1', daysLabel: 'Days', sortOrder: 1, steps: [{ title: 'Step 1', description: 'Step 1', iconName: 'icon-name' }] }] } } + */ + details: CampaignDetails | null; } // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -157,6 +163,27 @@ export type CampaignsState = { termsAndConditions: Json | null; excludedRegions: string[]; statusLabel: string; + details: { + image: { + lightModeUrl: string; + darkModeUrl: string; + }; + howItWorks: { + title: string; + description: string; + phases: { + name: string; + daysLabel: string; + sortOrder: number; + steps: { + title: string; + description: string; + iconName: string; + }[]; + }[]; + notes?: Json | null; + }; + } | null; }[]; lastFetched: number; }; @@ -167,116 +194,55 @@ export type CampaignsState = { export interface CampaignParticipantStatusDto { /** Whether the subscription has opted into the campaign */ optedIn: boolean; + + /** + * The number of participants in the campaign + * @example 100 + */ + participantCount: number; } // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type CampaignParticipantStatusState = { optedIn: boolean; + participantCount: number; lastFetched: number; }; -/** - * DTO for snapshot data from the backend - */ -export interface SnapshotDto { - /** - * The unique identifier of the snapshot - * @example '01974010-377f-7553-a365-0c33c8130980' - */ - id: string; - - /** - * The season ID this snapshot belongs to - * @example '7444682d-9050-43b8-9038-28a6a62d6264' - */ - seasonId: string; +export interface OndoCampaignStep { + title: string; + description: string; + iconName: string; +} - /** - * The name of the snapshot/airdrop - * @example 'Monad Airdrop' - */ +export interface OndoCampaignPhase { name: string; + daysLabel: string; + sortOrder: number; + steps: OndoCampaignStep[]; +} - /** - * Optional description of the snapshot - * @example 'Earn Monad tokens by participating in the airdrop' - */ - description?: string; - - /** - * The token symbol being distributed - * @example 'MONAD' - */ - tokenSymbol: string; - - /** - * The token amount as a serialized bigint string - * @example '50000000000000000000000' - */ - tokenAmount: string; - - /** - * The chain ID as a serialized bigint string - * @example '1' - */ - tokenChainId: string; - - /** - * Optional token contract address - * @example '0x1234567890abcdef1234567890abcdef12345678' - */ - tokenAddress?: string; - - /** - * The blockchain where tokens will be distributed - * @example 'Ethereum' - */ - receivingBlockchain: string; - - /** - * When the snapshot opens (ISO date string) - * @example '2025-03-01T00:00:00.000Z' - */ - opensAt: string; - - /** - * When the snapshot closes (ISO date string) - * @example '2025-03-15T00:00:00.000Z' - */ - closesAt: string; - - /** - * When results were calculated (ISO date string) - * @example '2025-03-16T00:00:00.000Z' - */ - calculatedAt?: string; - - /** - * When tokens were distributed (ISO date string) - * @example '2025-03-20T00:00:00.000Z' - */ - distributedAt?: string; +export interface OndoCampaignHowItWorks { + title: string; + description: string; + phases: OndoCampaignPhase[]; + notes?: Json | null; +} - /** - * Background image for the snapshot tile - */ - backgroundImage: ThemeImage; +export interface OndoHoldingDetails { + image: ThemeImage; + howItWorks: OndoCampaignHowItWorks; } +export type CampaignDetails = OndoHoldingDetails; + /** - * Snapshot status derived from dates - * - upcoming: now < opensAt - * - live: opensAt <= now < closesAt - * - calculating: closesAt <= now && !calculatedAt - * - distributing: calculatedAt && !distributedAt - * - complete: distributedAt is set + * Campaign status derived from dates + * - upcoming: now < startDate + * - active: startDate <= now < endDate + * - complete: now >= endDate */ -export type SnapshotStatus = - | 'upcoming' - | 'live' - | 'calculating' - | 'distributing' - | 'complete'; +export type CampaignStatus = 'upcoming' | 'active' | 'complete'; export interface EstimateAssetDto { /** @@ -930,30 +896,6 @@ export type OffDeviceSubscriptionAccountsState = { lastFetched: number; }; -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type SnapshotsState = { - snapshots: { - id: string; - seasonId: string; - name: string; - description?: string; - tokenSymbol: string; - tokenAmount: string; - tokenChainId: string; - tokenAddress?: string; - receivingBlockchain: string; - opensAt: string; - closesAt: string; - calculatedAt?: string; - distributedAt?: string; - backgroundImage: { - lightModeUrl: string; - darkModeUrl: string; - }; - }[]; - lastFetched: number; -}; - // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type PointsEventsDtoState = { results: { @@ -1286,7 +1228,6 @@ export type RewardsControllerState = { activeBoosts: { [compositeId: string]: ActiveBoostsState }; unlockedRewards: { [compositeId: string]: UnlockedRewardsState }; pointsEvents: { [compositeId: string]: PointsEventsDtoState }; - snapshots: { [seasonId: string]: SnapshotsState }; offDeviceSubscriptionAccounts: { [subscriptionId: string]: OffDeviceSubscriptionAccountsState; }; @@ -1670,14 +1611,6 @@ export interface RewardsControllerGetCampaignParticipantStatusAction { ) => Promise; } -/** - * Action for getting snapshots for a season - */ -export interface RewardsControllerGetSnapshotsAction { - type: 'RewardsController:getSnapshots'; - handler: (seasonId: string, subscriptionId: string) => Promise; -} - /** * Action for getting CAIP-10 accounts linked to a subscription that are not on this device */ @@ -1782,7 +1715,6 @@ export type RewardsControllerActions = | RewardsControllerGetCampaignsAction | RewardsControllerOptInToCampaignAction | RewardsControllerGetCampaignParticipantStatusAction - | RewardsControllerGetSnapshotsAction | RewardsControllerGetOffDeviceSubscriptionAccountsAction | RewardsControllerClaimRewardAction | RewardsControllerGetSeasonOneLineaRewardTokensAction diff --git a/app/core/Engine/controllers/smart-transactions-controller-init.test.ts b/app/core/Engine/controllers/smart-transactions-controller-init.test.ts index 7d5c4e05c37..c689e052011 100644 --- a/app/core/Engine/controllers/smart-transactions-controller-init.test.ts +++ b/app/core/Engine/controllers/smart-transactions-controller-init.test.ts @@ -51,7 +51,71 @@ describe('SmartTransactionsControllerInit', () => { clientId: 'mobile', getMetaMetricsProps: expect.any(Function), trackMetaMetricsEvent: expect.any(Function), + getBearerToken: expect.any(Function), trace: expect.any(Function), }); }); + + describe('getBearerToken', () => { + it('passes getter that returns token when AuthenticationController returns one', async () => { + const bearerToken = 'test-bearer-token'; + const request = getInitRequestMock(); + const mockCall = jest.fn().mockResolvedValue(bearerToken); + jest.spyOn(request.initMessenger, 'call').mockImplementation(mockCall); + + smartTransactionsControllerInit(request); + + const controllerMock = jest.mocked(SmartTransactionsController); + const constructorCall = + controllerMock.mock.calls[controllerMock.mock.calls.length - 1][0]; + const getBearerToken = constructorCall.getBearerToken as () => Promise< + string | undefined + >; + + const result = await getBearerToken(); + + expect(result).toBe(bearerToken); + expect(mockCall).toHaveBeenCalledWith( + 'AuthenticationController:getBearerToken', + ); + }); + + it('passes getter that returns undefined when AuthenticationController returns undefined', async () => { + const request = getInitRequestMock(); + const mockCall = jest.fn().mockResolvedValue(undefined); + jest.spyOn(request.initMessenger, 'call').mockImplementation(mockCall); + + smartTransactionsControllerInit(request); + + const controllerMock = jest.mocked(SmartTransactionsController); + const constructorCall = + controllerMock.mock.calls[controllerMock.mock.calls.length - 1][0]; + const getBearerToken = constructorCall.getBearerToken as () => Promise< + string | undefined + >; + + const result = await getBearerToken(); + + expect(result).toBeUndefined(); + }); + + it('passes getter that returns undefined when AuthenticationController throws', async () => { + const request = getInitRequestMock(); + const mockCall = jest.fn().mockRejectedValue(new Error('auth error')); + jest.spyOn(request.initMessenger, 'call').mockImplementation(mockCall); + + smartTransactionsControllerInit(request); + + const controllerMock = jest.mocked(SmartTransactionsController); + const constructorCall = + controllerMock.mock.calls[controllerMock.mock.calls.length - 1][0]; + const getBearerToken = constructorCall.getBearerToken as () => Promise< + string | undefined + >; + + const result = await getBearerToken(); + + expect(result).toBeUndefined(); + }); + }); }); diff --git a/app/core/Engine/controllers/smart-transactions-controller-init.ts b/app/core/Engine/controllers/smart-transactions-controller-init.ts index 86ec551eae0..2d7186d7a45 100644 --- a/app/core/Engine/controllers/smart-transactions-controller-init.ts +++ b/app/core/Engine/controllers/smart-transactions-controller-init.ts @@ -12,6 +12,7 @@ import type { SmartTransactionsControllerInitMessenger } from '../messengers/sma import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; import { trace } from '../../../util/trace'; import { getAllowedSmartTransactionsChainIds } from '../../../constants/smartTransactions'; +import { setSentinelApiAuth } from '../../../util/transactions/sentinel-api'; /** * Initialize the smart transactions controller. @@ -46,6 +47,24 @@ export const smartTransactionsControllerInit: ControllerInitFunction< } }; + /** + * Bearer token for Transaction API (and Sentinel) authentication. Only present when + * the user is signed in (AuthenticationController has a valid session). If getBearerToken + * returns undefined, no Authorization header is sent on smart transaction API calls. + */ + const getBearerToken = async (): Promise => { + try { + return await Promise.resolve( + initMessenger.call('AuthenticationController:getBearerToken'), + ); + } catch { + return undefined; + } + }; + + // Use same bearer token for Sentinel API (networks, relay) as for Transaction API + setSentinelApiAuth(getBearerToken); + const controller = new SmartTransactionsController({ messenger: controllerMessenger, state: persistedState.SmartTransactionsController, @@ -55,6 +74,7 @@ export const smartTransactionsControllerInit: ControllerInitFunction< // transactions. getMetaMetricsProps: () => Promise.resolve({}), trackMetaMetricsEvent, + getBearerToken, // @ts-expect-error: Type of `TraceRequest` is different. trace, diff --git a/app/core/Engine/controllers/snaps/execution-service-init.test.ts b/app/core/Engine/controllers/snaps/execution-service-init.test.ts index 2f4c505cb53..7c1c73dcd48 100644 --- a/app/core/Engine/controllers/snaps/execution-service-init.test.ts +++ b/app/core/Engine/controllers/snaps/execution-service-init.test.ts @@ -7,7 +7,7 @@ import { import { executionServiceInit } from './execution-service-init'; import { buildControllerInitRequestMock } from '../../utils/test-utils'; import { ExtendedMessenger } from '../../../ExtendedMessenger'; -// eslint-disable-next-line import/no-nodejs-modules +// eslint-disable-next-line import-x/no-nodejs-modules import { Duplex } from 'stream'; import { SnapBridge } from '../../../Snaps'; import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; diff --git a/app/core/Engine/controllers/snaps/execution-service-init.ts b/app/core/Engine/controllers/snaps/execution-service-init.ts index cbd26234ae1..cbca37c42c9 100644 --- a/app/core/Engine/controllers/snaps/execution-service-init.ts +++ b/app/core/Engine/controllers/snaps/execution-service-init.ts @@ -1,5 +1,5 @@ import { AbstractExecutionService } from '@metamask/snaps-controllers'; -// eslint-disable-next-line import/no-nodejs-modules +// eslint-disable-next-line import-x/no-nodejs-modules import { Duplex } from 'stream'; import { ControllerInitFunction } from '../../types'; import { ExecutionServiceMessenger } from '../../messengers/snaps'; diff --git a/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts b/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts index b8008d651b7..0ddf9ccfb82 100644 --- a/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts +++ b/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts @@ -3,7 +3,7 @@ import { merge } from 'lodash'; import { createProjectLogger } from '@metamask/utils'; import { TRANSACTION_EVENTS } from '../../../../Analytics/events/confirmations'; -import { IMetaMetricsEvent } from '../../../../Analytics/MetaMetrics.types'; +import { IMetaMetricsEvent } from '../../../../../util/analytics/analytics.types'; import { AnalyticsEventBuilder } from '../../../../../util/analytics/AnalyticsEventBuilder'; import { generateEvent, retryIfEngineNotInitialized } from '../utils'; import type { diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/batch.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/batch.ts index f35f2a7c2b8..c54fdaba420 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/batch.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/batch.ts @@ -10,7 +10,7 @@ import type { TransactionMetrics, TransactionMetricsBuilderRequest, } from '../types'; -import { JsonMap } from '../../../../Analytics/MetaMetrics.types'; +import { JsonMap } from '../../../../../util/analytics/analytics.types'; import { getMethodData } from '../../../../../util/transactions'; import { EIP5792ErrorCode } from '../../../../../constants/transaction'; diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts index 81a6c3c06cd..e3237c380ce 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts @@ -484,4 +484,75 @@ describe('Metamask Pay Metrics', () => { sensitiveProperties: {}, }); }); + + describe('mm_pay_time_to_complete_s', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('adds mm_pay_time_to_complete_s for finalized parent MM Pay transaction', () => { + jest.spyOn(Date, 'now').mockReturnValue(1060500); + + request.transactionMeta.type = TransactionType.perpsDeposit; + request.transactionMeta.submittedTime = 1000000; + + const result = getMetaMaskPayProperties(request) as TransactionMetrics; + + expect(result.properties).toStrictEqual( + expect.objectContaining({ + mm_pay_time_to_complete_s: 60.5, + }), + ); + }); + + it('adds mm_pay_time_to_complete_s for finalized child transaction using parent submittedTime', () => { + jest.spyOn(Date, 'now').mockReturnValue(2045123); + + request.allTransactions = [ + { + id: 'parent-1', + type: TransactionType.perpsDeposit, + requiredTransactionIds: ['child-1'], + submittedTime: 2000000, + } as TransactionMeta, + ]; + + const result = getMetaMaskPayProperties(request) as TransactionMetrics; + + expect(result.properties).toStrictEqual( + expect.objectContaining({ + mm_pay_time_to_complete_s: 45.123, + }), + ); + }); + + it('does not add mm_pay_time_to_complete_s for non-finalized events', () => { + request.eventType = TRANSACTION_EVENTS.TRANSACTION_SUBMITTED; + request.transactionMeta.type = TransactionType.perpsDeposit; + request.transactionMeta.submittedTime = 1000000; + + const result = getMetaMaskPayProperties(request) as TransactionMetrics; + + expect(result.properties).not.toHaveProperty('mm_pay_time_to_complete_s'); + }); + + it('does not add mm_pay_time_to_complete_s when submittedTime is undefined', () => { + request.transactionMeta.type = TransactionType.perpsDeposit; + + const result = getMetaMaskPayProperties(request) as TransactionMetrics; + + expect(result.properties).not.toHaveProperty('mm_pay_time_to_complete_s'); + }); + + it('does not add mm_pay_time_to_complete_s for non-MM-Pay transactions', () => { + jest.spyOn(Date, 'now').mockReturnValue(1060000); + + request.transactionMeta.type = TransactionType.contractInteraction; + request.transactionMeta.submittedTime = 1000000; + + const result = getMetaMaskPayProperties(request) as TransactionMetrics; + + expect(result.properties).not.toHaveProperty('mm_pay_time_to_complete_s'); + }); + }); }); diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts index a8dda5facb1..b5a55ce1bae 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts @@ -3,7 +3,7 @@ import { TransactionType, } from '@metamask/transaction-controller'; import { TransactionMetricsBuilder } from '../types'; -import { JsonMap } from '../../../../Analytics/MetaMetrics.types'; +import { JsonMap } from '../../../../../util/analytics/analytics.types'; import { orderBy } from 'lodash'; import { NATIVE_TOKEN_ADDRESS } from '../../../../../components/Views/confirmations/constants/tokens'; import { hasTransactionType } from '../../../../../components/Views/confirmations/utils/transaction'; @@ -15,6 +15,7 @@ import { import { RootState } from '../../../../../reducers'; import { selectSingleTokenByAddressAndChainId } from '../../../../../selectors/tokensController'; import { Hex } from '@metamask/utils'; +import { TRANSACTION_EVENTS } from '../../../../Analytics/events/confirmations'; const FOUR_BYTE_SAFE_PROXY_CREATE = '0xa1884d2c'; @@ -34,6 +35,7 @@ const PAY_TYPES = [ ]; export const getMetaMaskPayProperties: TransactionMetricsBuilder = ({ + eventType, transactionMeta, allTransactions, getUIMetrics, @@ -58,6 +60,10 @@ export const getMetaMaskPayProperties: TransactionMetricsBuilder = ({ if (hasTransactionType(transactionMeta, PAY_TYPES) || !parentTransaction) { addFallbackProperties(properties, transactionMeta, getState()); + if (hasTransactionType(transactionMeta, PAY_TYPES) || properties.mm_pay) { + addTimeToComplete(properties, eventType, transactionMeta.submittedTime); + } + return { properties, sensitiveProperties, @@ -127,12 +133,30 @@ export const getMetaMaskPayProperties: TransactionMetricsBuilder = ({ } } + addTimeToComplete(properties, eventType, parentTransaction.submittedTime); + return { properties, sensitiveProperties, }; }; +function addTimeToComplete( + properties: JsonMap, + eventType: Parameters[0]['eventType'], + submittedTime: number | undefined, +) { + if ( + eventType !== TRANSACTION_EVENTS.TRANSACTION_FINALIZED || + typeof submittedTime !== 'number' + ) { + return; + } + + properties.mm_pay_time_to_complete_s = + Math.round(Date.now() - submittedTime) / 1000; +} + function addFallbackProperties( properties: JsonMap, transaction: TransactionMeta, diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/security-alert-response.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/security-alert-response.ts index eea96c67eed..b2efe11be28 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/security-alert-response.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/security-alert-response.ts @@ -1,7 +1,7 @@ import type { SecurityAlertResponse } from '@metamask/transaction-controller'; import { ResultType } from '../../../../../components/Views/confirmations/constants/signatures'; -import type { JsonMap } from '../../../../Analytics/MetaMetrics.types'; +import type { JsonMap } from '../../../../../util/analytics/analytics.types'; import type { TransactionMetrics, TransactionMetricsBuilderRequest, diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/simulation-values.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/simulation-values.ts index 8332a1b4a56..1746f106ce1 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/simulation-values.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/simulation-values.ts @@ -1,5 +1,5 @@ import { TransactionMetricsBuilder } from '../types'; -import { JsonMap } from '../../../../Analytics/MetaMetrics.types'; +import { JsonMap } from '../../../../../util/analytics/analytics.types'; /** * Gets simulation asset fiat values for transaction metrics from TransactionMeta.assetsFiatValues. diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts index 9aefd2770d6..166551e4a1a 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts @@ -14,8 +14,10 @@ import { ORIGIN_METAMASK, toHex } from '@metamask/controller-utils'; import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; import { Hex } from '@metamask/utils'; import { selectShouldUseSmartTransaction } from '../../../../selectors/smartTransactionsController'; +import { selectMetaMaskPayFlags } from '../../../../selectors/featureFlagController/confirmations'; import { getGlobalChainId } from '../../../../util/networks/global-network'; import { submitSmartTransactionHook } from '../../../../util/smart-transactions/smart-publish-hook'; +import { accountSupports7702 } from '../../../../util/transactions/account-supports-7702'; import { Delegation7702PublishHook } from '../../../../util/transactions/hooks/delegation-7702-publish'; import { isSendBundleSupported } from '../../../../util/transactions/sentinel-api'; import { ExtendedMessenger } from '../../../ExtendedMessenger'; @@ -38,9 +40,11 @@ jest.mock('../../../../selectors/smartTransactionsController'); jest.mock('../../../../util/networks/global-network'); jest.mock('../../../../util/smart-transactions/smart-publish-hook'); jest.mock('./event-handlers/metrics'); +jest.mock('../../../../util/transactions/account-supports-7702'); jest.mock('../../../../util/transactions/hooks/delegation-7702-publish'); jest.mock('../../../../util/transactions/sentinel-api'); jest.mock('@metamask/transaction-pay-controller'); +jest.mock('../../../../selectors/featureFlagController/confirmations'); jest.mock('../../../../util/transactions', () => ({ getTransactionById: jest.fn((_id) => ({ @@ -139,7 +143,9 @@ describe('Transaction Controller Init', () => { const handleTransactionAddedEventForMetricsMock = jest.mocked( handleTransactionAddedEventForMetrics, ); + const accountSupports7702Mock = jest.mocked(accountSupports7702); const isSendBundleSupportedMock = jest.mocked(isSendBundleSupported); + const selectMetaMaskPayFlagsMock = jest.mocked(selectMetaMaskPayFlags); const payHookClassMock = jest.mocked(TransactionPayPublishHook); const payHookMock: jest.MockedFn = jest.fn(); @@ -172,6 +178,14 @@ describe('Transaction Controller Init', () => { selectShouldUseSmartTransactionMock.mockReturnValue(true); getGlobalChainIdMock.mockReturnValue('0x1'); isSendBundleSupportedMock.mockResolvedValue(true); + selectMetaMaskPayFlagsMock.mockReturnValue({ + attemptsMax: 2, + bufferInitial: 0.025, + bufferStep: 0.025, + bufferSubsequent: 0.05, + slippage: 0.005, + stxDisabled: false, + }); payHookClassMock.mockReturnValue({ getHook: () => payHookMock, @@ -315,6 +329,40 @@ describe('Transaction Controller Init', () => { expect(payHookMock).toHaveBeenCalledTimes(1); }); + + it('passes isSmartTransaction returning false to pay hook when stxDisabled is true', async () => { + selectMetaMaskPayFlagsMock.mockReturnValue({ + attemptsMax: 2, + bufferInitial: 0.025, + bufferStep: 0.025, + bufferSubsequent: 0.05, + slippage: 0.005, + stxDisabled: true, + }); + + const hooks = testConstructorOption('hooks'); + await hooks?.publish?.(MOCK_TRANSACTION_META); + + const { isSmartTransaction } = payHookClassMock.mock.calls[0][0]; + expect(isSmartTransaction('0x1')).toBe(false); + }); + + it('passes isSmartTransaction returning true to pay hook when stxDisabled is false', async () => { + selectMetaMaskPayFlagsMock.mockReturnValue({ + attemptsMax: 2, + bufferInitial: 0.025, + bufferStep: 0.025, + bufferSubsequent: 0.05, + slippage: 0.005, + stxDisabled: false, + }); + + const hooks = testConstructorOption('hooks'); + await hooks?.publish?.(MOCK_TRANSACTION_META); + + const { isSmartTransaction } = payHookClassMock.mock.calls[0][0]; + expect(isSmartTransaction('0x1')).toBe(true); + }); }); describe('publishBatch hook', () => { @@ -377,6 +425,7 @@ describe('Transaction Controller Init', () => { let mockDelegation7702Hook: jest.MockedFn; beforeEach(() => { + accountSupports7702Mock.mockResolvedValue(true); payHookMock.mockResolvedValue({ transactionHash: undefined }); mockDelegation7702Hook = jest .fn() @@ -389,6 +438,20 @@ describe('Transaction Controller Init', () => { ); }); + it('skips Delegation7702PublishHook for hardware wallet accounts', async () => { + accountSupports7702Mock.mockResolvedValue(false); + selectShouldUseSmartTransactionMock.mockReturnValue(false); + + const hooks = testConstructorOption('hooks'); + await hooks?.publish?.({ + ...MOCK_TRANSACTION_META, + chainId: '0x13', + }); + + expect(Delegation7702PublishHookMock).not.toHaveBeenCalled(); + expect(mockDelegation7702Hook).not.toHaveBeenCalled(); + }); + it('falls back to Delegation7702PublishHook when smart transactions are disabled', async () => { selectShouldUseSmartTransactionMock.mockReturnValue(false); const hooks = testConstructorOption('hooks'); @@ -673,6 +736,7 @@ describe('Transaction Controller Init', () => { }); it('returns true if isExternalSign', async () => { + accountSupports7702Mock.mockResolvedValue(true); const mockTransactionMeta = { id: '123', status: 'approved', @@ -687,6 +751,7 @@ describe('Transaction Controller Init', () => { }); it('calls getNonceLock and releaseLock via Delegation7702PublishHook getNextNonce', async () => { + accountSupports7702Mock.mockResolvedValue(true); const releaseLockMock = jest.fn(); const getNonceLockMock = jest.fn().mockResolvedValue({ nextNonce: 99, @@ -728,6 +793,7 @@ describe('Transaction Controller Init', () => { }); it('calls 7702 publish hook if isExternalSign', async () => { + accountSupports7702Mock.mockResolvedValue(true); const delegation7702Mock: jest.MockedFn = jest.fn(); jest.mocked(Delegation7702PublishHook).mockImplementation( diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts index 26de36b390d..a758e7066b4 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts @@ -50,7 +50,9 @@ import { TransactionPayControllerMessenger, TransactionPayPublishHook, } from '@metamask/transaction-pay-controller'; +import { selectMetaMaskPayFlags } from '../../../../selectors/featureFlagController/confirmations'; import { trace } from '../../../../util/trace'; +import { accountSupports7702 } from '../../../../util/transactions/account-supports-7702'; import { Delegation7702PublishHook } from '../../../../util/transactions/hooks/delegation-7702-publish'; import { isSendBundleSupported } from '../../../../util/transactions/sentinel-api'; import { NetworkClientId } from '@metamask/network-controller'; @@ -109,6 +111,7 @@ export const TransactionControllerInit: ControllerInitFunction< publishHook({ transactionMeta, getState, + keyringController, transactionController, smartTransactionsController, initMessenger, @@ -133,6 +136,15 @@ export const TransactionControllerInit: ControllerInitFunction< isFirstTimeInteractionEnabled: () => isFirstTimeInteractionEnabled(preferencesController), isEIP7702GasFeeTokensEnabled: async (transactionMeta) => { + if ( + !(await accountSupports7702( + transactionMeta.txParams?.from, + keyringController as Parameters[1], + )) + ) { + return false; + } + const { chainId, isExternalSign } = transactionMeta; const state = getState(); @@ -190,6 +202,7 @@ async function getNextNonce( async function publishHook({ transactionMeta, getState, + keyringController, transactionController, smartTransactionsController, initMessenger, @@ -197,6 +210,7 @@ async function publishHook({ }: { transactionMeta: TransactionMeta; getState: () => RootState; + keyringController: Parameters[1]; transactionController: TransactionController; smartTransactionsController: SmartTransactionsController; initMessenger: TransactionControllerInitMessenger; @@ -210,8 +224,10 @@ async function publishHook({ transactionMeta.chainId, ); + const { stxDisabled } = selectMetaMaskPayFlags(state); + const payResult = await new TransactionPayPublishHook({ - isSmartTransaction: () => shouldUseSmartTransaction, + isSmartTransaction: () => shouldUseSmartTransaction && !stxDisabled, messenger: initMessenger as TransactionPayControllerMessenger, }).getHook()(transactionMeta, signedTransactionInHex); @@ -221,7 +237,15 @@ async function publishHook({ const { isExternalSign } = transactionMeta; - if (!shouldUseSmartTransaction || !sendBundleSupport || isExternalSign) { + const keyringSupports7702 = await accountSupports7702( + transactionMeta.txParams?.from, + keyringController, + ); + + if ( + keyringSupports7702 && + (!shouldUseSmartTransaction || !sendBundleSupport || isExternalSign) + ) { const hook = new Delegation7702PublishHook({ isAtomicBatchSupported: transactionController.isAtomicBatchSupported.bind( transactionController, diff --git a/app/core/Engine/controllers/transaction-controller/types.ts b/app/core/Engine/controllers/transaction-controller/types.ts index 2ca76ee5136..1fb284e3474 100644 --- a/app/core/Engine/controllers/transaction-controller/types.ts +++ b/app/core/Engine/controllers/transaction-controller/types.ts @@ -1,7 +1,7 @@ import { JsonMap, IMetaMetricsEvent, -} from '../../../Analytics/MetaMetrics.types'; +} from '../../../../util/analytics/analytics.types'; import { SmartTransactionsController } from '@metamask/smart-transactions-controller'; import type { RootState } from '../../../../reducers'; import { TransactionControllerInitMessenger } from '../../messengers/transaction-controller-messenger'; diff --git a/app/core/Engine/controllers/transaction-controller/utils.ts b/app/core/Engine/controllers/transaction-controller/utils.ts index a63fafce2ef..450e347e036 100644 --- a/app/core/Engine/controllers/transaction-controller/utils.ts +++ b/app/core/Engine/controllers/transaction-controller/utils.ts @@ -4,7 +4,7 @@ import { MetricsEventBuilder } from '../../../Analytics/MetricsEventBuilder'; import { JsonMap, IMetaMetricsEvent, -} from '../../../Analytics/MetaMetrics.types'; +} from '../../../../util/analytics/analytics.types'; import { TRANSACTION_EVENTS } from '../../../Analytics/events/confirmations'; import type { TransactionEventHandlerRequest, diff --git a/app/core/Engine/messengers/bridge-controller-messenger/index.ts b/app/core/Engine/messengers/bridge-controller-messenger/index.ts index 46f8a945f1e..ac8235ae594 100644 --- a/app/core/Engine/messengers/bridge-controller-messenger/index.ts +++ b/app/core/Engine/messengers/bridge-controller-messenger/index.ts @@ -36,6 +36,7 @@ export function getBridgeControllerMessenger( 'CurrencyRateController:getState', 'RemoteFeatureFlagController:getState', 'AuthenticationController:getBearerToken', + 'AssetsController:getExchangeRatesForBridge', ], events: [], messenger, diff --git a/app/core/Engine/messengers/bridge-status-controller-messenger/index.ts b/app/core/Engine/messengers/bridge-status-controller-messenger/index.ts index 9a1c1dbcd4c..dea3b75e85c 100644 --- a/app/core/Engine/messengers/bridge-status-controller-messenger/index.ts +++ b/app/core/Engine/messengers/bridge-status-controller-messenger/index.ts @@ -30,6 +30,7 @@ export function getBridgeStatusControllerMessenger( 'NetworkController:getNetworkClientById', 'NetworkController:findNetworkClientIdByChainId', 'NetworkController:getState', + 'KeyringController:signTypedMessage', 'BridgeController:stopPollingForQuotes', 'BridgeController:trackUnifiedSwapBridgeEvent', 'GasFeeController:getState', diff --git a/app/core/Engine/messengers/compliance/compliance-controller-messenger.ts b/app/core/Engine/messengers/compliance/compliance-controller-messenger.ts new file mode 100644 index 00000000000..92a69a2b8fe --- /dev/null +++ b/app/core/Engine/messengers/compliance/compliance-controller-messenger.ts @@ -0,0 +1,71 @@ +import { + Messenger, + type MessengerActions, + type MessengerEvents, +} from '@metamask/messenger'; +import type { ComplianceControllerMessenger } from '@metamask/compliance-controller'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { RootMessenger } from '../../types'; + +/** + * Get the messenger for the ComplianceController. + * + * Delegates ComplianceService actions so the controller can call + * the service through the messenger. + * + * @param rootMessenger - The root messenger. + * @returns The ComplianceControllerMessenger. + */ +export function getComplianceControllerMessenger( + rootMessenger: RootMessenger, +): ComplianceControllerMessenger { + const messenger = new Messenger< + 'ComplianceController', + MessengerActions, + MessengerEvents, + RootMessenger + >({ + namespace: 'ComplianceController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'ComplianceService:checkWalletCompliance', + 'ComplianceService:checkWalletsCompliance', + 'ComplianceService:updateBlockedWallets', + ], + messenger, + }); + return messenger; +} + +export type ComplianceControllerInitMessenger = ReturnType< + typeof getComplianceControllerInitMessenger +>; + +/** + * Get the init messenger for the ComplianceController. + * + * Provides access to RemoteFeatureFlagController state for feature flag checks. + * + * @param rootMessenger - The root messenger. + * @returns The ComplianceControllerInitMessenger. + */ +export function getComplianceControllerInitMessenger( + rootMessenger: RootMessenger, +) { + const messenger = new Messenger< + 'ComplianceControllerInit', + RemoteFeatureFlagControllerGetStateAction, + never, + RootMessenger + >({ + namespace: 'ComplianceControllerInit', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: ['RemoteFeatureFlagController:getState'], + messenger, + }); + return messenger; +} diff --git a/app/core/Engine/messengers/compliance/compliance-service-messenger.ts b/app/core/Engine/messengers/compliance/compliance-service-messenger.ts new file mode 100644 index 00000000000..b972a7b08de --- /dev/null +++ b/app/core/Engine/messengers/compliance/compliance-service-messenger.ts @@ -0,0 +1,27 @@ +import { + Messenger, + type MessengerActions, + type MessengerEvents, +} from '@metamask/messenger'; +import type { ComplianceServiceMessenger } from '@metamask/compliance-controller'; +import type { RootMessenger } from '../../types'; + +/** + * Get the messenger for the ComplianceService. + * + * @param rootMessenger - The root messenger. + * @returns The ComplianceServiceMessenger. + */ +export function getComplianceServiceMessenger( + rootMessenger: RootMessenger, +): ComplianceServiceMessenger { + return new Messenger< + 'ComplianceService', + MessengerActions, + MessengerEvents, + RootMessenger + >({ + namespace: 'ComplianceService', + parent: rootMessenger, + }); +} diff --git a/app/core/Engine/messengers/index.ts b/app/core/Engine/messengers/index.ts index 7a8ca246490..aec7a81818a 100644 --- a/app/core/Engine/messengers/index.ts +++ b/app/core/Engine/messengers/index.ts @@ -152,6 +152,11 @@ import { getProfileMetricsServiceMessenger } from './profile-metrics-service-mes import { getAnalyticsControllerMessenger } from './analytics-controller-messenger'; import { getAiDigestControllerMessenger } from './ai-digest-controller-messenger'; import { getCardControllerMessenger } from './card-controller-messenger'; +import { getComplianceServiceMessenger } from './compliance/compliance-service-messenger'; +import { + getComplianceControllerMessenger, + getComplianceControllerInitMessenger, +} from './compliance/compliance-controller-messenger'; /** * The messengers for the controllers that have been. @@ -475,4 +480,12 @@ export const CONTROLLER_MESSENGERS = { getMessenger: getCardControllerMessenger, getInitMessenger: noop, }, + ComplianceService: { + getMessenger: getComplianceServiceMessenger, + getInitMessenger: noop, + }, + ComplianceController: { + getMessenger: getComplianceControllerMessenger, + getInitMessenger: getComplianceControllerInitMessenger, + }, } as const; diff --git a/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts b/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts index e94dab2f47f..9b495a8adca 100644 --- a/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts +++ b/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts @@ -1,20 +1,7 @@ -import { - MOCK_ANY_NAMESPACE, - Messenger, - MessengerActions, - MessengerEvents, - MockAnyNamespace, -} from '@metamask/messenger'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; import { getProfileMetricsControllerMessenger } from './profile-metrics-controller-messenger'; -import { ProfileMetricsControllerMessenger } from '@metamask/profile-metrics-controller'; -type RootMessenger = Messenger< - MockAnyNamespace, - MessengerActions, - MessengerEvents ->; - -const getRootMessenger = (): RootMessenger => +const getRootMessenger = () => new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); @@ -27,4 +14,39 @@ describe('getProfileMetricsControllerMessenger', () => { expect(profileMetricsControllerMessenger).toBeInstanceOf(Messenger); }); + + it('delegates required actions to the messenger', () => { + const rootMessenger = getRootMessenger(); + const delegateSpy = jest.spyOn(rootMessenger, 'delegate'); + + getProfileMetricsControllerMessenger(rootMessenger); + + expect(delegateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + actions: expect.arrayContaining([ + 'AccountsController:getState', + 'ProfileMetricsService:submitMetrics', + ]), + }), + ); + }); + + it('delegates required events to the messenger', () => { + const rootMessenger = getRootMessenger(); + const delegateSpy = jest.spyOn(rootMessenger, 'delegate'); + + getProfileMetricsControllerMessenger(rootMessenger); + + expect(delegateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + events: expect.arrayContaining([ + 'AccountsController:accountAdded', + 'AccountsController:accountRemoved', + 'KeyringController:lock', + 'KeyringController:unlock', + 'TransactionController:transactionSubmitted', + ]), + }), + ); + }); }); diff --git a/app/core/Engine/messengers/profile-metrics-controller-messenger.ts b/app/core/Engine/messengers/profile-metrics-controller-messenger.ts index cc32b0a1f90..34a3a44391d 100644 --- a/app/core/Engine/messengers/profile-metrics-controller-messenger.ts +++ b/app/core/Engine/messengers/profile-metrics-controller-messenger.ts @@ -42,6 +42,7 @@ export function getProfileMetricsControllerMessenger(messenger: RootMessenger) { 'AccountsController:accountRemoved', 'KeyringController:lock', 'KeyringController:unlock', + 'TransactionController:transactionSubmitted', ], }); return profileMetricsControllerMessenger; diff --git a/app/core/Engine/messengers/ramps-controller-messenger/ramps-controller-messenger.ts b/app/core/Engine/messengers/ramps-controller-messenger/ramps-controller-messenger.ts index 3f6b438cc9d..a3b9171ff9b 100644 --- a/app/core/Engine/messengers/ramps-controller-messenger/ramps-controller-messenger.ts +++ b/app/core/Engine/messengers/ramps-controller-messenger/ramps-controller-messenger.ts @@ -7,7 +7,10 @@ import { MessengerActions, MessengerEvents, } from '@metamask/messenger'; -import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { + RemoteFeatureFlagControllerGetStateAction, + RemoteFeatureFlagControllerStateChangeEvent, +} from '@metamask/remote-feature-flag-controller'; import { RootMessenger } from '../../types'; type AllowedActions = MessengerActions; @@ -91,7 +94,8 @@ export function getRampsControllerInitMessenger(rootMessenger: RootMessenger) { const messenger = new Messenger< 'RampsControllerInit', RemoteFeatureFlagControllerGetStateAction, - RampsControllerOrderStatusChangedEvent, + | RampsControllerOrderStatusChangedEvent + | RemoteFeatureFlagControllerStateChangeEvent, RootMessenger >({ namespace: 'RampsControllerInit', @@ -100,7 +104,10 @@ export function getRampsControllerInitMessenger(rootMessenger: RootMessenger) { rootMessenger.delegate({ actions: ['RemoteFeatureFlagController:getState'], - events: ['RampsController:orderStatusChanged'], + events: [ + 'RampsController:orderStatusChanged', + 'RemoteFeatureFlagController:stateChange', // React when flags arrive (avoids race with async fetch) + ], messenger, }); diff --git a/app/core/Engine/messengers/rewards-controller-messenger/index.ts b/app/core/Engine/messengers/rewards-controller-messenger/index.ts index 661b65acfd4..7cd64254b90 100644 --- a/app/core/Engine/messengers/rewards-controller-messenger/index.ts +++ b/app/core/Engine/messengers/rewards-controller-messenger/index.ts @@ -48,7 +48,6 @@ import { RewardsDataServiceGetSeasonOneLineaRewardTokensAction, RewardsDataServiceApplyReferralCodeAction, RewardsDataServiceApplyBonusCodeAction, - RewardsDataServiceGetSnapshotsAction, RewardsDataServiceGetRewardsEnvUrlAction, RewardsDataServiceCanChangeRewardsEnvUrlAction, RewardsDataServiceSetRewardsEnvUrlAction, @@ -91,7 +90,6 @@ type AllowedActions = | RewardsDataServiceGetSeasonMetadataAction | RewardsDataServiceGetSeasonOneLineaRewardTokensAction | RewardsDataServiceApplyReferralCodeAction - | RewardsDataServiceGetSnapshotsAction | RewardsDataServiceGetRewardsEnvUrlAction | RewardsDataServiceCanChangeRewardsEnvUrlAction | RewardsDataServiceSetRewardsEnvUrlAction @@ -157,7 +155,6 @@ export function getRewardsControllerMessenger( 'RewardsDataService:getSeasonOneLineaRewardTokens', 'RewardsDataService:applyReferralCode', 'RewardsDataService:applyBonusCode', - 'RewardsDataService:getSnapshots', 'RewardsDataService:getSubscriptionAccounts', 'RewardsDataService:getCampaigns', 'RewardsDataService:optInToCampaign', diff --git a/app/core/Engine/messengers/smart-transactions-controller-messenger.test.ts b/app/core/Engine/messengers/smart-transactions-controller-messenger.test.ts index f61293252d3..8af3005d1b2 100644 --- a/app/core/Engine/messengers/smart-transactions-controller-messenger.test.ts +++ b/app/core/Engine/messengers/smart-transactions-controller-messenger.test.ts @@ -37,7 +37,6 @@ describe('getSmartTransactionsControllerMessenger', () => { expect(delegateSpy).toHaveBeenCalledWith( expect.objectContaining({ actions: expect.arrayContaining([ - 'ErrorReportingService:captureException', 'NetworkController:getNetworkClientById', 'NetworkController:getState', 'RemoteFeatureFlagController:getState', diff --git a/app/core/Engine/messengers/smart-transactions-controller-messenger.ts b/app/core/Engine/messengers/smart-transactions-controller-messenger.ts index fc7e6654e0f..93ceda1b52d 100644 --- a/app/core/Engine/messengers/smart-transactions-controller-messenger.ts +++ b/app/core/Engine/messengers/smart-transactions-controller-messenger.ts @@ -6,6 +6,7 @@ import { import { RootMessenger } from '../types'; import { SmartTransactionsControllerMessenger } from '@metamask/smart-transactions-controller'; import { AnalyticsControllerActions } from '@metamask/analytics-controller'; +import { AuthenticationController } from '@metamask/profile-sync-controller'; /** * Get the messenger for the smart transactions controller. This is scoped to the @@ -28,7 +29,6 @@ export function getSmartTransactionsControllerMessenger( }); rootMessenger.delegate({ actions: [ - 'ErrorReportingService:captureException', 'NetworkController:getNetworkClientById', 'NetworkController:getState', 'RemoteFeatureFlagController:getState', @@ -46,7 +46,8 @@ export function getSmartTransactionsControllerMessenger( } type SmartTransactionsControllerInitMessengerActions = - AnalyticsControllerActions; + | AnalyticsControllerActions + | AuthenticationController.AuthenticationControllerGetBearerTokenAction; /** * Get the SmartTransactionsControllerInitMessenger for the SmartTransactionsController. @@ -78,7 +79,10 @@ export function getSmartTransactionsControllerInitMessenger( }); rootMessenger.delegate({ - actions: ['AnalyticsController:trackEvent'], + actions: [ + 'AnalyticsController:trackEvent', + 'AuthenticationController:getBearerToken', + ], events: [], messenger, }); diff --git a/app/core/Engine/messengers/snaps/snap-controller-messenger.ts b/app/core/Engine/messengers/snaps/snap-controller-messenger.ts index 43f961172e7..b1f12139a56 100644 --- a/app/core/Engine/messengers/snaps/snap-controller-messenger.ts +++ b/app/core/Engine/messengers/snaps/snap-controller-messenger.ts @@ -33,8 +33,8 @@ import { UpdateCaveat, } from '@metamask/permission-controller'; import { - AddApprovalRequest, - UpdateRequestState, + ApprovalControllerAddRequestAction, + ApprovalControllerUpdateRequestStateAction, } from '@metamask/approval-controller'; import { KeyringControllerLockEvent, @@ -63,8 +63,8 @@ type Actions = | RevokePermissions | RevokePermissionForAllSubjects | GetSubjects - | AddApprovalRequest - | UpdateRequestState + | ApprovalControllerAddRequestAction + | ApprovalControllerUpdateRequestStateAction | GrantPermissions | GetSubjectMetadata | UpdateCaveat diff --git a/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts b/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts index 30be40b78a4..b5cffc6c3e3 100644 --- a/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts +++ b/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts @@ -43,6 +43,7 @@ import { CurrencyRateControllerActions, } from '@metamask/assets-controllers'; import { + TransactionPayControllerGetDelegationTransactionAction, TransactionPayControllerGetStateAction, TransactionPayControllerGetStrategyAction, } from '@metamask/transaction-pay-controller'; @@ -107,6 +108,7 @@ type InitMessengerActions = | TransactionControllerAddTransactionBatchAction | TransactionControllerGetStateAction | TransactionControllerUpdateTransactionAction + | TransactionPayControllerGetDelegationTransactionAction | TransactionPayControllerGetStateAction | TransactionPayControllerGetStrategyAction | AnalyticsControllerActions; @@ -163,6 +165,7 @@ export function getTransactionControllerInitMessenger( 'TransactionController:addTransactionBatch', 'TransactionController:getState', 'TransactionController:updateTransaction', + 'TransactionPayController:getDelegationTransaction', 'TransactionPayController:getState', 'TransactionPayController:getStrategy', 'AnalyticsController:trackEvent', diff --git a/app/core/Engine/messengers/transaction-pay-controller-messenger/transaction-pay-controller-messenger.ts b/app/core/Engine/messengers/transaction-pay-controller-messenger/transaction-pay-controller-messenger.ts index 82caa797531..0ff3b876c17 100644 --- a/app/core/Engine/messengers/transaction-pay-controller-messenger/transaction-pay-controller-messenger.ts +++ b/app/core/Engine/messengers/transaction-pay-controller-messenger/transaction-pay-controller-messenger.ts @@ -24,6 +24,7 @@ export function getTransactionPayControllerMessenger( rootMessenger.delegate({ actions: [ 'AccountTrackerController:getState', + 'AssetsController:getStateForTransactionPay', 'BridgeController:fetchQuotes', 'BridgeStatusController:submitTx', 'CurrencyRateController:getState', diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index 932e03518fe..2af99d23182 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -428,6 +428,15 @@ import { AiDigestControllerEvents, AiDigestControllerState, } from '@metamask/ai-controllers'; +import { + ComplianceController, + ComplianceControllerActions, + ComplianceControllerEvents, + ComplianceControllerState, + ComplianceService, + ComplianceServiceActions, + ComplianceServiceEvents, +} from '@metamask/compliance-controller'; /** * Controllers that area always instantiated @@ -440,6 +449,7 @@ type RequiredControllers = Omit< | 'RewardsDataService' | 'SnapKeyringBuilder' | 'StorageService' + | 'ComplianceService' >; /** @@ -453,6 +463,7 @@ type OptionalControllers = Pick< | 'RewardsDataService' | 'SnapKeyringBuilder' | 'StorageService' + | 'ComplianceService' >; type PermissionsByRpcMethod = ReturnType; @@ -552,6 +563,8 @@ type GlobalActions = | RampsControllerActions | RampsServiceActions | AiDigestControllerActions + | ComplianceControllerActions + | ComplianceServiceActions | TransakServiceActions; type GlobalEvents = @@ -631,6 +644,8 @@ type GlobalEvents = | RampsControllerEvents | RampsServiceEvents | AiDigestControllerEvents + | ComplianceControllerEvents + | ComplianceServiceEvents | TransakServiceEvents; /** @@ -752,6 +767,8 @@ export type Controllers = { ProfileMetricsService: ProfileMetricsService; RampsService: RampsService; AiDigestController: AiDigestController; + ComplianceService: ComplianceService; + ComplianceController: ComplianceController; TransakService: TransakService; }; @@ -834,6 +851,7 @@ export type EngineState = { DelegationController: DelegationControllerState; ProfileMetricsController: ProfileMetricsControllerState; AiDigestController: AiDigestControllerState; + ComplianceController: ComplianceControllerState; }; /** Controller names */ @@ -945,7 +963,9 @@ export type ControllersToInitialize = | 'ProfileMetricsController' | 'ProfileMetricsService' | 'AnalyticsController' - | 'AiDigestController'; + | 'AiDigestController' + | 'ComplianceService' + | 'ComplianceController'; /** * Callback that returns a controller messenger for a specific controller. diff --git a/app/core/Engine/utils/analytics.ts b/app/core/Engine/utils/analytics.ts index 1ba4fb991d6..86c50996567 100644 --- a/app/core/Engine/utils/analytics.ts +++ b/app/core/Engine/utils/analytics.ts @@ -1,10 +1,10 @@ import type { ControllerMessenger } from '../types'; import type { AnalyticsTrackingEvent } from '@metamask/analytics-controller'; import type { + AnalyticsUnfilteredProperties, IMetaMetricsEvent, ITrackingEvent, -} from '../../../core/Analytics/MetaMetrics.types'; -import type { AnalyticsUnfilteredProperties } from '../../../util/analytics/analytics.types'; +} from '../../../util/analytics/analytics.types'; import Logger from '../../../util/Logger'; import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; diff --git a/app/core/Engine/utils/test/logger.test.ts b/app/core/Engine/utils/test/logger.test.ts index 855646ebc5e..26260353778 100644 --- a/app/core/Engine/utils/test/logger.test.ts +++ b/app/core/Engine/utils/test/logger.test.ts @@ -44,6 +44,7 @@ describe('logEngineCreation', () => { accounts: {}, selectedAccount: '', }, + accountIdByAddress: {}, }, }; @@ -87,6 +88,7 @@ describe('logEngineCreation', () => { accounts: {}, selectedAccount: '', }, + accountIdByAddress: {}, }, KeyringController: { vault: 'test-vault', diff --git a/app/core/EntryScriptWeb3.js b/app/core/EntryScriptWeb3.js index ce50405d651..bdc6899781e 100644 --- a/app/core/EntryScriptWeb3.js +++ b/app/core/EntryScriptWeb3.js @@ -1,4 +1,4 @@ -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as FileSystem from 'expo-file-system'; const EntryScriptWeb3 = { diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.test.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.test.tsx index 0a1d0c22ae3..176b6483393 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.test.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.test.tsx @@ -57,19 +57,18 @@ describe('ConnectingContent', () => { expect(getByTestId(CONNECTING_CONTENT_TEST_ID)).toBeOnTheScreen(); }); - it('renders activity indicator', () => { + it('renders spinner', () => { const { getByTestId } = renderComponent(); expect(getByTestId(CONNECTING_CONTENT_SPINNER_TEST_ID)).toBeOnTheScreen(); }); - it('renders tips', () => { + it('renders connection tips', () => { const { getByText } = renderComponent(); expect( getByText('hardware_wallet.connecting.tips_header'), ).toBeOnTheScreen(); - // All tips are rendered with { device: deviceName } interpolation params expect( getByText(/hardware_wallet\.connecting\.tip_unlock/), ).toBeOnTheScreen(); @@ -79,8 +78,5 @@ describe('ConnectingContent', () => { expect( getByText(/hardware_wallet\.connecting\.tip_enable_bluetooth/), ).toBeOnTheScreen(); - expect( - getByText(/hardware_wallet\.connecting\.tip_dnd_off/), - ).toBeOnTheScreen(); }); }); diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.tsx index fd4ba6edb89..7a43ca3b923 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.tsx @@ -1,15 +1,10 @@ import React from 'react'; -import { View, StyleSheet } from 'react-native'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; import Text, { TextVariant, TextColor, } from '../../../../../component-library/components/Texts/Text'; -import Button, { - ButtonVariants, - ButtonSize, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import { strings } from '../../../../../../locales/i18n'; import { HardwareWalletType } from '@metamask/hw-wallet-sdk'; @@ -18,11 +13,15 @@ import { getConnectionTipsForWalletType, } from '../../../helpers'; import { ContentLayout } from './ContentLayout'; +import { useTheme } from '../../../../../util/theme'; export const CONNECTING_CONTENT_TEST_ID = 'connecting-content'; export const CONNECTING_CONTENT_SPINNER_TEST_ID = 'connecting-content-spinner'; const styles = StyleSheet.create({ + tipsHeader: { + marginBottom: 8, + }, tipItem: { flexDirection: 'row', marginBottom: 8, @@ -34,6 +33,10 @@ const styles = StyleSheet.create({ tipText: { flex: 1, }, + spinnerContainer: { + alignItems: 'center', + paddingVertical: 16, + }, }); export interface ConnectingContentProps { @@ -44,6 +47,7 @@ export interface ConnectingContentProps { export const ConnectingContent: React.FC = ({ deviceType, }) => { + const { colors } = useTheme(); const deviceName = getHardwareWalletTypeName(deviceType); const connectionTips = getConnectionTipsForWalletType(deviceType); @@ -54,46 +58,46 @@ export const ConnectingContent: React.FC = ({ device: deviceName, })} body={ - connectionTips.length > 0 ? ( - - - {strings('hardware_wallet.connecting.tips_header')} - + + {connectionTips.length > 0 && ( + + + {strings('hardware_wallet.connecting.tips_header')} + - {connectionTips.map((tipKey) => ( - - - • - - - {strings(tipKey, { device: deviceName })} - - - ))} + {connectionTips.map((tipKey) => ( + + + • + + + {strings(tipKey, { device: deviceName })} + + + ))} + + )} + + + - ) : undefined - } - footer={ -