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/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/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; 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/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; 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, + }, }; // ============================================================================