From 2f327cd276c4e5c55e4bed407f2eeca55c0a64a0 Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:45:24 +0200 Subject: [PATCH 1/3] feat: add price alerts basic setup (#31477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds the initial implementation of **Price Alerts** behind the `priceAlertsEnabled` remote feature flag (version-gated). When enabled, a notification icon appears in the Token Details header for any token with a known price. Tapping it opens a manage view that either shows existing alerts or drops straight into the create flow if none exist yet. The create screen lets users set a target price via a keypad or quick-percentage pills (5/10/20/30%), toggle whether the alert repeats, and see a live ± % indicator against the current price. On save the alert is POSTed to `https://price-alerts.api.cx.metamask.io` using a bearer token from `AuthenticationController`. Delete and toggle-active actions in the manage view are stubbed for a follow-up PR, as is the "Price change" alert type tab. ## **Changelog** CHANGELOG entry: add price alerts basic setup ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3351 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** No price alerts support ### **After** https://github.com/user-attachments/assets/142f2842-0cd5-4692-b013-00566257e36b ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > New authenticated backend integration (bearer token) for user alert CRUD on token flows; mitigated by remote flag gating and incomplete manage mutations (delete/toggle TODO). > > **Overview** > Introduces **price alerts** behind the version-gated remote flag `priceAlertsEnabled`. When on, Token Details shows a notification control (only if price > 0 and CAIP-19 `assetId` resolves) that opens **manage** or **create** flows for that asset. > > **Create** supports “price reaches” with a keypad, quick ±5/10/20/30% pills, recurring toggle, and live % vs current price; saves via authenticated `POST` to the price-alerts API (`asset`, `threshold`, `recurring`). **Manage** loads alerts per asset (React Query), replaces into create when the list is empty or fetch fails, and lists thresholds with recurring/once labels—**delete** and **active toggle** are UI-only stubs. A second tab (“price change”) shows an under-development placeholder. > > Wiring adds `PRICE_ALERTS_API_URL` / `AppConstants.PRICE_ALERTS_API`, navigation routes and screens, i18n, feature-flag registry entry, and broad unit test coverage. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8ee8d6bd8c985e40c835e1c064bd18c02e9fec17. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .js.env.example | 3 + app/components/Nav/Main/MainNavigator.js | 10 + .../UI/AssetOverview/TokenOverview.testIds.ts | 1 + .../CreatePriceAlertView.test.tsx | 384 +++++++++++++++ .../CreatePriceAlertView.tsx | 441 ++++++++++++++++++ .../ManagePriceAlertsView.test.tsx | 261 +++++++++++ .../ManagePriceAlertsView.tsx | 208 +++++++++ .../UI/Assets/PriceAlerts/api.test.ts | 266 +++++++++++ app/components/UI/Assets/PriceAlerts/api.ts | 45 ++ .../components/AlertTypeToggle.test.tsx | 131 ++++++ .../components/AlertTypeToggle.tsx | 146 ++++++ .../UI/Assets/PriceAlerts/constants.ts | 74 +++ .../TokenDetails/Views/TokenDetails.test.tsx | 126 ++++- .../UI/TokenDetails/Views/TokenDetails.tsx | 29 ++ .../TokenDetailsInlineHeader.test.tsx | 55 +++ .../components/TokenDetailsInlineHeader.tsx | 24 +- app/constants/navigation/Routes.ts | 2 + app/core/AppConstants.ts | 7 + .../priceAlerts/index.test.ts | 151 ++++++ .../priceAlerts/index.ts | 19 + builds.yml | 9 + locales/languages/en.json | 19 + scripts/apply-build-config.js | 1 + tests/feature-flags/feature-flag-registry.ts | 11 + 24 files changed, 2417 insertions(+), 6 deletions(-) create mode 100644 app/components/UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView.test.tsx create mode 100644 app/components/UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView.tsx create mode 100644 app/components/UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView.test.tsx create mode 100644 app/components/UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView.tsx create mode 100644 app/components/UI/Assets/PriceAlerts/api.test.ts create mode 100644 app/components/UI/Assets/PriceAlerts/api.ts create mode 100644 app/components/UI/Assets/PriceAlerts/components/AlertTypeToggle.test.tsx create mode 100644 app/components/UI/Assets/PriceAlerts/components/AlertTypeToggle.tsx create mode 100644 app/components/UI/Assets/PriceAlerts/constants.ts create mode 100644 app/selectors/featureFlagController/priceAlerts/index.test.ts create mode 100644 app/selectors/featureFlagController/priceAlerts/index.ts diff --git a/.js.env.example b/.js.env.example index b8ab9dd52ffa..a6a3c96bef1e 100644 --- a/.js.env.example +++ b/.js.env.example @@ -80,6 +80,9 @@ export DECODING_API_URL: 'https://signature-insights.api.cx.metamask.io/v1' # URL of security alerts API used to validate dApp requests. export SECURITY_ALERTS_API_URL="https://security-alerts.api.cx.metamask.io" +# URL of the price alerts API. Override to http://localhost:3333 for local dev. +export PRICE_ALERTS_API_URL="https://price-alerts.dev-api.cx.metamask.io" + # URL of the compliance API used for wallet compliance checks. export COMPLIANCE_API_URL="https://compliance.api.cx.metamask.io" diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index cda3a0e809a7..816e800bae2d 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -171,6 +171,8 @@ import RewardsSelectSheet from '../../UI/Rewards/components/RewardsSelectSheet'; import SitesFullView from '../../Views/SitesFullView/SitesFullView'; import { TokenDetails } from '../../UI/TokenDetails/Views/TokenDetails'; +import CreatePriceAlertView from '../../UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView'; +import ManagePriceAlertsView from '../../UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView'; import BenefitFullView from '../../UI/Rewards/Views/BenefitFullView'; import BenefitsFullView from '../../UI/Rewards/Views/BenefitsFullView'; import MoneyTabPressTracker from '../../UI/Money/components/MoneyTabPressTracker'; @@ -211,6 +213,14 @@ const AssetStackFlow = (props) => ( name={Routes.TRANSACTION_DETAILS} component={TransactionDetails} /> + + ); diff --git a/app/components/UI/AssetOverview/TokenOverview.testIds.ts b/app/components/UI/AssetOverview/TokenOverview.testIds.ts index 96a32a92e177..ff257f57a518 100644 --- a/app/components/UI/AssetOverview/TokenOverview.testIds.ts +++ b/app/components/UI/AssetOverview/TokenOverview.testIds.ts @@ -3,6 +3,7 @@ import enContent from '../../../../locales/languages/en.json'; export const TokenOverviewSelectorsIDs = { CONTAINER: 'token-asset-overview', TOKEN_PRICE: 'token-price', + PRICE_ALERT_BUTTON: 'token-price-alert-button', SEND_BUTTON: 'token-send-button', RECEIVE_BUTTON: 'token-receive-button', BUY_BUTTON: 'token-buy-button', diff --git a/app/components/UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView.test.tsx b/app/components/UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView.test.tsx new file mode 100644 index 000000000000..c76038c23062 --- /dev/null +++ b/app/components/UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView.test.tsx @@ -0,0 +1,384 @@ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react-native'; +import CreatePriceAlertView from './CreatePriceAlertView'; +import { CreatePriceAlertTestIds } from '../../constants'; + +const mockGoBack = jest.fn(); +const mockPop = jest.fn(); +const mockSave = jest.fn(); +const mockShowToast = jest.fn(); +const mockCloseToast = jest.fn(); +const mockUseSavePriceAlert = jest.fn(() => ({ + save: mockSave, + isSubmitting: false, +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ goBack: mockGoBack, pop: mockPop }), + useRoute: () => ({ + params: { + symbol: 'ETH', + ticker: 'ETH', + currentPrice: 1201.98, + currentCurrency: 'USD', + assetId: 'eip155:1/slip44:60', + }, + }), +})); + +jest.mock('../../api', () => ({ + useSavePriceAlert: () => mockUseSavePriceAlert(), +})); + +import { ToastContext } from '../../../../../../component-library/components/Toast'; + +function WithToast({ children }: { children: React.ReactNode }) { + const ref = React.useRef({ + showToast: mockShowToast, + closeToast: mockCloseToast, + }); + return ( + + {children} + + ); +} + +const renderWithToast = () => + render( + + + , + ); + +const setRoute = (params: object) => + jest + .spyOn(jest.requireMock('@react-navigation/native'), 'useRoute') + .mockReturnValue({ params }); + +describe('CreatePriceAlertView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSave.mockResolvedValue(undefined); + mockUseSavePriceAlert.mockImplementation(() => ({ + save: mockSave, + isSubmitting: false, + })); + }); + + it('renders the create alert title and current price subtitle', () => { + const { getByText, getAllByText } = renderWithToast(); + expect(getByText('Create ETH price alert')).toBeOnTheScreen(); + // price appears in the header subtitle and as the keypad placeholder + expect(getAllByText('$1,201.98').length).toBeGreaterThanOrEqual(2); + }); + + it('renders the price reaches experience by default', () => { + const { getByTestId, getByText } = renderWithToast(); + + expect(getByText('Enter target price')).toBeOnTheScreen(); + expect( + getByTestId(CreatePriceAlertTestIds.TARGET_PRICE_INPUT), + ).toBeOnTheScreen(); + expect( + getByTestId(CreatePriceAlertTestIds.RECURRING_TOGGLE), + ).toBeOnTheScreen(); + expect( + getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-5`), + ).toBeOnTheScreen(); + }); + + it('shows percentage pickers and hides Set button before any input', () => { + const { getByTestId, queryByTestId } = renderWithToast(); + + expect( + getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-5`), + ).toBeOnTheScreen(); + expect(queryByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON)).toBeNull(); + }); + + it('hides percentage pickers and shows Set button once user types a digit', () => { + const { getByTestId, queryByTestId } = renderWithToast(); + + fireEvent.press(getByTestId('keypad-key-1')); + + expect( + queryByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-5`), + ).toBeNull(); + expect( + getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON), + ).toBeOnTheScreen(); + }); + + it('keeps percentage pickers and hides Set button for zero-valued keypad input like "0."', () => { + const { getByTestId, getByText, queryByTestId } = renderWithToast(); + + fireEvent.press(getByTestId('keypad-key-dot')); + + expect(getByText('$0.')).toBeOnTheScreen(); + expect( + getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-5`), + ).toBeOnTheScreen(); + expect(queryByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON)).toBeNull(); + }); + + it('shows Set button after a quick-percentage pill is pressed', () => { + const { getByTestId } = renderWithToast(); + + fireEvent.press( + getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-10`), + ); + + expect( + getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON), + ).toBeOnTheScreen(); + }); + + it('shows under development message when price change is selected', () => { + const { getByTestId, getByText } = renderWithToast(); + + fireEvent.press(getByTestId(CreatePriceAlertTestIds.PRICE_CHANGE_TAB)); + + expect( + getByTestId(CreatePriceAlertTestIds.UNDER_DEVELOPMENT), + ).toBeOnTheScreen(); + expect( + getByText('This experience is currently under development'), + ).toBeOnTheScreen(); + }); + + it('updates the displayed price when a quick-percentage pill is pressed', () => { + const { getByTestId, getByText } = renderWithToast(); + + fireEvent.press( + getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-10`), + ); + + // 1201.98 * 1.10 = 1322.178 → stripped to 6 sig figs → "1322.18" + expect(getByText('$1322.18')).toBeOnTheScreen(); + }); + + it('shows raw digits without forced decimals while typing', () => { + const { getByTestId, getByText } = renderWithToast(); + + fireEvent.press(getByTestId('keypad-key-1')); + + expect(getByText('$1')).toBeOnTheScreen(); + }); + + it('shows ≈ 0% when no target has been entered', () => { + const { getByTestId } = renderWithToast(); + expect(getByTestId(CreatePriceAlertTestIds.PERCENT_DIFF)).toHaveTextContent( + '≈ 0%', + ); + }); + + it('shows the positive percentage and "above" wording when target exceeds current price', () => { + const { getByTestId, getByText } = renderWithToast(); + + // 5% above 1201.98 + fireEvent.press( + getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-5`), + ); + + expect(getByText('5%')).toBeOnTheScreen(); + expect(getByText(/above current ETH price/)).toBeOnTheScreen(); + }); + + it('shows the percentage and "below" wording when target is less than current price', () => { + const { getByTestId, getByText } = renderWithToast(); + + // $1000 is ~17% below 1201.98 + fireEvent.press(getByTestId('keypad-key-1')); + fireEvent.press(getByTestId('keypad-key-0')); + fireEvent.press(getByTestId('keypad-key-0')); + fireEvent.press(getByTestId('keypad-key-0')); + + expect(getByText(/below current ETH price/)).toBeOnTheScreen(); + }); + + describe('Set price alert button', () => { + it('calls save with the correct asset, threshold, and recurring values', async () => { + const { getByTestId } = renderWithToast(); + + fireEvent.press(getByTestId('keypad-key-1')); + fireEvent.press(getByTestId('keypad-key-5')); + fireEvent.press(getByTestId('keypad-key-0')); + fireEvent.press(getByTestId('keypad-key-0')); + + await act(async () => { + fireEvent.press(getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON)); + }); + + expect(mockSave).toHaveBeenCalledWith({ + asset: 'eip155:1/slip44:60', + threshold: 1500, + recurring: true, + }); + }); + + it('passes recurring=false when the toggle is switched off', async () => { + const { getByTestId } = renderWithToast(); + + fireEvent( + getByTestId(CreatePriceAlertTestIds.RECURRING_TOGGLE), + 'valueChange', + false, + ); + fireEvent.press(getByTestId('keypad-key-2')); + + await act(async () => { + fireEvent.press(getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON)); + }); + + expect(mockSave).toHaveBeenCalledWith( + expect.objectContaining({ recurring: false }), + ); + }); + + it('navigates back and shows a success toast on save', async () => { + const { getByTestId } = renderWithToast(); + + fireEvent.press(getByTestId('keypad-key-1')); + + await act(async () => { + fireEvent.press(getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON)); + }); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + hasNoTimeout: false, + labelOptions: expect.arrayContaining([ + expect.objectContaining({ label: expect.stringContaining('ETH') }), + ]), + }), + ); + }); + + it('pops two screens instead of goBack when opened from the manage list', async () => { + setRoute({ + symbol: 'ETH', + ticker: 'ETH', + currentPrice: 1201.98, + currentCurrency: 'USD', + assetId: 'eip155:1/slip44:60', + fromManage: true, + }); + + const { getByTestId } = renderWithToast(); + fireEvent.press(getByTestId('keypad-key-1')); + + await act(async () => { + fireEvent.press(getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON)); + }); + + expect(mockPop).toHaveBeenCalledWith(2); + expect(mockGoBack).not.toHaveBeenCalled(); + }); + + it('does not navigate or show toast when save throws', async () => { + mockSave.mockRejectedValueOnce(new Error('HTTP 500')); + const { getByTestId } = renderWithToast(); + + fireEvent.press(getByTestId('keypad-key-1')); + + await act(async () => { + fireEvent.press(getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON)); + }); + + expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('does not call save a second time when the button is pressed while already submitting', async () => { + // The real hook sets isSubmitting=true and the Button becomes disabled+loading, + // preventing re-presses. Here we verify the component only calls save once + // even if the underlying hook guard is bypassed in tests. + let resolveFirst!: () => void; + mockSave.mockReturnValueOnce( + new Promise((res) => { + resolveFirst = res; + }), + ); + + const { getByTestId } = renderWithToast(); + fireEvent.press(getByTestId('keypad-key-1')); + fireEvent.press(getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON)); + + await act(async () => { + resolveFirst(); + }); + + expect(mockSave).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('CreatePriceAlertView — tiny price token', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSave.mockResolvedValue(undefined); + setRoute({ + symbol: 'SHIB', + ticker: 'SHIB', + currentPrice: 0.00000000000001, + currentCurrency: 'USD', + assetId: 'eip155:1/erc20:0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', + }); + }); + + it('quick-percentage pill shows a plain decimal string (never scientific notation)', () => { + const { getByTestId, getByText } = render(); + + fireEvent.press( + getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-5`), + ); + + // 0.00000000000001 * 1.05 = 1.05e-14 → must render as plain decimal + expect(getByText('$0.0000000000000105')).toBeOnTheScreen(); + }); + + it('quick-percentage pill produces a non-zero value and reveals the Set button', () => { + const { getByTestId } = render(); + + fireEvent.press( + getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-5`), + ); + + expect( + getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON), + ).toBeOnTheScreen(); + }); + + it('allows manual keypad entry beyond two decimal places for sub-cent tokens', () => { + setRoute({ + symbol: 'SHIB', + ticker: 'SHIB', + currentPrice: 0.00001234, + currentCurrency: 'USD', + assetId: 'eip155:1/erc20:0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', + }); + + const { getByTestId, getByText } = render(); + + // Type 0.000012345 — blocked when keypad decimals are fixed at 2 + fireEvent.press(getByTestId('keypad-key-0')); + fireEvent.press(getByTestId('keypad-key-dot')); + fireEvent.press(getByTestId('keypad-key-0')); + fireEvent.press(getByTestId('keypad-key-0')); + fireEvent.press(getByTestId('keypad-key-0')); + fireEvent.press(getByTestId('keypad-key-0')); + fireEvent.press(getByTestId('keypad-key-1')); + fireEvent.press(getByTestId('keypad-key-2')); + fireEvent.press(getByTestId('keypad-key-3')); + fireEvent.press(getByTestId('keypad-key-4')); + fireEvent.press(getByTestId('keypad-key-5')); + + expect(getByText('$0.000012345')).toBeOnTheScreen(); + expect( + getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON), + ).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView.tsx b/app/components/UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView.tsx new file mode 100644 index 000000000000..b33056766ad7 --- /dev/null +++ b/app/components/UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView.tsx @@ -0,0 +1,441 @@ +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + Animated, + StyleSheet, + Switch, + Text as RNText, + View, +} from 'react-native'; +import { + useNavigation, + useRoute, + type RouteProp, +} from '@react-navigation/native'; +import type { StackNavigationProp } from '@react-navigation/stack'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Button, + ButtonVariant, + FontWeight, + HeaderStandard, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { strings } from '../../../../../../../locales/i18n'; +import { useTheme } from '../../../../../../util/theme'; +import KeypadComponent, { + type KeypadChangeData, +} from '../../../../../Base/Keypad'; +import { formatPriceWithSubscriptNotation } from '../../../../Predict/utils/format'; +import { + ToastContext, + ToastVariants, +} from '../../../../../../component-library/components/Toast'; +import { IconName } from '../../../../../../component-library/components/Icons/Icon'; +import AlertTypeToggle from '../../components/AlertTypeToggle'; +import { + CreatePriceAlertRouteParams, + CreatePriceAlertTestIds, + CURRENCY_SYMBOLS, + PRICE_ALERT_QUICK_PERCENTAGES, + PriceAlertType, +} from '../../constants'; +import { useSavePriceAlert } from '../../api'; +import { trimTrailingZeros } from '../../../../Bridge/utils/trimTrailingZeros'; + +const KEYPAD_EMPTY = '0'; +const MIN_KEYPAD_DECIMALS = 2; +/** Fractional offset that yields 6 significant figures via toFixed. */ +const SIG_FIGS_FRACTIONAL_OFFSET = 5; + +/** + * Max fractional digits the keypad should allow for a given USD price. + * Mirrors the precision used by {@link toKeypadString} so manual entry and + * quick-percentage pills stay consistent (e.g. sub-cent SHIB prices). + */ +const getKeypadDecimalPlaces = (price: number): number => { + if (!Number.isFinite(price) || price <= 0) { + return MIN_KEYPAD_DECIMALS; + } + + const exponent = Math.floor(Math.log10(price)); + return exponent >= 0 + ? Math.max(MIN_KEYPAD_DECIMALS, SIG_FIGS_FRACTIONAL_OFFSET - exponent) + : Math.abs(exponent) + SIG_FIGS_FRACTIONAL_OFFSET; +}; + +/** + * Converts a computed price into a plain decimal string suitable for the keypad. + * Always preserves 6 significant figures and never produces scientific notation. + * e.g. 0.3224 * 1.10 → "0.35464", 1.05e-14 → "0.0000000000000105". + */ +const toKeypadString = (price: number): string => { + if (!Number.isFinite(price) || price <= 0) return KEYPAD_EMPTY; + + const decimalPlaces = getKeypadDecimalPlaces(price); + const str = trimTrailingZeros(price.toFixed(decimalPlaces)); + + return str || KEYPAD_EMPTY; +}; + +const viewStyles = StyleSheet.create({ + priceText: { + fontSize: 48, + flexShrink: 1, + maxWidth: '95%', + }, + cursor: { + flexShrink: 0, + }, +}); + +const CreatePriceAlertView: React.FC = () => { + const tw = useTailwind(); + const { colors } = useTheme(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const navigation = useNavigation>(); + const route = + useRoute< + RouteProp< + { CreatePriceAlert: CreatePriceAlertRouteParams }, + 'CreatePriceAlert' + > + >(); + const { symbol, ticker, currentPrice, currentCurrency, assetId, fromManage } = + route.params; + + const displayTicker = ticker || symbol; + const [alertType, setAlertType] = useState( + PriceAlertType.PriceReaches, + ); + const [targetAmount, setTargetAmount] = useState(KEYPAD_EMPTY); + const [isRecurring, setIsRecurring] = useState(true); + const fadeAnim = useRef(new Animated.Value(1)).current; + + useEffect(() => { + const animation = Animated.loop( + Animated.sequence([ + Animated.timing(fadeAnim, { + toValue: 0, + duration: 500, + useNativeDriver: true, + }), + Animated.timing(fadeAnim, { + toValue: 1, + duration: 500, + useNativeDriver: true, + }), + ]), + ); + animation.start(); + return () => animation.stop(); + }, [fadeAnim]); + + const hasInput = targetAmount !== KEYPAD_EMPTY; + + const targetPrice = useMemo(() => { + const parsed = parseFloat(targetAmount); + return Number.isFinite(parsed) ? parsed : 0; + }, [targetAmount]); + + const hasValidTarget = targetPrice > 0; + + const percentDiff = useMemo(() => { + if (!hasInput || currentPrice <= 0 || targetPrice <= 0) { + return { rounded: 0, direction: 'none' as const }; + } + const percent = ((targetPrice - currentPrice) / currentPrice) * 100; + const rounded = Math.round(percent); + if (rounded > 0) return { rounded, direction: 'above' as const }; + if (rounded < 0) + return { rounded: Math.abs(rounded), direction: 'below' as const }; + return { rounded: 0, direction: 'none' as const }; + }, [hasInput, currentPrice, targetPrice]); + + // While typing, show the raw digits the user entered (e.g. "1", "12.", "12.5"). + // Only format with currency notation for the placeholder and after a quick-percentage press. + const displayText = useMemo(() => { + if (!hasInput) { + return formatPriceWithSubscriptNotation(currentPrice, currentCurrency); + } + // Prepend the currency symbol but keep the raw keypad string intact so + // no unexpected decimals appear while the user is mid-entry. + const currencySymbol = + CURRENCY_SYMBOLS[currentCurrency.toLowerCase()] ?? ''; + return `${currencySymbol}${targetAmount}`; + }, [hasInput, currentPrice, currentCurrency, targetAmount]); + + const formattedCurrentPrice = useMemo( + () => formatPriceWithSubscriptNotation(currentPrice, currentCurrency), + [currentCurrency, currentPrice], + ); + + const keypadDecimals = useMemo( + () => getKeypadDecimalPlaces(currentPrice), + [currentPrice], + ); + + const { save, isSubmitting } = useSavePriceAlert(); + const { toastRef } = useContext(ToastContext); + + const handleBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleSaveAlert = useCallback(async () => { + if (!hasValidTarget) { + return; + } + try { + await save({ + asset: assetId, + threshold: targetPrice, + recurring: isRecurring, + }); + toastRef?.current?.showToast({ + variant: ToastVariants.Icon, + iconName: IconName.Confirmation, + iconColor: colors.success.default, + labelOptions: [ + { + label: strings('price_alerts.save_success', { + ticker: displayTicker, + }), + }, + ], + hasNoTimeout: false, + }); + // If opened from the manage list, pop both CreatePriceAlert and ManagePriceAlerts + // so the user lands back on TokenDetails instead of the list. + if (fromManage) { + navigation.pop(2); + } else { + navigation.goBack(); + } + } catch { + // save() surfaces the error via its thrown rejection; nothing to do here + } + }, [ + save, + assetId, + targetPrice, + hasValidTarget, + isRecurring, + fromManage, + navigation, + toastRef, + colors, + displayTicker, + ]); + + const handleKeypadChange = useCallback(({ value }: KeypadChangeData) => { + setTargetAmount(value); + }, []); + + const handleQuickPercentagePress = useCallback( + (percentage: number) => { + if (currentPrice <= 0) { + return; + } + const nextPrice = currentPrice * (1 + percentage / 100); + setTargetAmount(toKeypadString(nextPrice)); + }, + [currentPrice], + ); + + return ( + + + + + + + {alertType === PriceAlertType.PriceReaches ? ( + <> + + + {strings('price_alerts.enter_target_price')} + + + + + {displayText} + + + + + + {percentDiff.direction === 'none' ? ( + strings('price_alerts.approx_percent', { percent: '0' }) + ) : ( + <> + {'≈ '} + + {`${percentDiff.rounded}%`} + + {` ${strings( + percentDiff.direction === 'above' + ? 'price_alerts.approx_percent_above' + : 'price_alerts.approx_percent_below', + { ticker: displayTicker }, + )}`} + + )} + + + + + {/* Recurring row — always visible above the bottom controls */} + + + {strings('price_alerts.recurring')} + + + + + {/* Quick-percentage pickers → hidden once a positive target is set */} + {hasValidTarget ? ( + + ) : ( + + {PRICE_ALERT_QUICK_PERCENTAGES.map((percentage) => ( + + ))} + + )} + + + + + ) : ( + + + {strings('price_alerts.price_change_under_development')} + + + )} + + + ); +}; + +export default CreatePriceAlertView; diff --git a/app/components/UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView.test.tsx b/app/components/UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView.test.tsx new file mode 100644 index 000000000000..6c92a94ff420 --- /dev/null +++ b/app/components/UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView.test.tsx @@ -0,0 +1,261 @@ +import React from 'react'; +import { render, act, fireEvent, waitFor } from '@testing-library/react-native'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { notifyManager } from '@tanstack/query-core'; +import ManagePriceAlertsView from './ManagePriceAlertsView'; +import { ManagePriceAlertsTestIds, type PriceAlert } from '../../constants'; +import Routes from '../../../../../../constants/navigation/Routes'; + +// Prevents act() warnings caused by useQuery's internal batched updates +notifyManager.setBatchNotifyFunction((callback: () => void) => { + callback(); +}); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + return { Wrapper, queryClient }; +}; + +const renderView = () => { + const { Wrapper } = createWrapper(); + return render(, { wrapper: Wrapper }); +}; + +const mockGoBack = jest.fn(); +const mockReplace = jest.fn(); +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + goBack: mockGoBack, + replace: mockReplace, + navigate: mockNavigate, + }), + useRoute: () => ({ + params: { + symbol: 'ETH', + ticker: 'ETH', + currentPrice: 2500, + currentCurrency: 'USD', + assetId: 'eip155:1/slip44:60', + }, + }), +})); + +const mockFetchAlerts = jest.fn(); +jest.mock('../../api', () => ({ + fetchAlerts: (...args: unknown[]) => mockFetchAlerts(...args), +})); + +const makeAlert = (overrides: Partial = {}): PriceAlert => ({ + id: 'alert-1', + userId: 'user-1', + asset: 'eip155:1/slip44:60', + threshold: 3000, + recurring: true, + active: true, + createdAt: '2025-01-01T00:00:00.000Z', + ...overrides, +}); + +const makeFetchResponse = (alerts: PriceAlert[], ok = true) => ({ + ok, + status: ok ? 200 : 500, + json: jest.fn().mockResolvedValue(alerts), + text: jest.fn().mockResolvedValue(''), +}); + +const waitForLoaded = (screen: ReturnType) => + waitFor(() => { + expect(screen.queryByTestId(ManagePriceAlertsTestIds.LOADING)).toBeNull(); + }); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('ManagePriceAlertsView', () => { + it('shows a loading indicator while the fetch is in-flight', async () => { + let resolve!: (value: unknown) => void; + mockFetchAlerts.mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + + const { getByTestId } = renderView(); + expect(getByTestId(ManagePriceAlertsTestIds.LOADING)).toBeOnTheScreen(); + + await act(async () => { + resolve(makeFetchResponse([makeAlert()])); + }); + }); + + it('hides the loading indicator once alerts are loaded', async () => { + mockFetchAlerts.mockResolvedValue(makeFetchResponse([makeAlert()])); + const screen = renderView(); + + await waitForLoaded(screen); + + expect(screen.queryByTestId(ManagePriceAlertsTestIds.LOADING)).toBeNull(); + }); + + it('calls fetchAlerts with the assetId from route params', async () => { + mockFetchAlerts.mockResolvedValue(makeFetchResponse([makeAlert()])); + const screen = renderView(); + + await waitForLoaded(screen); + + expect(mockFetchAlerts).toHaveBeenCalledWith('eip155:1/slip44:60'); + }); + + describe('when alerts are returned', () => { + const twoAlerts = [ + makeAlert({ id: 'alert-1', threshold: 3000, recurring: true }), + makeAlert({ + id: 'alert-2', + threshold: 1500, + recurring: false, + active: false, + }), + ]; + + beforeEach(() => { + mockFetchAlerts.mockResolvedValue(makeFetchResponse(twoAlerts)); + }); + + it('renders one row per alert', async () => { + const screen = renderView(); + await waitForLoaded(screen); + + expect( + screen.getByTestId( + `${ManagePriceAlertsTestIds.ALERT_ITEM_PREFIX}-alert-1`, + ), + ).toBeOnTheScreen(); + expect( + screen.getByTestId( + `${ManagePriceAlertsTestIds.ALERT_ITEM_PREFIX}-alert-2`, + ), + ).toBeOnTheScreen(); + }); + + it('shows the formatted threshold in each row', async () => { + const screen = renderView(); + await waitForLoaded(screen); + + expect(screen.getByText('Reaches $3,000.00')).toBeOnTheScreen(); + }); + + it('formats tiny thresholds with subscript notation instead of scientific notation', async () => { + mockFetchAlerts.mockResolvedValue( + makeFetchResponse([ + makeAlert({ id: 'alert-tiny', threshold: 0.0000000000000105 }), + ]), + ); + + const screen = renderView(); + await waitForLoaded(screen); + + expect(screen.getByText('Reaches $0.0₁₃105')).toBeOnTheScreen(); + }); + + it('shows "Recurring" for recurring alerts and "Once" for one-shot alerts', async () => { + const screen = renderView(); + await waitForLoaded(screen); + + expect(screen.getAllByText('Recurring').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Once')).toBeOnTheScreen(); + }); + + it('renders a delete button and active toggle for each row', async () => { + const screen = renderView(); + await waitForLoaded(screen); + + expect( + screen.getByTestId( + `${ManagePriceAlertsTestIds.ALERT_DELETE_PREFIX}-alert-1`, + ), + ).toBeOnTheScreen(); + expect( + screen.getByTestId( + `${ManagePriceAlertsTestIds.ALERT_TOGGLE_PREFIX}-alert-1`, + ), + ).toBeOnTheScreen(); + }); + + it('shows the "Add alert" button', async () => { + const screen = renderView(); + await waitForLoaded(screen); + + expect( + screen.getByTestId(ManagePriceAlertsTestIds.ADD_ALERT_BUTTON), + ).toBeOnTheScreen(); + }); + + it('navigates to CreatePriceAlert with fromManage=true when "Add alert" is pressed', async () => { + const screen = renderView(); + await waitForLoaded(screen); + + fireEvent.press( + screen.getByTestId(ManagePriceAlertsTestIds.ADD_ALERT_BUTTON), + ); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.CREATE_PRICE_ALERT, + expect.objectContaining({ + symbol: 'ETH', + ticker: 'ETH', + currentPrice: 2500, + currentCurrency: 'USD', + assetId: 'eip155:1/slip44:60', + fromManage: true, + }), + ); + }); + }); + + describe('redirect to CreatePriceAlert', () => { + it('replaces the screen when the API returns an empty list', async () => { + mockFetchAlerts.mockResolvedValue(makeFetchResponse([])); + renderView(); + + await waitFor(() => expect(mockReplace).toHaveBeenCalled()); + + expect(mockReplace).toHaveBeenCalledWith( + Routes.CREATE_PRICE_ALERT, + expect.objectContaining({ assetId: 'eip155:1/slip44:60' }), + ); + expect(mockReplace.mock.calls[0][1].fromManage).toBeUndefined(); + }); + + it('replaces the screen on a non-ok HTTP response', async () => { + mockFetchAlerts.mockResolvedValue(makeFetchResponse([], false)); + renderView(); + + await waitFor(() => expect(mockReplace).toHaveBeenCalled()); + + expect(mockReplace).toHaveBeenCalledWith( + Routes.CREATE_PRICE_ALERT, + expect.objectContaining({ assetId: 'eip155:1/slip44:60' }), + ); + }); + + it('replaces the screen when the fetch rejects entirely', async () => { + mockFetchAlerts.mockRejectedValue(new Error('Network failure')); + renderView(); + + await waitFor(() => expect(mockReplace).toHaveBeenCalled()); + + expect(mockReplace).toHaveBeenCalledWith( + Routes.CREATE_PRICE_ALERT, + expect.objectContaining({ assetId: 'eip155:1/slip44:60' }), + ); + }); + }); +}); diff --git a/app/components/UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView.tsx b/app/components/UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView.tsx new file mode 100644 index 000000000000..50bc7023a004 --- /dev/null +++ b/app/components/UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView.tsx @@ -0,0 +1,208 @@ +import React, { useCallback, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { ActivityIndicator, FlatList, Switch, View } from 'react-native'; +import { + useNavigation, + useRoute, + type RouteProp, +} from '@react-navigation/native'; +import type { StackNavigationProp } from '@react-navigation/stack'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Button, + ButtonVariant, + ButtonIcon, + ButtonIconSize, + HeaderStandard, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { strings } from '../../../../../../../locales/i18n'; +import { useTheme } from '../../../../../../util/theme'; +import { IconName } from '../../../../../../component-library/components/Icons/Icon'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { formatPriceWithSubscriptNotation } from '../../../../Predict/utils/format'; +import { + ManagePriceAlertsTestIds, + PriceAlert, + PriceAlertRouteParams, +} from '../../constants'; +import { fetchAlerts } from '../../api'; + +const ManagePriceAlertsView: React.FC = () => { + const tw = useTailwind(); + const { colors } = useTheme(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const navigation = useNavigation>(); + const route = + useRoute< + RouteProp< + { ManagePriceAlerts: PriceAlertRouteParams }, + 'ManagePriceAlerts' + > + >(); + const { symbol, ticker, currentPrice, currentCurrency, assetId } = + route.params; + + const { + data: alerts = [], + isLoading, + isError, + } = useQuery({ + queryKey: ['priceAlerts', assetId], + queryFn: async (): Promise => { + const response = await fetchAlerts(assetId); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }, + retry: false, + staleTime: 0, + cacheTime: 0, + }); + + useEffect(() => { + if (!isLoading && (isError || alerts.length === 0)) { + navigation.replace(Routes.CREATE_PRICE_ALERT, { + symbol, + ticker, + currentPrice, + currentCurrency, + assetId, + }); + } + }, [ + isLoading, + isError, + alerts.length, + navigation, + symbol, + ticker, + currentPrice, + currentCurrency, + assetId, + ]); + + const handleBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleAddAlert = useCallback(() => { + navigation.navigate(Routes.CREATE_PRICE_ALERT, { + symbol, + ticker, + currentPrice, + currentCurrency, + assetId, + fromManage: true, + }); + }, [navigation, symbol, ticker, currentPrice, currentCurrency, assetId]); + + const renderItem = ({ item }: { item: PriceAlert }) => ( + + + + {strings('price_alerts.reaches_threshold', { + threshold: formatPriceWithSubscriptNotation( + item.threshold, + currentCurrency, + ), + })} + + + {item.recurring + ? strings('price_alerts.recurring') + : strings('price_alerts.once_label')} + + + + { + // TODO: delete alert + }} + size={ButtonIconSize.Md} + iconName={IconName.Trash} + testID={`${ManagePriceAlertsTestIds.ALERT_DELETE_PREFIX}-${item.id}`} + accessibilityLabel="Delete alert" + style={tw.style('self-center')} + /> + + { + // TODO: toggle alert active state + }} + trackColor={{ + true: colors.primary.default, + false: colors.border.muted, + }} + thumbColor={colors.background.default} + ios_backgroundColor={colors.border.muted} + testID={`${ManagePriceAlertsTestIds.ALERT_TOGGLE_PREFIX}-${item.id}`} + style={tw.style('ml-3 self-center')} + /> + + ); + + return ( + + + + + {isLoading ? ( + + + + ) : ( + + item.id} + renderItem={renderItem} + testID={ManagePriceAlertsTestIds.ALERT_LIST} + /> + + )} + + {!isLoading && alerts.length > 0 && ( + + + + )} + + + ); +}; + +export default ManagePriceAlertsView; diff --git a/app/components/UI/Assets/PriceAlerts/api.test.ts b/app/components/UI/Assets/PriceAlerts/api.test.ts new file mode 100644 index 000000000000..09bd60be5c2e --- /dev/null +++ b/app/components/UI/Assets/PriceAlerts/api.test.ts @@ -0,0 +1,266 @@ +import React from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { notifyManager } from '@tanstack/query-core'; +import { fetchAlerts, createAlert, useSavePriceAlert } from './api'; + +// Prevents teardown crashes with unstable_batchedUpdates in Jest +notifyManager.setBatchNotifyFunction((callback: () => void) => { + callback(); +}); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + return { Wrapper, queryClient }; +}; + +const mockGetBearerToken = jest.fn().mockResolvedValue('test-bearer-token'); + +jest.mock('../../../../core/Engine', () => ({ + context: { + AuthenticationController: { + getBearerToken: (...args: unknown[]) => mockGetBearerToken(...args), + }, + }, +})); + +jest.mock('../../../../core/AppConstants', () => ({ + PRICE_ALERTS_API: { URL: 'https://price-alerts.api.cx.metamask.io' }, +})); + +const ALERTS_URL = 'https://price-alerts.api.cx.metamask.io/alerts'; + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +const makeOkResponse = (body?: unknown) => + ({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(body ?? []), + text: jest.fn().mockResolvedValue(''), + }) as unknown as Response; + +const makeErrorResponse = (status: number, bodyText = 'Bad Request') => + ({ + ok: false, + status, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(bodyText), + }) as unknown as Response; + +beforeEach(() => { + jest.clearAllMocks(); + mockFetch.mockResolvedValue(makeOkResponse()); +}); + +// authenticatedFetch is tested indirectly through fetchAlerts / createAlert +describe('authenticatedFetch', () => { + it('fetches a bearer token before every request', async () => { + await fetchAlerts('eip155:1/slip44:60'); + expect(mockGetBearerToken).toHaveBeenCalledTimes(1); + }); + + it('attaches the token as an Authorization: Bearer header', async () => { + await fetchAlerts('eip155:1/slip44:60'); + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record).Authorization).toBe( + 'Bearer test-bearer-token', + ); + }); + + it('always sends Accept: application/json', async () => { + await fetchAlerts('eip155:1/slip44:60'); + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record).Accept).toBe( + 'application/json', + ); + }); + + it('sends credentials: omit so cookies are never forwarded', async () => { + await fetchAlerts('eip155:1/slip44:60'); + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(init.credentials).toBe('omit'); + }); +}); + +describe('fetchAlerts', () => { + it('calls the correct URL with the asset URL-encoded as a query param', async () => { + await fetchAlerts('eip155:1/slip44:60'); + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toBe( + `${ALERTS_URL}?asset=${encodeURIComponent('eip155:1/slip44:60')}`, + ); + }); + + it('encodes special characters in the asset identifier', async () => { + await fetchAlerts('eip155:1/erc20:0xABCDEF'); + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toContain(encodeURIComponent('eip155:1/erc20:0xABCDEF')); + }); + + it('returns the raw Response from fetch', async () => { + const response = makeOkResponse([{ id: '1' }]); + mockFetch.mockResolvedValue(response); + expect(await fetchAlerts('eip155:1/slip44:60')).toBe(response); + }); +}); + +describe('createAlert', () => { + it('POSTs to the alerts endpoint with a JSON-serialised body', async () => { + const params = { + asset: 'eip155:1/slip44:60', + threshold: 2000, + recurring: false, + }; + await createAlert(params); + + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(ALERTS_URL); + expect(init.method).toBe('POST'); + expect(init.body).toBe(JSON.stringify(params)); + expect((init.headers as Record)['Content-Type']).toBe( + 'application/json', + ); + }); + + it('returns the raw Response from fetch', async () => { + const response = makeOkResponse({ id: 'abc' }); + mockFetch.mockResolvedValue(response); + expect( + await createAlert({ + asset: 'eip155:1/slip44:60', + threshold: 1500, + recurring: true, + }), + ).toBe(response); + }); +}); + +describe('useSavePriceAlert', () => { + it('starts with isSubmitting = false', () => { + const { Wrapper } = createWrapper(); + const { result } = renderHook(() => useSavePriceAlert(), { + wrapper: Wrapper, + }); + expect(result.current.isSubmitting).toBe(false); + }); + + it('sets isSubmitting = true while the request is in-flight and clears it on success', async () => { + let resolveRequest!: () => void; + mockFetch.mockReturnValueOnce( + new Promise((res) => { + resolveRequest = () => res(makeOkResponse()); + }), + ); + + const { Wrapper } = createWrapper(); + const { result } = renderHook(() => useSavePriceAlert(), { + wrapper: Wrapper, + }); + + act(() => { + result.current.save({ + asset: 'eip155:1/slip44:60', + threshold: 1000, + recurring: true, + }); + }); + + await waitFor(() => { + expect(result.current.isSubmitting).toBe(true); + }); + + await act(async () => { + resolveRequest(); + }); + + await waitFor(() => { + expect(result.current.isSubmitting).toBe(false); + }); + }); + + it('resets isSubmitting = false even when the request fails', async () => { + mockFetch.mockResolvedValueOnce(makeErrorResponse(500, 'Server Error')); + const { Wrapper } = createWrapper(); + const { result } = renderHook(() => useSavePriceAlert(), { + wrapper: Wrapper, + }); + + await act(async () => { + await expect( + result.current.save({ + asset: 'eip155:1/slip44:60', + threshold: 1000, + recurring: true, + }), + ).rejects.toThrow(); + }); + + expect(result.current.isSubmitting).toBe(false); + }); + + it('throws with the HTTP status and body text on a non-ok response', async () => { + mockFetch.mockResolvedValueOnce(makeErrorResponse(409, 'Conflict')); + const { Wrapper } = createWrapper(); + const { result } = renderHook(() => useSavePriceAlert(), { + wrapper: Wrapper, + }); + + await act(async () => { + await expect( + result.current.save({ + asset: 'eip155:1/slip44:60', + threshold: 1000, + recurring: true, + }), + ).rejects.toThrow('HTTP 409: Conflict'); + }); + }); + + it('falls back to "(no body)" when the error response body is unreadable', async () => { + const response = { + ok: false, + status: 500, + text: jest.fn().mockRejectedValue(new Error('stream error')), + } as unknown as Response; + mockFetch.mockResolvedValueOnce(response); + const { Wrapper } = createWrapper(); + const { result } = renderHook(() => useSavePriceAlert(), { + wrapper: Wrapper, + }); + + await act(async () => { + await expect( + result.current.save({ + asset: 'eip155:1/slip44:60', + threshold: 1000, + recurring: true, + }), + ).rejects.toThrow('HTTP 500: (no body)'); + }); + }); + + it('forwards the exact params to createAlert', async () => { + const params = { + asset: 'eip155:1/erc20:0xABC', + threshold: 3500, + recurring: false, + }; + const { Wrapper } = createWrapper(); + const { result } = renderHook(() => useSavePriceAlert(), { + wrapper: Wrapper, + }); + + await act(async () => { + await result.current.save(params); + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(init.body).toBe(JSON.stringify(params)); + }); +}); diff --git a/app/components/UI/Assets/PriceAlerts/api.ts b/app/components/UI/Assets/PriceAlerts/api.ts new file mode 100644 index 000000000000..94f4f6393238 --- /dev/null +++ b/app/components/UI/Assets/PriceAlerts/api.ts @@ -0,0 +1,45 @@ +import { useMutation } from '@tanstack/react-query'; +import AppConstants from '../../../../core/AppConstants'; +import Engine from '../../../../core/Engine'; +import type { SaveAlertParams } from './constants'; + +const ALERTS_URL = `${AppConstants.PRICE_ALERTS_API.URL}/alerts`; + +async function authenticatedFetch( + url: string, + options: RequestInit = {}, +): Promise { + const token = await Engine.context.AuthenticationController.getBearerToken(); + return fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + ...options.headers, + }, + credentials: 'omit', + }); +} + +export const fetchAlerts = (assetId: string): Promise => + authenticatedFetch(`${ALERTS_URL}?asset=${encodeURIComponent(assetId)}`); + +export const createAlert = (params: SaveAlertParams): Promise => + authenticatedFetch(ALERTS_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + +export const useSavePriceAlert = () => { + const { mutateAsync, isPending } = useMutation({ + mutationFn: async (params) => { + const response = await createAlert(params); + if (!response.ok) { + const body = await response.text().catch(() => '(no body)'); + throw new Error(`HTTP ${response.status}: ${body}`); + } + }, + }); + return { save: mutateAsync, isSubmitting: isPending }; +}; diff --git a/app/components/UI/Assets/PriceAlerts/components/AlertTypeToggle.test.tsx b/app/components/UI/Assets/PriceAlerts/components/AlertTypeToggle.test.tsx new file mode 100644 index 000000000000..04ea859fd1e1 --- /dev/null +++ b/app/components/UI/Assets/PriceAlerts/components/AlertTypeToggle.test.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import AlertTypeToggle from './AlertTypeToggle'; +import { PriceAlertType, CreatePriceAlertTestIds } from '../constants'; + +jest.mock('../../../../../util/haptics', () => ({ + playSelection: jest.fn(), +})); + +import { playSelection } from '../../../../../util/haptics'; +const mockPlaySelection = playSelection as jest.MockedFunction< + typeof playSelection +>; + +const mockOnChange = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('AlertTypeToggle', () => { + it('renders both tabs', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(CreatePriceAlertTestIds.PRICE_REACHES_TAB), + ).toBeOnTheScreen(); + expect( + getByTestId(CreatePriceAlertTestIds.PRICE_CHANGE_TAB), + ).toBeOnTheScreen(); + }); + + it('marks the active tab as selected via accessibilityState', () => { + const { getByTestId, rerender } = render( + , + ); + + expect( + getByTestId(CreatePriceAlertTestIds.PRICE_REACHES_TAB).props + .accessibilityState?.selected, + ).toBe(true); + expect( + getByTestId(CreatePriceAlertTestIds.PRICE_CHANGE_TAB).props + .accessibilityState?.selected, + ).toBe(false); + + rerender( + , + ); + + expect( + getByTestId(CreatePriceAlertTestIds.PRICE_REACHES_TAB).props + .accessibilityState?.selected, + ).toBe(false); + expect( + getByTestId(CreatePriceAlertTestIds.PRICE_CHANGE_TAB).props + .accessibilityState?.selected, + ).toBe(true); + }); + + it('both tabs have accessibilityRole="button"', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(CreatePriceAlertTestIds.PRICE_REACHES_TAB).props + .accessibilityRole, + ).toBe('button'); + expect( + getByTestId(CreatePriceAlertTestIds.PRICE_CHANGE_TAB).props + .accessibilityRole, + ).toBe('button'); + }); + + it('calls onChange with the new value when a different tab is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(CreatePriceAlertTestIds.PRICE_CHANGE_TAB)); + + expect(mockOnChange).toHaveBeenCalledTimes(1); + expect(mockOnChange).toHaveBeenCalledWith(PriceAlertType.PriceChange); + }); + + it('does not call onChange when the already-selected tab is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(CreatePriceAlertTestIds.PRICE_REACHES_TAB)); + + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + it('plays haptic feedback when switching tabs, but not when re-pressing the current tab', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(CreatePriceAlertTestIds.PRICE_REACHES_TAB)); + expect(mockPlaySelection).not.toHaveBeenCalled(); + + fireEvent.press(getByTestId(CreatePriceAlertTestIds.PRICE_CHANGE_TAB)); + expect(mockPlaySelection).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Assets/PriceAlerts/components/AlertTypeToggle.tsx b/app/components/UI/Assets/PriceAlerts/components/AlertTypeToggle.tsx new file mode 100644 index 000000000000..8764162bffde --- /dev/null +++ b/app/components/UI/Assets/PriceAlerts/components/AlertTypeToggle.tsx @@ -0,0 +1,146 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Animated, + StyleSheet, + TouchableOpacity, + type LayoutRectangle, +} from 'react-native'; +import { + Box, + BoxFlexDirection, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import { useTheme } from '../../../../../util/theme'; +import { playSelection } from '../../../../../util/haptics'; +import { CreatePriceAlertTestIds, PriceAlertType } from '../constants'; + +const styles = StyleSheet.create({ + container: { + position: 'relative', + }, + slider: { + position: 'absolute', + borderRadius: 10, + }, + pill: { + flex: 1, + }, +}); + +interface AlertTypeToggleProps { + value: PriceAlertType; + onChange: (value: PriceAlertType) => void; +} + +/** + * Animated sliding-pill toggle for price alert type selection. + * Uses the same spring-animation technique as QuickBuyTradeModeToggle. + */ +const AlertTypeToggle: React.FC = ({ + value, + onChange, +}) => { + const { colors } = useTheme(); + const slideAnim = useRef(new Animated.Value(0)).current; + const [firstLayout, setFirstLayout] = useState(null); + const [secondWidth, setSecondWidth] = useState(0); + + const handlePress = (next: PriceAlertType) => { + if (value !== next) { + playSelection(); + onChange(next); + } + }; + + useEffect(() => { + if (!firstLayout) return; + Animated.spring(slideAnim, { + toValue: value === PriceAlertType.PriceReaches ? 0 : firstLayout.width, + useNativeDriver: true, + tension: 180, + friction: 20, + }).start(); + }, [value, firstLayout, slideAnim]); + + const sliderWidth = + value === PriceAlertType.PriceReaches + ? (firstLayout?.width ?? 0) + : secondWidth; + + return ( + + {firstLayout && sliderWidth > 0 && ( + + )} + + handlePress(PriceAlertType.PriceReaches)} + onLayout={(e) => setFirstLayout(e.nativeEvent.layout)} + accessibilityRole="button" + accessibilityState={{ selected: value === PriceAlertType.PriceReaches }} + testID={CreatePriceAlertTestIds.PRICE_REACHES_TAB} + style={styles.pill} + > + + + {strings('price_alerts.price_reaches')} + + + + + handlePress(PriceAlertType.PriceChange)} + onLayout={(e) => setSecondWidth(e.nativeEvent.layout.width)} + accessibilityRole="button" + accessibilityState={{ selected: value === PriceAlertType.PriceChange }} + testID={CreatePriceAlertTestIds.PRICE_CHANGE_TAB} + style={styles.pill} + > + + + {strings('price_alerts.price_change')} + + + + + ); +}; + +export default AlertTypeToggle; diff --git a/app/components/UI/Assets/PriceAlerts/constants.ts b/app/components/UI/Assets/PriceAlerts/constants.ts new file mode 100644 index 000000000000..770255d98651 --- /dev/null +++ b/app/components/UI/Assets/PriceAlerts/constants.ts @@ -0,0 +1,74 @@ +export enum PriceAlertType { + PriceReaches = 'price_reaches', + PriceChange = 'price_change', +} + +/** Shared navigation params used by both Create and Manage screens. */ +export interface PriceAlertRouteParams { + symbol: string; + ticker?: string; + currentPrice: number; + currentCurrency: string; + /** CAIP-19 asset identifier, e.g. "eip155:1/slip44:60" or "eip155:1/erc20:0x..." */ + assetId: string; +} + +/** Route params for the Create Price Alert screen. */ +export interface CreatePriceAlertRouteParams extends PriceAlertRouteParams { + /** When true the screen was opened from ManagePriceAlertsView; pop 2 on save to return to TokenDetails. */ + fromManage?: boolean; +} + +/** API response shape for a single price alert. */ +export interface PriceAlert { + id: string; + userId: string; + asset: string; + threshold: number; + recurring: boolean; + active: boolean; + createdAt: string; +} + +export const PRICE_ALERT_QUICK_PERCENTAGES = [5, 10, 20, 30] as const; + +export const CURRENCY_SYMBOLS: Record = { + usd: '$', + eur: '€', + gbp: '£', + jpy: '¥', + cad: 'CA$', + aud: 'A$', +}; + +/** Request body for creating a price alert. */ +export interface SaveAlertParams { + /** CAIP-19 asset identifier, e.g. "eip155:1/slip44:60". */ + asset: string; + threshold: number; + recurring: boolean; +} + +export const CreatePriceAlertTestIds = { + CONTAINER: 'create-price-alert-container', + ALERT_TYPE_TOGGLE: 'create-price-alert-type-toggle', + PRICE_REACHES_TAB: 'create-price-alert-price-reaches-tab', + PRICE_CHANGE_TAB: 'create-price-alert-price-change-tab', + TARGET_PRICE_INPUT: 'create-price-alert-target-price', + PERCENT_DIFF: 'create-price-alert-percent-diff', + RECURRING_TOGGLE: 'create-price-alert-recurring-toggle', + UNDER_DEVELOPMENT: 'create-price-alert-under-development', + QUICK_PERCENTAGE_PREFIX: 'create-price-alert-quick-percentage', + SET_ALERT_BUTTON: 'create-price-alert-set-button', +} as const; + +export const ManagePriceAlertsTestIds = { + CONTAINER: 'manage-price-alerts-container', + LOADING: 'manage-price-alerts-loading', + ALERT_LIST: 'manage-price-alerts-list', + ALERT_ITEM_PREFIX: 'manage-price-alerts-item', + ALERT_TOGGLE_PREFIX: 'manage-price-alerts-toggle', + ALERT_DELETE_PREFIX: 'manage-price-alerts-delete', + ADD_ALERT_BUTTON: 'manage-price-alerts-add-button', + EMPTY_STATE: 'manage-price-alerts-empty', +} as const; diff --git a/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx b/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx index 4463d451896c..6a08e7da509c 100644 --- a/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx +++ b/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx @@ -11,6 +11,8 @@ import { selectDepositActiveFlag, selectDepositMinimumVersionFlag, } from '../../../../selectors/featureFlagController/deposit'; +import { selectPriceAlertsEnabled } from '../../../../selectors/featureFlagController/priceAlerts'; +import Routes from '../../../../constants/navigation/Routes'; import { AMBIENT_NEGATIVE_COLOR, AMBIENT_PRICE_COLOR_AB_KEY, @@ -160,10 +162,10 @@ jest.mock('../components/AssetOverviewContent', () => { token?: { address?: string; chainId?: string; symbol?: string }; useAmbientColor?: boolean; }) => { - mockLastUseAmbientColorProp = useAmbientColor; - mockLatestPriceDirectionChange = onPriceDirectionChange; const insightsTokenKey = `${token?.address ?? ''}:${token?.chainId ?? ''}:${token?.symbol ?? ''}`; ReactLib.useEffect(() => { + mockLastUseAmbientColorProp = useAmbientColor; + mockLatestPriceDirectionChange = onPriceDirectionChange; mockLatestMarketInsightsResolver = onMarketInsightsDisplayResolved; if (!mockAutoResolveMarketInsights) { return; @@ -172,7 +174,12 @@ jest.mock('../components/AssetOverviewContent', () => { isDisplayed: true, severity: undefined, }); - }, [onMarketInsightsDisplayResolved, insightsTokenKey]); + }, [ + onMarketInsightsDisplayResolved, + onPriceDirectionChange, + useAmbientColor, + insightsTokenKey, + ]); return null; }; @@ -233,6 +240,10 @@ jest.mock('../../../../selectors/featureFlagController/deposit', () => ({ selectDepositMinimumVersionFlag: jest.fn(() => null), })); +jest.mock('../../../../selectors/featureFlagController/priceAlerts', () => ({ + selectPriceAlertsEnabled: jest.fn(() => false), +})); + jest.mock('../../Ramp/Aggregator/utils', () => ({ isNetworkRampNativeTokenSupported: jest.fn(() => true), isNetworkRampSupported: jest.fn(() => true), @@ -350,6 +361,7 @@ describe('TokenDetails', () => { if (selector === getRampNetworks) return []; if (selector === selectDepositActiveFlag) return false; if (selector === selectDepositMinimumVersionFlag) return null; + if (selector === selectPriceAlertsEnabled) return false; return undefined; }); }); @@ -704,6 +716,114 @@ describe('TokenDetails', () => { }); }); + describe('price alert button gating', () => { + const enablePriceAlerts = () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectNetworkConfigurationByChainId) + return { name: 'Ethereum' }; + if (selector === selectPerpsEnabledFlag) return false; + if (selector === selectMerklCampaignClaimingEnabledFlag) return false; + if (selector === getRampNetworks) return []; + if (selector === selectDepositActiveFlag) return false; + if (selector === selectDepositMinimumVersionFlag) return null; + if (selector === selectPriceAlertsEnabled) return true; + return undefined; + }); + }; + + it('passes onPriceAlertPress to the header when the flag is enabled and currentPrice > 0', () => { + enablePriceAlerts(); + mockUseTokenPrice.mockReturnValue({ + ...defaultUseTokenPriceReturn, + currentPrice: 100, + }); + + render(); + + expect(mockTokenDetailsInlineHeader).toHaveBeenLastCalledWith( + expect.objectContaining({ + onPriceAlertPress: expect.any(Function), + }), + ); + }); + + it('passes undefined onPriceAlertPress when the flag is disabled', () => { + // Flag disabled — default mockUseSelector returns false for selectPriceAlertsEnabled + render(); + + expect(mockTokenDetailsInlineHeader).toHaveBeenLastCalledWith( + expect.objectContaining({ onPriceAlertPress: undefined }), + ); + }); + + it('passes undefined onPriceAlertPress when currentPrice is 0, even if flag is enabled', () => { + enablePriceAlerts(); + mockUseTokenPrice.mockReturnValue({ + ...defaultUseTokenPriceReturn, + currentPrice: 0, + }); + + render(); + + expect(mockTokenDetailsInlineHeader).toHaveBeenLastCalledWith( + expect.objectContaining({ onPriceAlertPress: undefined }), + ); + }); + + it('passes undefined onPriceAlertPress when CAIP-19 asset id cannot be resolved', () => { + enablePriceAlerts(); + mockUseTokenPrice.mockReturnValue({ + ...defaultUseTokenPriceReturn, + currentPrice: 100, + }); + mockRouteParams.mockReturnValue({ + ...defaultRouteParams, + chainId: undefined, + }); + + render(); + + expect(mockTokenDetailsInlineHeader).toHaveBeenLastCalledWith( + expect.objectContaining({ onPriceAlertPress: undefined }), + ); + }); + + it('navigates to MANAGE_PRICE_ALERTS with the correct params when the price alert button is pressed', () => { + enablePriceAlerts(); + mockUseTokenPrice.mockReturnValue({ + ...defaultUseTokenPriceReturn, + currentPrice: 2500, + currentCurrency: 'USD', + }); + mockRouteParams.mockReturnValue({ + ...defaultRouteParams, + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + chainId: '0x1', + symbol: 'DAI', + }); + + render(); + + // Retrieve the handler passed to the mocked header component and invoke it + const lastCall = mockTokenDetailsInlineHeader.mock.calls.at(-1)?.[0] as { + onPriceAlertPress?: () => void; + }; + act(() => { + lastCall.onPriceAlertPress?.(); + }); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MANAGE_PRICE_ALERTS, + expect.objectContaining({ + symbol: 'DAI', + currentPrice: 2500, + currentCurrency: 'USD', + assetId: expect.stringMatching(/^eip155:1\//), + }), + ); + }); + }); + describe('TOKEN_DETAILS_CLOSED app state tracking', () => { let handleAppStateChange: (nextState: AppStateStatus) => void; diff --git a/app/components/UI/TokenDetails/Views/TokenDetails.tsx b/app/components/UI/TokenDetails/Views/TokenDetails.tsx index f25accb33f0e..2f743dfefa21 100644 --- a/app/components/UI/TokenDetails/Views/TokenDetails.tsx +++ b/app/components/UI/TokenDetails/Views/TokenDetails.tsx @@ -52,6 +52,8 @@ import { useTokenBalance } from '../hooks/useTokenBalance'; import { useTokenPrice } from '../hooks/useTokenPrice'; import { useTokenSecurityData } from '../hooks/useTokenSecurityData'; import { useTokenTransactions } from '../hooks/useTokenTransactions'; +import Routes from '../../../../constants/navigation/Routes'; +import { selectPriceAlertsEnabled } from '../../../../selectors/featureFlagController/priceAlerts'; const styleSheet = (params: { theme: Theme }) => { const { theme } = params; @@ -182,6 +184,8 @@ const TokenDetails: React.FC<{ } }, [token.address, token.chainId]); + const isPriceAlertsFeatureEnabled = useSelector(selectPriceAlertsEnabled); + const { securityData, isLoading: isSecurityDataLoading, @@ -263,6 +267,26 @@ const TokenDetails: React.FC<{ await onSend(); }, [onSend, onCtaClicked]); + const handlePriceAlertPress = useCallback(() => { + if (!caip19AssetId) { + return; + } + navigation.navigate(Routes.MANAGE_PRICE_ALERTS, { + symbol: token.symbol, + ticker: token.ticker, + currentPrice, + currentCurrency, + assetId: caip19AssetId, + }); + }, [ + navigation, + token.symbol, + token.ticker, + currentPrice, + currentCurrency, + caip19AssetId, + ]); + const { transactions, submittedTxs, @@ -337,6 +361,11 @@ const TokenDetails: React.FC<{ navigation.goBack()} + onPriceAlertPress={ + isPriceAlertsFeatureEnabled && currentPrice > 0 && caip19AssetId + ? handlePriceAlertPress + : undefined + } iconColor={ambientIconColor} useAmbientColor={useAmbientColor} /> diff --git a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx index 1e380d0f4782..4a95f1640afc 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx @@ -46,6 +46,31 @@ describe('TokenDetailsInlineHeader', () => { expect(mockOnBackPress).toHaveBeenCalledTimes(1); }); + + it('renders price alert button when onPriceAlertPress is provided', () => { + const mockOnPriceAlertPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('token-price-alert-button')); + expect(mockOnPriceAlertPress).toHaveBeenCalledTimes(1); + }); + + it('does not render the price alert button when onPriceAlertPress is undefined', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('token-price-alert-button')).toBeNull(); + }); }); describe('treatment group (useAmbientColor=true)', () => { @@ -85,5 +110,35 @@ describe('TokenDetailsInlineHeader', () => { expect(mockOnBackPress).toHaveBeenCalledTimes(1); }); + + it('renders price alert button when iconColor is provided and onPriceAlertPress is set', () => { + const mockOnPriceAlertPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('token-price-alert-button')); + expect(mockOnPriceAlertPress).toHaveBeenCalledTimes(1); + }); + + it('does not render the price alert button when iconColor is undefined', () => { + const mockOnPriceAlertPress = jest.fn(); + const { queryByTestId } = render( + , + ); + + // shouldShowButton is false when useAmbientColor=true and iconColor is undefined, + // so the price alert button must not be rendered even if the handler is provided + expect(queryByTestId('token-price-alert-button')).toBeNull(); + }); }); }); diff --git a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx index a3e548b31416..66fa35cbdee5 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx @@ -8,6 +8,7 @@ import { IconName, } from '@metamask/design-system-react-native'; import { EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { TokenOverviewSelectorsIDs } from '../../AssetOverview/TokenOverview.testIds'; const inlineHeaderStyles = (params: { theme: Theme; @@ -38,18 +39,25 @@ const inlineHeaderStyles = (params: { gap: 10, flexShrink: 0, }, - rightPlaceholder: { - width: 24, + endButtonHitArea: { + width: 40, + height: 40, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + flexShrink: 0, }, }); }; export const TokenDetailsInlineHeader = ({ onBackPress, + onPriceAlertPress, iconColor, useAmbientColor = false, }: { onBackPress: () => void; + onPriceAlertPress?: () => void; /** Hex color string for the back button icon (A/B test). */ iconColor?: string; useAmbientColor?: boolean; @@ -76,7 +84,17 @@ export const TokenDetailsInlineHeader = ({ /> )} - + + {shouldShowButton && onPriceAlertPress ? ( + + ) : null} + ); }; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index c1d2f8d8c255..f51316cf410a 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -566,6 +566,8 @@ const Routes = { CONFIRM: 'AgenticCliDashboardConfirmation', }, FEATURE_FLAG_OVERRIDE: 'FeatureFlagOverride', + CREATE_PRICE_ALERT: 'CreatePriceAlert', + MANAGE_PRICE_ALERTS: 'ManagePriceAlerts', SECURITY_TRUST: 'SecurityTrust', AGENTIC_CLI_APPROVAL: { ID: 'AgenticCliApproval', diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts index 13378f636664..46d5d4a09d0f 100644 --- a/app/core/AppConstants.ts +++ b/app/core/AppConstants.ts @@ -33,6 +33,10 @@ const SECURITY_ALERTS_API_URL = process.env.SECURITY_ALERTS_API_URL ?? 'https://security-alerts.api.cx.metamask.io'; +const PRICE_ALERTS_API_URL = + process.env.PRICE_ALERTS_API_URL ?? + 'https://price-alerts.dev-api.cx.metamask.io'; + export default { IS_DEV: process.env?.NODE_ENV === DEVELOPMENT, METAMASK_BUILD_TYPE: process.env.METAMASK_BUILD_TYPE, @@ -48,6 +52,9 @@ export default { SECURITY_ALERTS_API: { URL: SECURITY_ALERTS_API_URL, }, + PRICE_ALERTS_API: { + URL: PRICE_ALERTS_API_URL, + }, PORTFOLIO: { URL: PORTFOLIO_URL, }, diff --git a/app/selectors/featureFlagController/priceAlerts/index.test.ts b/app/selectors/featureFlagController/priceAlerts/index.test.ts new file mode 100644 index 000000000000..f311f7369350 --- /dev/null +++ b/app/selectors/featureFlagController/priceAlerts/index.test.ts @@ -0,0 +1,151 @@ +import { selectPriceAlertsEnabled } from '.'; +import mockedEngine from '../../../core/__mocks__/MockedEngine'; +import { mockedEmptyFlagsState, mockedUndefinedFlagsState } from '../mocks'; +import { getVersion } from 'react-native-device-info'; +// eslint-disable-next-line import-x/no-namespace +import * as remoteFeatureFlagModule from '../../../util/remoteFeatureFlag'; + +jest.mock('../../../core/Engine', () => ({ + init: () => mockedEngine.init(), +})); + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('1.0.0'), +})); + +jest.mock( + '../../../core/Engine/controllers/remote-feature-flag-controller', + () => ({ + isRemoteFeatureFlagOverrideActivated: false, + }), +); + +describe('Price Alerts Feature Flag Selector (version-gated)', () => { + let mockHasMinimumRequiredVersion: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + + mockHasMinimumRequiredVersion = jest.spyOn( + remoteFeatureFlagModule, + 'hasMinimumRequiredVersion', + ); + mockHasMinimumRequiredVersion.mockReturnValue(true); + (getVersion as jest.MockedFunction).mockReturnValue( + '1.0.0', + ); + }); + + afterEach(() => { + mockHasMinimumRequiredVersion?.mockRestore(); + }); + + it('returns true when priceAlertsEnabled is enabled and minimum version requirement passes', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + priceAlertsEnabled: { enabled: true, minimumVersion: '1.0.0' }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + expect(selectPriceAlertsEnabled(state)).toBe(true); + }); + + it('returns false when priceAlertsEnabled is enabled but minimum version requirement fails', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + priceAlertsEnabled: { enabled: true, minimumVersion: '99.0.0' }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + expect(selectPriceAlertsEnabled(state)).toBe(false); + }); + + it('returns false when priceAlertsEnabled is disabled regardless of version', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + priceAlertsEnabled: { enabled: false, minimumVersion: '0.0.0' }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + expect(selectPriceAlertsEnabled(state)).toBe(false); + expect(mockHasMinimumRequiredVersion).not.toHaveBeenCalled(); + }); + + it('returns false when priceAlertsEnabled flag is missing', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }, + }, + }, + }; + + expect(selectPriceAlertsEnabled(state)).toBe(false); + }); + + it('returns false when feature flag state is empty', () => { + expect(selectPriceAlertsEnabled(mockedEmptyFlagsState)).toBe(false); + }); + + it('returns false when RemoteFeatureFlagController state is undefined', () => { + expect(selectPriceAlertsEnabled(mockedUndefinedFlagsState)).toBe(false); + }); + + it('returns false when priceAlertsEnabled has wrong property types', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + priceAlertsEnabled: { enabled: 'true', minimumVersion: 100 }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + expect(selectPriceAlertsEnabled(state)).toBe(false); + }); + + it('returns false when priceAlertsEnabled is null', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { priceAlertsEnabled: null }, + cacheTimestamp: 0, + }, + }, + }, + }; + + expect(selectPriceAlertsEnabled(state)).toBe(false); + }); +}); diff --git a/app/selectors/featureFlagController/priceAlerts/index.ts b/app/selectors/featureFlagController/priceAlerts/index.ts new file mode 100644 index 000000000000..5c11b2c94615 --- /dev/null +++ b/app/selectors/featureFlagController/priceAlerts/index.ts @@ -0,0 +1,19 @@ +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '..'; +import { + VersionGatedFeatureFlag, + validatedVersionGatedFeatureFlag, +} from '../../../util/remoteFeatureFlag'; + +export const PRICE_ALERTS_FLAG_KEY = 'priceAlertsEnabled'; + +export const selectPriceAlertsEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const remoteFlag = remoteFeatureFlags?.[ + PRICE_ALERTS_FLAG_KEY + ] as unknown as VersionGatedFeatureFlag; + + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; + }, +); diff --git a/builds.yml b/builds.yml index 02449b5f82fe..209ce23a432a 100644 --- a/builds.yml +++ b/builds.yml @@ -27,6 +27,7 @@ _public_envs: &public_envs # Servers (production) MM_PORTFOLIO_URL: 'https://portfolio.api.cx.metamask.io' SECURITY_ALERTS_API_URL: 'https://security-alerts.api.cx.metamask.io' DECODING_API_URL: 'https://signature-insights.api.cx.metamask.io/v1' + PRICE_ALERTS_API_URL: 'https://price-alerts.api.cx.metamask.io' COMPLIANCE_API_URL: 'https://compliance.api.cx.metamask.io' AUTH_SERVICE_URL: 'https://auth-service.api.cx.metamask.io' DIGEST_API_URL: 'https://digest.api.cx.metamask.io/api/v1' @@ -313,6 +314,7 @@ builds: BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io' + PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io' BRIDGE_USE_DEV_APIS: 'true' RAMPS_ENVIRONMENT: 'staging' RAMP_DEV_BUILD: 'true' @@ -337,6 +339,7 @@ builds: BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io' + PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io' BRIDGE_USE_DEV_APIS: 'true' RAMPS_ENVIRONMENT: 'staging' RAMP_INTERNAL_BUILD: 'true' @@ -361,6 +364,7 @@ builds: BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io' + PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io' BRIDGE_USE_DEV_APIS: 'true' RAMPS_ENVIRONMENT: 'staging' RAMP_INTERNAL_BUILD: 'true' @@ -420,6 +424,7 @@ builds: BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io' + PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io' BRIDGE_USE_DEV_APIS: 'true' RAMPS_ENVIRONMENT: 'staging' RAMP_INTERNAL_BUILD: 'true' @@ -447,6 +452,7 @@ builds: BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io' + PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io' BRIDGE_USE_DEV_APIS: 'true' RAMPS_ENVIRONMENT: 'staging' RAMP_INTERNAL_BUILD: 'true' @@ -491,6 +497,7 @@ builds: BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io' + PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io' BRIDGE_USE_DEV_APIS: 'true' RAMPS_ENVIRONMENT: 'staging' RAMP_INTERNAL_BUILD: 'true' @@ -515,6 +522,7 @@ builds: BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io' + PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io' BRIDGE_USE_DEV_APIS: 'true' RAMPS_ENVIRONMENT: 'staging' RAMP_INTERNAL_BUILD: 'true' @@ -538,6 +546,7 @@ builds: BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io' + PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io' BRIDGE_USE_DEV_APIS: 'true' RAMPS_ENVIRONMENT: 'staging' RAMP_DEV_BUILD: 'true' diff --git a/locales/languages/en.json b/locales/languages/en.json index e479778f6b85..5241edd5cd0f 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -4047,6 +4047,25 @@ "sell": "Sell", "sell_description": "Sell crypto for cash" }, + "price_alerts": { + "create_title": "Create {{ticker}} price alert", + "price_reaches": "Price reaches", + "price_change": "Price change", + "enter_target_price": "Enter target price", + "approx_percent": "≈ {{percent}}%", + "approx_percent_above": "above current {{ticker}} price", + "approx_percent_below": "below current {{ticker}} price", + "recurring": "Recurring", + "quick_percentage": "{{percentage}}%", + "price_change_under_development": "This experience is currently under development", + "set_price_alert": "Set price alert", + "save_success": "Price alert for {{ticker}} saved successfully.", + "manage_title": "Price alerts", + "add_alert": "Add alert", + "reaches_threshold": "Reaches {{threshold}}", + "once_label": "Once", + "no_alerts": "No price alerts set for this asset." + }, "asset_overview": { "market_closed": "Market closed", "send_button": "Send", diff --git a/scripts/apply-build-config.js b/scripts/apply-build-config.js index 6f571fa94aef..2815dfdb9ec8 100755 --- a/scripts/apply-build-config.js +++ b/scripts/apply-build-config.js @@ -125,6 +125,7 @@ function writeBuildEnvJson(buildName) { 'PORTFOLIO_API_URL', 'SECURITY_ALERTS_API_URL', 'DECODING_API_URL', + 'PRICE_ALERTS_API_URL', 'COMPLIANCE_API_URL', 'AUTH_SERVICE_URL', 'DIGEST_API_URL', diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index f97317105e6b..05d5aa50cdb7 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -5042,6 +5042,17 @@ export const FEATURE_FLAG_REGISTRY: Record = { }, status: FeatureFlagStatus.Active, }, + + priceAlertsEnabled: { + name: 'priceAlertsEnabled', + type: FeatureFlagType.Remote, + inProd: false, + productionDefault: { + enabled: false, + minimumVersion: '0.0.0', + }, + status: FeatureFlagStatus.Active, + }, }; // ============================================================================ From 73f97de7ae6a55c9abcc165a666ebb16cce4de3e Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Mon, 15 Jun 2026 09:46:53 +0200 Subject: [PATCH 2/3] fix: match Quick Buy token icon and chain badge styles to homepage token list (TSA-647) (#31459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The Quick Buy sheet rendered its token icon + chain badge differently from the rest of the app: the network badge was the fixed-size, square-cornered `BadgeNetwork` from the design system package, oversized relative to the token avatar, and token artwork resolution missed the homepage's checksummed-address CDN fallback (so tokens like CAKE showed different artwork than the homepage token list). Fix — reuse the homepage Token list pattern (`TokenListItem` / `AssetLogo`) via a new shared `QuickBuyTokenIcon` component: - Fully round design-system `AvatarToken`, wrapped in the component-library `BadgeWrapper`/`Badge` (variant `Network`, circular, scaled to half the avatar, bottom-right) — exactly what `TokenListItem` uses. - Same data sources: `getNetworkImageSource` for the badge; token image → `getBridgeTokenImageSource` (keeps natives working via SLIP-44 asset ids) → `getFallbackAssetImageUrls` (lowercased + checksummed CDN variants), cycled by the shared `useSmartImageFallback` hook. - Adopted in the "Pay with / Receive" footer pill (size Sm) and the token-select rows (size Md); removed the now-dead `BadgeNetwork` plumbing. Note: one expected `@typescript-eslint/no-deprecated` warning for the component-library `Badge` — the homepage `TokenListItem` carries the identical unsuppressed warning; matching it is intentional. Jira: [TSA-647](https://consensyssoftware.atlassian.net/browse/TSA-647) ## **Changelog** CHANGELOG entry: Fixed token icon and network badge styling in the Quick Buy sheet to match the wallet token list ## **Related issues** Fixes: [TSA-647](https://consensyssoftware.atlassian.net/browse/TSA-647) ## **Manual testing steps** ```gherkin Feature: Quick Buy token icon and chain badge styles Scenario: user compares Quick Buy icons with the homepage token list Given the user holds CAKE on BNB Chain When the user opens the Quick Buy sheet and the "Pay with" picker Then the token icon is fully round with the same artwork as the homepage token list And the network badge is circular, smaller than the token icon, anchored bottom-right ``` ## **Screenshots/Recordings** ### **Before** NA ### **After** Tokens view: Screenshot 2026-06-11 at 16 50 47 Quickbuy view: Screenshot 2026-06-12 at 16 53 02 Screenshot 2026-06-11 at 16 51 51 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [TSA-647]: https://consensyssoftware.atlassian.net/browse/TSA-647?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Low Risk** > Scoped UI and image-resolution changes in Quick Buy plus a small, tested adjustment to the shared image-fallback hook; no auth, payments, or data-path changes. > > **Overview** > Aligns **Quick Buy** token icons and chain badges with the homepage token list (TSA-647) by introducing **`QuickBuyTokenIcon`**, used in the pay-with footer pill and pay-with rows instead of design-system `BadgeNetwork` + ad hoc `AvatarToken` wiring. > > The shared component uses component-library **`BadgeWrapper`/`Badge` (Network)** for the circular bottom-right chain badge, and resolves artwork like **`AssetLogo`**: token image → bridge/native source → **`getFallbackAssetImageUrls`** (including checksummed CDN), with **`useSmartImageFallback`** cycling on load errors. > > **`useSmartImageFallback`** now **resets the fallback index when the `sources` array changes**, so switching tokens after image errors does not stick on a stale fallback index; coverage is added in hook and component tests. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ad88e1f92f43b551216dcfb5944f9965ee8133b4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Fable 5 Co-authored-by: Cursor Agent Co-authored-by: Xavier Brochard Co-authored-by: Antonio Regadas --- .../AssetLogo/AssetLogo.hook.test.tsx | 50 +++++++++ .../components/AssetLogo/AssetLogo.hook.tsx | 8 +- .../components/QuickBuyActionFooter.tsx | 44 +------- .../components/QuickBuyPayWithRow.tsx | 27 +---- .../components/QuickBuyTokenIcon.test.tsx | 104 ++++++++++++++++++ .../QuickBuy/components/QuickBuyTokenIcon.tsx | 102 +++++++++++++++++ 6 files changed, 270 insertions(+), 65 deletions(-) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTokenIcon.test.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTokenIcon.tsx diff --git a/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.test.tsx b/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.test.tsx index 487674a220ca..bb36d03e59a9 100644 --- a/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.test.tsx +++ b/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.test.tsx @@ -135,6 +135,56 @@ describe('useSmartImageFallback', () => { expect(result.current.source).toStrictEqual(updatedSources[0]); }); + it('resets fallback index when sources change after onError cycles', () => { + const initialSources = createSourcesFromUris([ + 'https://example.com/reset-token-a-primary.png', + 'https://example.com/reset-token-a-fallback.png', + ]); + const newSources = createSourcesFromUris([ + 'https://example.com/reset-token-b-primary.png', + 'https://example.com/reset-token-b-fallback.png', + ]); + + const { result, rerender } = renderHook( + ({ hookSources }) => useSmartImageFallback(hookSources), + { initialProps: { hookSources: initialSources } }, + ); + + act(() => { + result.current.onError(); + }); + expect(result.current.source).toStrictEqual(initialSources[1]); + + rerender({ hookSources: newSources }); + + expect(result.current.source).toStrictEqual(newSources[0]); + }); + + it('preserves fallback index when sources reference changes but URIs stay the same', () => { + const makeSources = () => + createSourcesFromUris([ + 'https://example.com/stable-ref-primary.png', + 'https://example.com/stable-ref-fallback.png', + ]); + + const { result, rerender } = renderHook( + ({ hookSources }) => useSmartImageFallback(hookSources), + { initialProps: { hookSources: makeSources() } }, + ); + + act(() => { + result.current.onError(); + }); + const sourceAfterError = result.current.source; + expect(sourceAfterError).toStrictEqual({ + uri: 'https://example.com/stable-ref-fallback.png', + }); + + rerender({ hookSources: makeSources() }); + + expect(result.current.source).toStrictEqual(sourceAfterError); + }); + it('persists dead images across hook instances', () => { const sources = createSourcesFromUris([ 'https://example.com/persist-primary.png', diff --git a/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.tsx b/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.tsx index 82e1339fdbe5..3a5461d8a588 100644 --- a/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.tsx +++ b/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { ImageURISource } from 'react-native'; import { createRotatingSet } from './AssetLogo.utils'; @@ -11,6 +11,12 @@ export function useSmartImageFallback(sources: ImageURISource[]) { const LAST_ITEM_INDEX = validSources.length - 1; const [index, setIndex] = useState(0); + const uriKey = sources.map((s) => s.uri ?? '').join('\0'); + const prevUriKeyRef = useRef(uriKey); + if (prevUriKeyRef.current !== uriKey) { + prevUriKeyRef.current = uriKey; + setIndex(0); + } const currentSource: ImageURISource | undefined = validSources[index] || sources[sources.length - 1]; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx index 6321ef269d1e..ae31d2e796b8 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx @@ -7,15 +7,11 @@ import { Text, TextVariant, TextColor, - AvatarToken, AvatarTokenSize, Icon, IconColor, IconName, IconSize, - BadgeWrapper, - BadgeWrapperPosition, - BadgeNetwork, } from '@metamask/design-system-react-native'; import { TouchableOpacity } from 'react-native'; import { strings } from '../../../../../../../../locales/i18n'; @@ -23,8 +19,7 @@ import QuickBuyConfirmButton from '../QuickBuyConfirmButton'; import QuickBuyBanners from '../QuickBuyBanners'; import { useQuickBuyContext } from '../useQuickBuyContext'; import { QuickBuyPercentageSlider } from './QuickBuyPercentageSlider'; -import { getNetworkImageSource } from '../../../../../../../util/networks'; -import { getBridgeTokenImageSource } from '../getBridgeTokenImageSource'; +import QuickBuyTokenIcon from './QuickBuyTokenIcon'; const QuickBuyActionFooter: React.FC = () => { const { @@ -41,7 +36,6 @@ const QuickBuyActionFooter: React.FC = () => { isHardwareSolanaBlocked, tradeMode, sourceToken, - sourceChainId, sourceBalanceFiat, destBalanceFiat, destToken, @@ -51,23 +45,9 @@ const QuickBuyActionFooter: React.FC = () => { } = useQuickBuyContext(); const pickerToken = tradeMode === 'sell' ? selectedDestStable : sourceToken; - const pickerChainId = - tradeMode === 'sell' - ? (selectedDestStable?.chainId as - | import('@metamask/utils').Hex - | undefined) - : sourceChainId; - // Both balances are driven by live, selector-backed state (TSA-632): - // `sourceBalanceFiat` from `useLatestBalance` re-keyed off the live cached - // balance, and `destBalanceFiat` resynced from the reactive receive-token - // list. Either updates the pill the moment the underlying balance changes. const pickerBalanceFiat = tradeMode === 'sell' ? destBalanceFiat : sourceBalanceFiat; - const networkImage = pickerChainId - ? getNetworkImageSource({ chainId: pickerChainId }) - : undefined; - return ( {/* Slider — reduced top padding to tighten gap with the amount section */} @@ -106,24 +86,10 @@ const QuickBuyActionFooter: React.FC = () => { gap={2} > {pickerToken ? ( - networkImage ? ( - } - > - - - ) : ( - - ) + ) : null} {pickerToken diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPayWithRow.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPayWithRow.tsx index e41189781b9a..3e7597ebc07e 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPayWithRow.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPayWithRow.tsx @@ -1,9 +1,5 @@ import { - AvatarToken, AvatarTokenSize, - BadgeNetwork, - BadgeWrapper, - BadgeWrapperPosition, Box, BoxAlignItems, BoxFlexDirection, @@ -17,10 +13,9 @@ import { } from '@metamask/design-system-react-native'; import React, { useMemo } from 'react'; import { TouchableOpacity } from 'react-native'; -import { getNetworkImageSource } from '../../../../../../../util/networks'; import type { BridgeToken } from '../../../../../../UI/Bridge/types'; -import { getBridgeTokenImageSource } from '../getBridgeTokenImageSource'; import { getTokenKey } from '../tokenKey'; +import QuickBuyTokenIcon from './QuickBuyTokenIcon'; interface QuickBuyPayWithRowProps { token: BridgeToken; @@ -51,7 +46,6 @@ const QuickBuyPayWithRow: React.FC = ({ isSelected, onPress, }) => { - const networkImage = getNetworkImageSource({ chainId: token.chainId }); const tokenKey = getTokenKey(token); const cryptoBalanceLabel = useMemo( () => formatTokenBalance(token.balance, token.symbol), @@ -71,24 +65,7 @@ const QuickBuyPayWithRow: React.FC = ({ gap={4} twClassName={`px-4 py-2 ${isSelected ? 'bg-muted' : ''}`} > - {networkImage ? ( - } - > - - - ) : ( - - )} + = {}): BridgeToken => ({ + symbol: 'CAKE', + name: 'PancakeSwap', + address: CAKE_ADDRESS, + decimals: 18, + chainId: BSC_CHAIN_ID, + ...overrides, +}); + +describe('QuickBuyTokenIcon', () => { + it('renders a round AvatarToken from the token image with the requested size', () => { + const token = createToken({ image: 'https://example.com/cake-own.png' }); + + render(); + + const avatar = screen.UNSAFE_getByType(AvatarToken); + expect(avatar.props).toStrictEqual( + expect.objectContaining({ + name: 'CAKE', + src: { uri: 'https://example.com/cake-own.png' }, + size: AvatarTokenSize.Sm, + }), + ); + }); + + it('renders the component-library network badge used by the homepage token list', () => { + const token = createToken({ image: 'https://example.com/cake-badge.png' }); + + render(); + + expect(screen.getByTestId(BADGENETWORK_TEST_ID)).toBeOnTheScreen(); + }); + + it('falls back to the shared static-CDN token icon when the token has no image', () => { + const token = createToken({ image: undefined }); + const expectedFallbackUrl = getFallbackAssetImageUrls( + BSC_CHAIN_ID, + CAKE_ADDRESS, + )?.[0]; + + render(); + + const avatar = screen.UNSAFE_getByType(AvatarToken); + expect(avatar.props.src).toStrictEqual({ uri: expectedFallbackUrl }); + }); + + it('resolves the same fallback image list as the homepage token list', () => { + // The hook receives the token image first, then the static-CDN fallbacks + // (lowercased + checksummed address variants) used by AssetLogo. + const token = createToken({ image: 'https://example.com/cake-list.png' }); + const fallbackUrls = getFallbackAssetImageUrls(BSC_CHAIN_ID, CAKE_ADDRESS); + + render(); + + fireEvent( + screen.getByTestId(QUICK_BUY_TOKEN_ICON_AVATAR_TEST_ID), + 'error', + { nativeEvent: {} }, + ); + + const avatar = screen.UNSAFE_getByType(AvatarToken); + expect(avatar.props.src).toStrictEqual({ uri: fallbackUrls?.[0] }); + }); + + it('cycles through to the checksummed CDN variant when earlier images fail', () => { + const token = createToken({ image: 'https://example.com/cake-cycle.png' }); + const fallbackUrls = getFallbackAssetImageUrls(BSC_CHAIN_ID, CAKE_ADDRESS); + + render(); + + // own image fails -> lowercase CDN fails -> checksummed CDN remains. + fireEvent( + screen.getByTestId(QUICK_BUY_TOKEN_ICON_AVATAR_TEST_ID), + 'error', + { nativeEvent: {} }, + ); + fireEvent( + screen.getByTestId(QUICK_BUY_TOKEN_ICON_AVATAR_TEST_ID), + 'error', + { nativeEvent: {} }, + ); + + const avatar = screen.UNSAFE_getByType(AvatarToken); + expect(avatar.props.src).toStrictEqual({ uri: fallbackUrls?.[1] }); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTokenIcon.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTokenIcon.tsx new file mode 100644 index 000000000000..2500856c34d7 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTokenIcon.tsx @@ -0,0 +1,102 @@ +import { + AvatarToken, + AvatarTokenSize, +} from '@metamask/design-system-react-native'; +import React, { useMemo } from 'react'; +import { StyleSheet } from 'react-native'; +import Badge, { + BadgeVariant, +} from '../../../../../../../component-library/components/Badges/Badge'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../../../../component-library/components/Badges/BadgeWrapper'; +import { getNetworkImageSource } from '../../../../../../../util/networks'; +import { useSmartImageFallback } from '../../../../../../UI/Assets/components/AssetLogo/AssetLogo.hook'; +import { getFallbackAssetImageUrls } from '../../../../../../UI/Assets/components/AssetLogo/AssetLogo.utils'; +import type { BridgeToken } from '../../../../../../UI/Bridge/types'; +import { getBridgeTokenImageSource } from '../getBridgeTokenImageSource'; + +export const QUICK_BUY_TOKEN_ICON_AVATAR_TEST_ID = + 'quick-buy-token-icon-avatar'; + +const styles = StyleSheet.create({ + badgeWrapper: { + // Override BadgeWrapper's alignSelf: 'flex-start' so the parent row keeps + // controlling the vertical alignment (same as Bridge's TokenSelectorItem). + alignSelf: undefined, + }, +}); + +interface QuickBuyTokenIconProps { + token: BridgeToken; + size?: AvatarTokenSize; +} + +/** + * Token avatar with a network badge for the Quick Buy sheet. + * + * Mirrors the homepage Token list rendering (`TokenListItem` + `AssetLogo`): + * a fully round `AvatarToken` resolved from the token image with the shared + * static-CDN fallbacks (`getFallbackAssetImageUrls` + `useSmartImageFallback`), + * and a circular network badge scaled to half the avatar via the + * component-library `BadgeWrapper`/`Badge` pair, sourced from + * `getNetworkImageSource`. + */ +const QuickBuyTokenIcon: React.FC = ({ + token, + size = AvatarTokenSize.Md, +}) => { + const networkImageSource = getNetworkImageSource({ + chainId: token.chainId, + }); + + // Same data sources as the homepage Token list (`AssetLogo`): the token's + // own image first, then the static-CDN fallbacks (lowercased + checksummed + // address variants). `getBridgeTokenImageSource` keeps native assets working + // by resolving their canonical SLIP-44 asset-id icon. + const bridgeImageUri = getBridgeTokenImageSource(token)?.uri; + const images = useMemo(() => { + const urls = [ + token.image, + bridgeImageUri, + ...(getFallbackAssetImageUrls(token.chainId, token.address) ?? []), + ].filter((image): image is string => Boolean(image)); + return [...new Set(urls)].map((uri) => ({ uri })); + }, [token.image, token.chainId, token.address, bridgeImageUri]); + + const { source, onError, uniqueSourceImageKey } = + useSmartImageFallback(images); + + const avatar = ( + + ); + + if (!networkImageSource) { + return avatar; + } + + return ( + + } + > + {avatar} + + ); +}; + +export default QuickBuyTokenIcon; From ca846b2801e4cf6589eef0d661e8b327e60b7c1a Mon Sep 17 00:00:00 2001 From: Nick Gambino Date: Mon, 15 Jun 2026 01:09:35 -0700 Subject: [PATCH 3/3] feat: add suggestions to watchlist on marketlist UI (#31650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add suggestions to the watchlist on market list UI. Should match behavior on Perps Home, minus the collapsible "show more" button. Fixes back navigation from asset details screen so that it navigates correctly to Perps Home, or Market List, contextually. Fixes search bar functionality on Market List Watchlist ## **Changelog** CHANGELOG entry: add suggested markets to watchlist on marketlist UI ## **Related issues** Fixes: n/a ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** https://github.com/user-attachments/assets/c1001aba-5bfc-49d9-8e6f-da359f1b1ef9 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > UI-only perps market list and watchlist component changes behind the existing watchlist flag, with broad test coverage and no auth or data-layer changes. > > **Overview** > **Perps market list watchlist mode** now mirrors Perps Home: when the watchlist feature flag is on and the watchlist filter is active, the screen always renders `PerpsWatchlistMarkets` with saved markets **and** suggested markets—not only when the watchlist is empty. The list view passes `enableShowMore={false}`, `showHeader={false}`, and wires row taps through the existing `handleMarketPress` so `onMarketSelect`, `navigateToMarketDetails`, and route `transactionActiveAbTests` behave like the main market list. > > **`PerpsWatchlistMarkets`** gains optional **`onMarketPress`** (parent-owned navigation instead of internal `navigation.navigate`) and **`enableShowMore`** (default `true`; when `false`, all watchlist rows show with no expand/collapse). Both V1 and V2 honor `onMarketPress`. > > Tests cover the new market-list watchlist flows and the new component props. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6ffb46bef738fe41c7343f2766022e733663b52a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../PerpsMarketListView.styles.ts | 6 + .../PerpsMarketListView.test.tsx | 609 +++++++++++++++++- .../PerpsMarketListView.tsx | 107 ++- .../PerpsWatchlistMarkets.test.tsx | 95 +++ .../PerpsWatchlistMarkets.tsx | 43 +- 5 files changed, 832 insertions(+), 28 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.styles.ts b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.styles.ts index 018d17eee20c..9a838ff0567a 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.styles.ts +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.styles.ts @@ -135,6 +135,12 @@ const styleSheet = (params: { theme: Theme }) => { animatedListContainer: { flex: 1, }, + watchlistScrollContainer: { + flex: 1, + }, + watchlistScrollContent: { + paddingBottom: 120, + }, searchBarRow: { paddingHorizontal: 16, paddingBottom: 8, diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx index 60ac166da794..13193748b840 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx @@ -154,6 +154,9 @@ jest.mock('../../hooks', () => ({ favoritesState: { showFavoritesOnly: false, setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: false, + watchlistMarketObjects: [], + suggestedMarkets: [], }, marketTypeFilterState: { marketTypeFilter: 'all', @@ -188,6 +191,59 @@ jest.mock('../../hooks/usePerpsOrderFees', () => ({ formatFeeRate: jest.fn((rate) => `${((rate || 0) * 100).toFixed(3)}%`), })); +jest.mock( + '../../components/PerpsWatchlistMarkets/PerpsWatchlistMarkets', + () => { + const MockReact = jest.requireActual('react'); + const { View, Text, TouchableOpacity } = jest.requireActual('react-native'); + return function MockPerpsWatchlistMarkets({ + markets, + suggestedMarkets, + enableShowMore, + onMarketPress, + }: { + markets: { symbol: string }[]; + suggestedMarkets?: { symbol: string }[]; + enableShowMore?: boolean; + onMarketPress?: (market: { symbol: string }) => void; + }) { + return MockReact.createElement( + View, + { testID: 'perps-watchlist-markets' }, + markets.map((m) => + MockReact.createElement( + TouchableOpacity, + { + key: m.symbol, + testID: `watchlist-row-${m.symbol}`, + onPress: () => onMarketPress?.(m), + }, + MockReact.createElement(Text, null, m.symbol), + ), + ), + (suggestedMarkets ?? []).map((m) => + MockReact.createElement( + TouchableOpacity, + { + key: m.symbol, + testID: `suggested-row-${m.symbol}`, + onPress: () => onMarketPress?.(m), + }, + MockReact.createElement(Text, null, m.symbol), + ), + ), + enableShowMore === false + ? MockReact.createElement( + Text, + { testID: 'show-more-disabled' }, + 'no-show-more', + ) + : null, + ); + }; + }, +); + jest.mock('../../components/PerpsMarketBalanceActions', () => { const MockReact = jest.requireActual('react'); const { View, Text } = jest.requireActual('react-native'); @@ -576,6 +632,9 @@ describe('PerpsMarketListView', () => { beforeEach(() => { jest.clearAllMocks(); + // Reset watchlist flag so each test starts with it off + mockWatchlistFlagEnabled = false; + // Set mock market data for the hook mockMarketDataForHook.length = 0; mockMarketDataForHook.push(...mockMarketData); @@ -743,6 +802,520 @@ describe('PerpsMarketListView', () => { expect(ethRows.length).toBeGreaterThan(0); expect(solRows.length).toBeGreaterThan(0); }); + + it('renders PerpsWatchlistMarkets with suggestions when watchlist filter is active and populated', () => { + mockWatchlistFlagEnabled = true; + + const watchlistMarket = mockMarketData[0]; // BTC + const suggested = [mockMarketData[1], mockMarketData[2]]; // ETH, SOL + + mockUsePerpsMarketListView.mockReturnValueOnce({ + markets: [watchlistMarket], + searchState: { + searchQuery: '', + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc', + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: true, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: true, + watchlistMarketObjects: [watchlistMarket], + suggestedMarkets: suggested, + }, + marketTypeFilterState: { + marketTypeFilter: 'all', + setMarketTypeFilter: jest.fn(), + }, + marketCounts: { + crypto: 3, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + isLoading: false, + error: null, + }); + + renderWithProvider(, { state: mockState }); + + // PerpsWatchlistMarkets renders the watchlist row and suggested rows + expect(screen.getByTestId('perps-watchlist-markets')).toBeOnTheScreen(); + expect(screen.getByTestId('watchlist-row-BTC')).toBeOnTheScreen(); + expect(screen.getByTestId('suggested-row-ETH')).toBeOnTheScreen(); + expect(screen.getByTestId('suggested-row-SOL')).toBeOnTheScreen(); + // enableShowMore={false} indicator is present + expect(screen.getByTestId('show-more-disabled')).toBeOnTheScreen(); + }); + + it('renders PerpsWatchlistMarkets with suggestions when watchlist filter is active and empty', () => { + mockWatchlistFlagEnabled = true; + + const suggested = [mockMarketData[0], mockMarketData[1]]; // BTC, ETH + + mockUsePerpsMarketListView.mockReturnValueOnce({ + markets: [], + searchState: { + searchQuery: '', + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc', + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: true, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: false, + watchlistMarketObjects: [], + suggestedMarkets: suggested, + }, + marketTypeFilterState: { + marketTypeFilter: 'all', + setMarketTypeFilter: jest.fn(), + }, + marketCounts: { + crypto: 3, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + isLoading: false, + error: null, + }); + + renderWithProvider(, { state: mockState }); + + expect(screen.getByTestId('perps-watchlist-markets')).toBeOnTheScreen(); + expect(screen.getByTestId('suggested-row-BTC')).toBeOnTheScreen(); + expect(screen.getByTestId('suggested-row-ETH')).toBeOnTheScreen(); + }); + + it('navigates to market details via push when watchlist row is pressed', () => { + mockWatchlistFlagEnabled = true; + + const watchlistMarket = mockMarketData[0]; // BTC + + mockUsePerpsMarketListView.mockReturnValueOnce({ + markets: [watchlistMarket], + searchState: { + searchQuery: '', + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc', + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: true, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: true, + watchlistMarketObjects: [watchlistMarket], + suggestedMarkets: [], + }, + marketTypeFilterState: { + marketTypeFilter: 'all', + setMarketTypeFilter: jest.fn(), + }, + marketCounts: { + crypto: 3, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + isLoading: false, + error: null, + }); + + renderWithProvider(, { state: mockState }); + + fireEvent.press(screen.getByTestId('watchlist-row-BTC')); + + // handleMarketPress uses StackActions.push via navigation.dispatch so that + // MARKET_LIST is always preserved in the stack below MARKET_DETAILS. + expect(mockNavigation.dispatch).toHaveBeenCalledTimes(1); + expect(mockNavigation.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + params: expect.objectContaining({ + market: watchlistMarket, + source: 'perp_markets', + }), + }), + }), + ); + }); + + it('routes watchlist row press through onMarketSelect when provided and watchlist filter is active', () => { + mockWatchlistFlagEnabled = true; + + const mockOnMarketSelect = jest.fn(); + const watchlistMarket = mockMarketData[0]; // BTC + + mockUsePerpsMarketListView.mockReturnValueOnce({ + markets: [watchlistMarket], + searchState: { + searchQuery: '', + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc', + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: true, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: true, + watchlistMarketObjects: [watchlistMarket], + suggestedMarkets: [], + }, + marketTypeFilterState: { + marketTypeFilter: 'all', + setMarketTypeFilter: jest.fn(), + }, + marketCounts: { + crypto: 3, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + isLoading: false, + error: null, + }); + + renderWithProvider( + , + { state: mockState }, + ); + + fireEvent.press(screen.getByTestId('watchlist-row-BTC')); + + expect(mockOnMarketSelect).toHaveBeenCalledWith(watchlistMarket); + expect(mockNavigation.dispatch).not.toHaveBeenCalled(); + }); + + it('carries transactionActiveAbTests through dispatch when watchlist row is pressed', () => { + mockWatchlistFlagEnabled = true; + + const transactionActiveAbTests = [ + createActiveABTestAssignment( + 'homeTMCU725AbtestHomepagePerpsPillsEmptyState', + 'treatment', + ), + ]; + + const watchlistMarket = mockMarketData[0]; // BTC + + mockUseRoute.mockReturnValue({ + key: 'PerpsMarketListView-123', + name: 'PerpsMarketListView', + params: { transactionActiveAbTests }, + }); + + mockUsePerpsMarketListView.mockReturnValueOnce({ + markets: [watchlistMarket], + searchState: { + searchQuery: '', + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc', + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: true, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: true, + watchlistMarketObjects: [watchlistMarket], + suggestedMarkets: [], + }, + marketTypeFilterState: { + marketTypeFilter: 'all', + setMarketTypeFilter: jest.fn(), + }, + marketCounts: { + crypto: 3, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + isLoading: false, + error: null, + }); + + renderWithProvider(, { state: mockState }); + + fireEvent.press(screen.getByTestId('watchlist-row-BTC')); + + expect(mockNavigation.dispatch).toHaveBeenCalledTimes(1); + expect(mockNavigation.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + params: expect.objectContaining({ + market: watchlistMarket, + source: 'perp_markets', + transactionActiveAbTests, + }), + }), + }), + ); + }); + + it('filters watchlist rows by query and also filters suggestions, showing matching suggestions', () => { + mockWatchlistFlagEnabled = true; + + const btcMarket = mockMarketData[0]; // BTC — watchlisted + const ethMarket = mockMarketData[1]; // ETH — watchlisted + const suggested = [mockMarketData[2]]; // SOL — suggested (not watchlisted) + + mockUsePerpsMarketListView.mockReturnValueOnce({ + markets: [btcMarket, ethMarket], + searchState: { + searchQuery: 'BTC', + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc', + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: true, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: true, + watchlistMarketObjects: [btcMarket, ethMarket], + suggestedMarkets: suggested, + }, + marketTypeFilterState: { + marketTypeFilter: 'all', + setMarketTypeFilter: jest.fn(), + }, + marketCounts: { + crypto: 3, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + isLoading: false, + error: null, + }); + + renderWithProvider(, { state: mockState }); + + // BTC matches the watchlist query; ETH does not + expect(screen.getByTestId('watchlist-row-BTC')).toBeOnTheScreen(); + expect(screen.queryByTestId('watchlist-row-ETH')).not.toBeOnTheScreen(); + // SOL is in suggestions but doesn't match "BTC", so it is filtered out too + expect(screen.queryByTestId('suggested-row-SOL')).not.toBeOnTheScreen(); + }); + + it('shows a matching suggestion when it is in suggestions but not in the watchlist', () => { + mockWatchlistFlagEnabled = true; + + const btcMarket = mockMarketData[0]; // BTC — watchlisted + const ethMarket = mockMarketData[1]; // ETH — suggested (not watchlisted) + + mockUsePerpsMarketListView.mockReturnValueOnce({ + markets: [btcMarket], + searchState: { + searchQuery: 'ETH', + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc', + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: true, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: true, + watchlistMarketObjects: [btcMarket], + suggestedMarkets: [ethMarket], + }, + marketTypeFilterState: { + marketTypeFilter: 'all', + setMarketTypeFilter: jest.fn(), + }, + marketCounts: { + crypto: 3, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + isLoading: false, + error: null, + }); + + renderWithProvider(, { state: mockState }); + + // BTC is watchlisted but doesn't match "ETH" + expect(screen.queryByTestId('watchlist-row-BTC')).not.toBeOnTheScreen(); + // ETH is in suggestions and matches — rendered under suggested section + expect(screen.getByTestId('suggested-row-ETH')).toBeOnTheScreen(); + // No-results state must NOT appear since a suggestion matched + expect( + screen.queryByTestId(PerpsMarketListViewSelectorsIDs.NO_RESULTS), + ).not.toBeOnTheScreen(); + }); + + it('shows the no-results empty state when a search query matches nothing in watchlist or suggestions', () => { + mockWatchlistFlagEnabled = true; + + const btcMarket = mockMarketData[0]; // BTC — watchlisted + const ethMarket = mockMarketData[1]; // ETH — suggested + + mockUsePerpsMarketListView.mockReturnValueOnce({ + markets: [btcMarket], + searchState: { + searchQuery: 'XYZ', + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc', + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: true, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: true, + watchlistMarketObjects: [btcMarket], + suggestedMarkets: [ethMarket], + }, + marketTypeFilterState: { + marketTypeFilter: 'all', + setMarketTypeFilter: jest.fn(), + }, + marketCounts: { + crypto: 3, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + isLoading: false, + error: null, + }); + + renderWithProvider(, { state: mockState }); + + // Neither watchlist (BTC) nor suggestion (ETH) matches "XYZ" + expect( + screen.getByTestId(PerpsMarketListViewSelectorsIDs.NO_RESULTS), + ).toBeOnTheScreen(); + expect( + screen.queryByTestId('perps-watchlist-markets'), + ).not.toBeOnTheScreen(); + }); + + it('restores full watchlist and suggestions when search query is cleared', () => { + mockWatchlistFlagEnabled = true; + + const btcMarket = mockMarketData[0]; + const ethMarket = mockMarketData[1]; + const suggested = [mockMarketData[2]]; // SOL + + mockUsePerpsMarketListView.mockReturnValueOnce({ + markets: [btcMarket, ethMarket], + searchState: { + searchQuery: '', + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc', + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: true, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: true, + watchlistMarketObjects: [btcMarket, ethMarket], + suggestedMarkets: suggested, + }, + marketTypeFilterState: { + marketTypeFilter: 'all', + setMarketTypeFilter: jest.fn(), + }, + marketCounts: { + crypto: 3, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + isLoading: false, + error: null, + }); + + renderWithProvider(, { state: mockState }); + + // All watchlist rows visible, suggestion restored + expect(screen.getByTestId('watchlist-row-BTC')).toBeOnTheScreen(); + expect(screen.getByTestId('watchlist-row-ETH')).toBeOnTheScreen(); + expect(screen.getByTestId('suggested-row-SOL')).toBeOnTheScreen(); + }); }); describe('Market Selection', () => { @@ -765,7 +1338,7 @@ describe('PerpsMarketListView', () => { expect(() => fireEvent.press(btcRows[0])).not.toThrow(); }); - it('navigates to SPCX details with market-list source when SPCX is pressed', () => { + it('navigates to SPCX details via push with market-list source when SPCX is pressed', () => { const spcxMarket: PerpsMarketData = { symbol: 'xyz:SPCX', name: 'SPCX', @@ -783,14 +1356,20 @@ describe('PerpsMarketListView', () => { fireEvent.press(screen.getByTestId('market-row-xyz:SPCX')); - expect(mockNavigateToMarketDetails).toHaveBeenCalledWith( - spcxMarket, - 'perp_markets', - undefined, + expect(mockNavigation.dispatch).toHaveBeenCalledTimes(1); + expect(mockNavigation.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + params: expect.objectContaining({ + market: spcxMarket, + source: 'perp_markets', + }), + }), + }), ); }); - it('carries route transactionActiveAbTests when a market opens market details', () => { + it('carries route transactionActiveAbTests when a market row is pressed', () => { const transactionActiveAbTests = [ createActiveABTestAssignment( 'homeTMCU725AbtestHomepagePerpsPillsEmptyState', @@ -807,10 +1386,17 @@ describe('PerpsMarketListView', () => { fireEvent.press(screen.getAllByTestId('market-row-BTC')[0]); - expect(mockNavigateToMarketDetails).toHaveBeenCalledWith( - mockMarketData[0], - 'perp_markets', - transactionActiveAbTests, + expect(mockNavigation.dispatch).toHaveBeenCalledTimes(1); + expect(mockNavigation.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + params: expect.objectContaining({ + market: mockMarketData[0], + source: 'perp_markets', + transactionActiveAbTests, + }), + }), + }), ); }); }); @@ -946,6 +1532,9 @@ describe('PerpsMarketListView', () => { favoritesState: { showFavoritesOnly: false, setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: false, + watchlistMarketObjects: [], + suggestedMarkets: [], }, marketTypeFilterState: { marketTypeFilter: 'all', diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index dd25a34d8555..2a45a6b7424f 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; import { HeaderStandard } from '@metamask/design-system-react-native'; -import { View, Animated } from 'react-native'; +import { View, Animated, ScrollView } from 'react-native'; import { useStyles } from '../../../../../component-library/hooks'; import Icon, { IconName, @@ -34,7 +34,13 @@ import { type MarketTypeFilter, } from '@metamask/perps-controller'; import { PerpsMarketListViewSelectorsIDs } from '../../Perps.testIds'; -import { useRoute, RouteProp } from '@react-navigation/native'; +import { + useRoute, + RouteProp, + useNavigation, + StackActions, +} from '@react-navigation/native'; +import Routes from '../../../../../constants/navigation/Routes'; import { useSelector } from 'react-redux'; import { SafeAreaView } from 'react-native-safe-area-context'; import { TraceName } from '../../../../../util/trace'; @@ -56,6 +62,7 @@ const PerpsMarketListView = ({ useRoute>(); const perpsNavigation = usePerpsNavigation(); + const navigation = useNavigation(); const variant = route.params?.variant ?? propVariant ?? 'full'; const title = route.params?.title ?? propTitle; @@ -114,14 +121,23 @@ const PerpsMarketListView = ({ if (onMarketSelect) { onMarketSelect(market); } else { - perpsNavigation.navigateToMarketDetails( - market, - PERPS_EVENT_VALUE.SOURCE.PERP_MARKETS, - transactionActiveAbTests, + // Use push instead of navigate so that MARKET_LIST is always beneath + // MARKET_DETAILS in the stack. navigate() can jump to an existing + // MARKET_DETAILS entry (e.g. one opened from PerpsHome via the watchlist + // component's ROOT-based navigation), which would skip MARKET_LIST on + // back and land the user on PERPS_HOME instead. + navigation.dispatch( + StackActions.push(Routes.PERPS.MARKET_DETAILS, { + market, + source: PERPS_EVENT_VALUE.SOURCE.PERP_MARKETS, + ...(transactionActiveAbTests?.length + ? { transactionActiveAbTests } + : {}), + }), ); } }, - [onMarketSelect, perpsNavigation, transactionActiveAbTests], + [onMarketSelect, navigation, transactionActiveAbTests], ); const { track } = usePerpsEventTracking(); @@ -245,15 +261,76 @@ const PerpsMarketListView = ({ ); } - // Empty watchlist — show suggested markets with the same default state as PerpsHome - // Only reachable when the watchlist flag is enabled (pill is hidden otherwise) - if (isWatchlistEnabled && showFavoritesOnly && !hasWatchlistMarkets) { + // Watchlist filter active — show watchlisted markets plus suggestions. + // Mirrors PerpsHome behavior, without the collapsible "Show more" toggle. + // Only reachable when the watchlist flag is enabled (pill is hidden otherwise). + // When a search query is active both watchlist rows and suggested markets are + // filtered inline so the user can find any relevant market by name or symbol. + // "No tokens found" is only shown when nothing matches in either section. + if (isWatchlistEnabled && showFavoritesOnly) { + const trimmedQuery = searchQuery.trim().toLowerCase(); + const visibleWatchlistMarkets = trimmedQuery + ? watchlistMarketObjects.filter( + (m) => + m.symbol.toLowerCase().includes(trimmedQuery) || + m.name.toLowerCase().includes(trimmedQuery), + ) + : watchlistMarketObjects; + const visibleSuggestedMarkets = trimmedQuery + ? suggestedMarkets?.filter( + (m) => + m.symbol.toLowerCase().includes(trimmedQuery) || + m.name.toLowerCase().includes(trimmedQuery), + ) + : suggestedMarkets; + + if ( + trimmedQuery && + visibleWatchlistMarkets.length === 0 && + !visibleSuggestedMarkets?.length + ) { + return ( + + + + {strings('perps.no_tokens_found')} + + + {strings('perps.no_tokens_found_description', { searchQuery })} + + + ); + } + return ( - + + + ); } diff --git a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx index fae4501d08da..ccd879d4c788 100644 --- a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx +++ b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx @@ -496,6 +496,49 @@ describe('PerpsWatchlistMarkets', () => { }); }); + // ------------------------------------------------------------------------- + // onMarketPress override + // ------------------------------------------------------------------------- + + describe('onMarketPress prop (navigation override)', () => { + it('calls onMarketPress instead of navigation.navigate when a watchlist row is pressed', () => { + const onMarketPress = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByTestId('perps-market-row-BTC')); + expect(onMarketPress).toHaveBeenCalledTimes(1); + expect(onMarketPress).toHaveBeenCalledWith(mockMarkets[0]); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('calls onMarketPress instead of navigation.navigate when a suggested row is pressed', () => { + const onMarketPress = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByTestId('perps-market-row-SOL')); + expect(onMarketPress).toHaveBeenCalledTimes(1); + expect(onMarketPress).toHaveBeenCalledWith(mockSuggestedMarkets[0]); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('falls back to navigation.navigate when onMarketPress is not provided', () => { + render( + , + ); + fireEvent.press(screen.getByTestId('perps-market-row-BTC')); + expect(mockNavigate).toHaveBeenCalledTimes(1); + }); + }); + // ------------------------------------------------------------------------- // onSeeAllPress header behaviour // ------------------------------------------------------------------------- @@ -629,6 +672,58 @@ describe('PerpsWatchlistMarkets', () => { }); }); + // ------------------------------------------------------------------------- + // enableShowMore prop + // ------------------------------------------------------------------------- + + describe('enableShowMore prop', () => { + const fiveMarkets = [ + makeMarket('BTC', 'Bitcoin'), + makeMarket('ETH', 'Ethereum'), + makeMarket('SOL', 'Solana'), + makeMarket('AVAX', 'Avalanche'), + makeMarket('DOT', 'Polkadot'), + ]; + + it('shows all markets without a "Show more" button when enableShowMore is false', () => { + render( + , + ); + // All five markets visible + expect(screen.getByText('BTC')).toBeOnTheScreen(); + expect(screen.getByText('ETH')).toBeOnTheScreen(); + expect(screen.getByText('SOL')).toBeOnTheScreen(); + expect(screen.getByText('AVAX')).toBeOnTheScreen(); + expect(screen.getByText('DOT')).toBeOnTheScreen(); + // No show-more toggle + expect(screen.queryByText(/Show \d+ more/)).not.toBeOnTheScreen(); + expect(screen.queryByText('Show less')).not.toBeOnTheScreen(); + }); + + it('still renders suggestions below watchlist rows when enableShowMore is false', () => { + render( + , + ); + // All watchlist rows visible (incl. markets beyond INITIAL_DISPLAY_COUNT) + expect(screen.getByText('DOT')).toBeOnTheScreen(); + // Suggested rows present with add buttons — BNB only exists in suggestions + expect( + screen.getByTestId('perps-market-row-BNB-add-button'), + ).toBeOnTheScreen(); + // No toggle + expect(screen.queryByText(/Show \d+ more/)).not.toBeOnTheScreen(); + }); + + it('defaults to showing show-more toggle when enableShowMore is not specified', () => { + render(); + expect(screen.getByText('Show 2 more')).toBeOnTheScreen(); + }); + }); + // ------------------------------------------------------------------------- // Component lifecycle // ------------------------------------------------------------------------- diff --git a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx index 9f6450883ee0..00e0cd9ac2cf 100644 --- a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx +++ b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx @@ -61,10 +61,19 @@ interface PerpsWatchlistMarketsProps { headerStyle?: StyleProp; /** Override content container styles (e.g., to remove horizontal margin) */ contentContainerStyle?: StyleProp; + /** + * Override the default internal navigation when a market row is pressed. + * Use this when the parent screen owns navigation (e.g. PerpsMarketListView), + * so that onMarketSelect and transactionActiveAbTests are handled correctly. + * When omitted the component falls back to its own navigation.navigate call. + */ + onMarketPress?: (market: PerpsMarketData) => void; /** Called when the "Watchlist >" header is pressed */ onSeeAllPress?: () => void; /** Whether to render the "Watchlist" section header. Defaults to true. */ showHeader?: boolean; + /** Whether to render the collapsible "Show more"/"Show less" toggle. Defaults to true. */ + enableShowMore?: boolean; } // ─── Legacy (flag OFF) ────────────────────────────────────────────────────── @@ -82,11 +91,17 @@ const PerpsWatchlistMarketsV1: React.FC = ({ transactionActiveAbTests, sectionStyle, contentContainerStyle, + onMarketPress, }) => { const navigation = useNavigation(); const handleMarketPress = useCallback( (market: PerpsMarketData) => { + if (onMarketPress) { + onMarketPress(market); + return; + } + const hasPosition = positions.some((p) => p.symbol === market.symbol); const hasOrder = orders.some((o) => o.symbol === market.symbol); @@ -109,7 +124,14 @@ const PerpsWatchlistMarketsV1: React.FC = ({ }, }); }, - [navigation, positions, orders, source, transactionActiveAbTests], + [ + onMarketPress, + navigation, + positions, + orders, + source, + transactionActiveAbTests, + ], ); const renderMarket = useCallback( @@ -161,8 +183,10 @@ const PerpsWatchlistMarketsV2: React.FC = ({ sectionStyle, headerStyle, contentContainerStyle, + onMarketPress, onSeeAllPress, showHeader = true, + enableShowMore = true, }) => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); @@ -175,6 +199,11 @@ const PerpsWatchlistMarketsV2: React.FC = ({ const handleMarketPress = useCallback( (market: PerpsMarketData) => { + if (onMarketPress) { + onMarketPress(market); + return; + } + const hasPosition = positions.some((p) => p.symbol === market.symbol); const hasOrder = orders.some((o) => o.symbol === market.symbol); @@ -197,7 +226,14 @@ const PerpsWatchlistMarketsV2: React.FC = ({ }, }); }, - [navigation, positions, orders, source, transactionActiveAbTests], + [ + onMarketPress, + navigation, + positions, + orders, + source, + transactionActiveAbTests, + ], ); const watchlistHeader = showHeader ? ( @@ -235,7 +271,8 @@ const PerpsWatchlistMarketsV2: React.FC = ({ } // Expand/collapse applies only to the watchlist rows - const hasMore = hasWatchlist && markets.length > INITIAL_DISPLAY_COUNT; + const hasMore = + enableShowMore && hasWatchlist && markets.length > INITIAL_DISPLAY_COUNT; const displayedMarkets = hasMore && !expanded ? markets.slice(0, INITIAL_DISPLAY_COUNT) : markets; const hiddenCount = markets.length - INITIAL_DISPLAY_COUNT;