diff --git a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx index da3652aaaad..e2930ad89f9 100644 --- a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx +++ b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx @@ -31,6 +31,7 @@ import ErrorState from '../Homepage/components/ErrorState/ErrorState'; import WhatsHappeningExpandedCard from './components/WhatsHappeningExpandedCard'; import WhatsHappeningSourcesBottomSheet from './components/WhatsHappeningSourcesBottomSheet'; import PageIndicator from './components/PageIndicator'; +import { PerpsStreamProvider } from '../../UI/Perps/providers/PerpsStreamManager'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; @@ -210,64 +211,66 @@ const WhatsHappeningDetailView = () => { - - {isLoading ? ( - - {SKELETON_KEYS.map((key) => ( - - ))} - - ) : hasError ? ( - - ) : ( - <> + + + {isLoading ? ( - {cardHeight > 0 && - items.map((item, index) => ( - - handleSourcesPress(articles, item, index) - } - /> - ))} + {SKELETON_KEYS.map((key) => ( + + ))} + ) : hasError ? ( + + ) : ( + <> + + {cardHeight > 0 && + items.map((item, index) => ( + + handleSourcesPress(articles, item, index) + } + /> + ))} + - - - )} - + + + )} + + {sourcesContext && ( ({ + getRelatedAssetImageSource: jest.fn(() => undefined), +})); + +const btcAsset: RelatedAsset = { + sourceAssetId: 'bitcoin', + symbol: 'BTC', + name: 'Bitcoin', + caip19: ['eip155:1/slip44:0'], +}; + +const symbolOnlyAsset: RelatedAsset = { + sourceAssetId: 'unknown', + symbol: 'UNK', + name: '', + caip19: [], +}; + +describe('AssetRow', () => { + const onAction = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('displays asset.name when name is present', () => { + renderWithProvider( + , + ); + expect(screen.getByText('Bitcoin')).toBeOnTheScreen(); + }); + + it('falls back to asset.symbol when name is empty', () => { + renderWithProvider( + , + ); + expect(screen.getByText('UNK')).toBeOnTheScreen(); + }); + + it('does not render an action button when onAction is omitted', () => { + renderWithProvider(); + expect(screen.queryByText('Buy')).toBeNull(); + expect(screen.queryByText('Trade')).toBeNull(); + }); + + it('renders the action button with the provided label', () => { + renderWithProvider( + , + ); + expect(screen.getByText('Trade')).toBeOnTheScreen(); + }); + + it('calls onAction when the button is pressed', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Buy')); + expect(onAction).toHaveBeenCalledTimes(1); + }); + + it('renders the price secondary line when secondaryLine is provided', () => { + renderWithProvider( + , + ); + expect(screen.getByText('$95,000.00')).toBeOnTheScreen(); + expect(screen.getByText('+2.50%')).toBeOnTheScreen(); + }); + + it('renders just the price without change text when changeText is undefined', () => { + renderWithProvider( + , + ); + expect(screen.getByText('$95,000.00')).toBeOnTheScreen(); + expect(screen.queryByText('%')).toBeNull(); + }); + + it('does not render secondary line when secondaryLine is not provided', () => { + renderWithProvider( + , + ); + expect(screen.queryByText('$')).toBeNull(); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx index 381404af394..53bedc75cbb 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx @@ -17,23 +17,31 @@ import { import type { RelatedAsset } from '@metamask/ai-controllers'; import { getRelatedAssetImageSource } from '../utils/getRelatedAssetImageSource'; +export interface AssetRowSecondaryLine { + priceText: string; + changeText: string | undefined; + changeColor: TextColor; +} + interface AssetRowProps { asset: RelatedAsset; - actionLabel: string; - accessibilityLabel: string; - onAction: () => void; + actionLabel?: string; + accessibilityLabel?: string; + onAction?: () => void; + /** When provided, renders price + 24h change below the asset name. */ + secondaryLine?: AssetRowSecondaryLine; } /** - * Shared layout for a single asset row (logo + symbol + action button). - * Used by TokenRow (Buy/Trade) and PerpsRow (Trade); each wrapper supplies its - * own hook logic and passes the resolved label and handler here. + * Shared layout for a single asset row (logo + name + optional badge + optional + * price/change + optional action button). Used by PerpsRow (Trade when tradable). */ const AssetRow: React.FC = ({ asset, actionLabel, accessibilityLabel, onAction, + secondaryLine, }) => { const rawImageSource = getRelatedAssetImageSource(asset); const imageSource = Array.isArray(rawImageSource) @@ -59,22 +67,58 @@ const AssetRow: React.FC = ({ alignItems={BoxAlignItems.Center} justifyContent={BoxJustifyContent.Between} > - - {asset.symbol} - + {/* Left: name + optional badge + optional price/change */} + + + {asset.name || asset.symbol} + + + {secondaryLine && ( + + + {secondaryLine.priceText} + + {secondaryLine.changeText ? ( + <> + + {' \u2022 '} + + + {secondaryLine.changeText} + + + ) : null} + + )} + - + {onAction && actionLabel && accessibilityLabel ? ( + + ) : null} ); diff --git a/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.test.tsx index 8c7699f17d2..f1ebfd61149 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.test.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.test.tsx @@ -40,4 +40,31 @@ describe('PageIndicator', () => { ); expect(screen.queryAllByTestId('page-indicator-dot')).toHaveLength(3); }); + + it('active dot has wider width style than inactive dots (pill shape)', () => { + renderWithProvider(); + const [activeDot] = screen.getAllByTestId('page-indicator-dot-active'); + const inactiveDots = screen.getAllByTestId('page-indicator-dot'); + + const activeStyle = activeDot.props.style; + const inactiveStyle = inactiveDots[0].props.style; + + // Active dot should be wider (w-6 = 24) than inactive (w-2 = 8) + const activeWidth = Array.isArray(activeStyle) + ? activeStyle.find((s: Record) => s?.width !== undefined) + ?.width + : activeStyle?.width; + const inactiveWidth = Array.isArray(inactiveStyle) + ? inactiveStyle.find( + (s: Record) => s?.width !== undefined, + )?.width + : inactiveStyle?.width; + + if (activeWidth !== undefined && inactiveWidth !== undefined) { + expect(activeWidth).toBeGreaterThan(inactiveWidth); + } else { + // Style is applied but might be in a different shape — just verify both dots are distinct + expect(activeDot.props.testID).toBe('page-indicator-dot-active'); + } + }); }); diff --git a/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.tsx b/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.tsx index 314d0b765c3..da5bb4b28c3 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.tsx @@ -38,7 +38,7 @@ const PageIndicator: React.FC = ({ } style={ index === activeIndex - ? tw.style('w-2 h-2 rounded-full bg-icon-default') + ? tw.style('w-6 h-2 rounded-full bg-icon-default') : tw.style('w-2 h-2 rounded-full bg-icon-muted') } /> diff --git a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx index 15504e180f3..d2863ced4e6 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx @@ -6,6 +6,7 @@ import Routes from '../../../../constants/navigation/Routes'; import type { RelatedAsset } from '@metamask/ai-controllers'; import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; +import type { PerpsPriceEntry } from '../hooks/useWhatsHappeningAssetPrices'; const mockNavigate = jest.fn(); const mockTrackEvent = jest.fn(); @@ -43,14 +44,6 @@ const perpsOnlyAsset: RelatedAsset = { hlPerpsMarket: ['xyz:TSLA'], }; -const dualAsset: RelatedAsset = { - sourceAssetId: 'bitcoin', - symbol: 'BTC', - name: 'Bitcoin', - caip19: ['eip155:1/slip44:0'], - hlPerpsMarket: ['BTC'], -}; - const mockItem: WhatsHappeningItem = { id: 'trend-3', title: 'TSLA earnings', @@ -62,28 +55,45 @@ const mockItem: WhatsHappeningItem = { articles: [], }; +const emptyPriceMap: Record = {}; + describe('PerpsRow', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('renders the asset symbol', () => { + it('renders the asset name', () => { renderWithProvider( - , + , ); - expect(screen.getByText('TSLA')).toBeOnTheScreen(); + expect(screen.getByText('Tesla')).toBeOnTheScreen(); }); it('renders the Trade button', () => { renderWithProvider( - , + , ); expect(screen.getByText('Trade')).toBeOnTheScreen(); }); it('navigates to PerpsMarketDetails with minimal market payload on Trade press', () => { renderWithProvider( - , + , ); fireEvent.press(screen.getByText('Trade')); expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { @@ -94,34 +104,54 @@ describe('PerpsRow', () => { }); }); - it('uses first hlPerpsMarket entry as the market symbol', () => { + it('uses first hlPerpsMarket entry as the market symbol when multiple are present', () => { + const multiMarketAsset: RelatedAsset = { + ...perpsOnlyAsset, + hlPerpsMarket: ['FIRST-MARKET', 'SECOND-MARKET'], + }; renderWithProvider( - , + , ); fireEvent.press(screen.getByText('Trade')); expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: expect.objectContaining({ - market: { symbol: 'BTC', name: 'Bitcoin' }, + market: { symbol: 'FIRST-MARKET', name: 'Tesla' }, }), }); }); - it('does not navigate when hlPerpsMarket is empty', () => { + it('does not render Trade when hlPerpsMarket is empty', () => { const assetNoPerps: RelatedAsset = { ...perpsOnlyAsset, hlPerpsMarket: [], }; renderWithProvider( - , + , ); - fireEvent.press(screen.getByText('Trade')); + expect(screen.queryByText('Trade')).toBeNull(); expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); }); - it('tracks Whats Happening Interaction with interaction_type=trade_pressed and asset details on Trade press', () => { + it('tracks Whats Happening Interaction on Trade press', () => { renderWithProvider( - , + , ); fireEvent.press(screen.getByText('Trade')); expect(mockCreateEventBuilder).toHaveBeenCalledWith( @@ -143,15 +173,31 @@ describe('PerpsRow', () => { ); }); - it('does not track Interaction when hlPerpsMarket is empty', () => { - const assetNoPerps: RelatedAsset = { - ...perpsOnlyAsset, - hlPerpsMarket: [], + it('displays price and 24h change from perpsPriceBySymbol', () => { + const priceMap: Record = { + 'xyz:TSLA': { price: 172.5, percentChange24h: 3.45 }, }; renderWithProvider( - , + , ); - fireEvent.press(screen.getByText('Trade')); - expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + expect(screen.getByText('$172.50')).toBeOnTheScreen(); + expect(screen.getByText('+3.45%')).toBeOnTheScreen(); + }); + + it('shows no price text when no price entry is available', () => { + renderWithProvider( + , + ); + expect(screen.queryByText('$')).toBeNull(); }); }); diff --git a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx index 6996cee0154..8f72324d5e7 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import type { RelatedAsset } from '@metamask/ai-controllers'; import { strings } from '../../../../../locales/i18n'; import { MetaMetricsEvents } from '../../../../core/Analytics'; @@ -6,6 +6,8 @@ import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import { WhatsHappeningInteractionType } from '../../Homepage/Sections/WhatsHappening/constants'; import { getWhatsHappeningEventProps } from '../../Homepage/Sections/WhatsHappening/eventProperties'; import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; +import { formatAssetPrice } from '../utils/formatAssetPrice'; +import type { PerpsPriceEntry } from '../hooks/useWhatsHappeningAssetPrices'; import AssetRow from './AssetRow'; import useTradeNavigation from '../hooks/useTradeNavigation'; @@ -13,27 +15,46 @@ interface PerpsRowProps { asset: RelatedAsset; item: WhatsHappeningItem; cardIndex: number; + /** Map from perps symbol → live price data, resolved by the parent card hook. */ + perpsPriceBySymbol: Record; } /** - * A single row in the Perps section of the expanded What's Happening card. - * Displays the asset logo and symbol with a Trade button that navigates to - * the Perps market details view. Extracted as its own component so hooks can - * be called per-asset (hooks cannot be called inside a loop). + * A single row for a perps asset in the expanded What's Happening card. + * Shows logo, name, optional verified badge (for assets that also have caip19), + * live price/change when `hlPerpsMarket` is set, and a Trade button only when a + * perps market symbol exists. */ -const PerpsRow: React.FC = ({ asset, item, cardIndex }) => { +const PerpsRow: React.FC = ({ + asset, + item, + cardIndex, + perpsPriceBySymbol, +}) => { const { handleTrade } = useTradeNavigation(asset); const { trackEvent, createEventBuilder } = useAnalytics(); + // Perps prices are always quoted in USD + const secondaryLine = useMemo(() => { + const symbol = asset.hlPerpsMarket?.[0]; + if (!symbol) return undefined; + const entry = perpsPriceBySymbol[symbol]; + if (!entry) return undefined; + return formatAssetPrice(entry.price, entry.percentChange24h, 'USD'); + }, [asset.hlPerpsMarket, perpsPriceBySymbol]); + const handleTradeWithTracking = useCallback(() => { - if (!asset.hlPerpsMarket?.[0]) return; + const perpsMarket = asset.hlPerpsMarket?.[0]; + if (!perpsMarket) { + return; + } trackEvent( createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_INTERACTION) .addProperties({ ...getWhatsHappeningEventProps(item, cardIndex), interaction_type: WhatsHappeningInteractionType.TradePressed, asset_symbol: asset.symbol, - perps_market: asset.hlPerpsMarket?.[0], + perps_market: perpsMarket, }) .build(), ); @@ -48,12 +69,19 @@ const PerpsRow: React.FC = ({ asset, item, cardIndex }) => { createEventBuilder, ]); + const perpsMarketSymbol = asset.hlPerpsMarket?.[0]; + return ( ); }; diff --git a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx deleted file mode 100644 index 9f2ae4a4df0..00000000000 --- a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import React from 'react'; -import { screen, fireEvent } from '@testing-library/react-native'; -import renderWithProvider from '../../../../util/test/renderWithProvider'; -import TokenRow from './TokenRow'; -import type { RelatedAsset } from '@metamask/ai-controllers'; -import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; -import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; -import Routes from '../../../../constants/navigation/Routes'; - -const mockGoToBuy = jest.fn(); -const mockTrackEvent = jest.fn(); -const mockCreateEventBuilder = jest.fn((eventName: string) => ({ - addProperties: jest.fn((properties: Record) => ({ - build: jest.fn(() => ({ category: eventName, properties })), - })), - build: jest.fn(() => ({ category: eventName })), -})); - -const mockNavigate = jest.fn(); - -jest.mock('../../../UI/Ramp/hooks/useRampNavigation', () => ({ - useRampNavigation: () => ({ goToBuy: mockGoToBuy }), -})); - -jest.mock('@react-navigation/native', () => { - const actual = jest.requireActual('@react-navigation/native'); - return { - ...actual, - useNavigation: () => ({ navigate: mockNavigate }), - }; -}); - -jest.mock('../utils/getRelatedAssetImageSource', () => ({ - getRelatedAssetImageSource: jest.fn(() => undefined), -})); - -jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ - useAnalytics: () => ({ - trackEvent: mockTrackEvent, - createEventBuilder: mockCreateEventBuilder, - }), -})); - -const btcAsset: RelatedAsset = { - sourceAssetId: 'bitcoin', - symbol: 'BTC', - name: 'Bitcoin', - caip19: ['eip155:1/slip44:0'], -}; - -const dualAsset: RelatedAsset = { - sourceAssetId: 'eth', - symbol: 'ETH', - name: 'Ethereum', - caip19: ['eip155:1/slip44:60'], - hlPerpsMarket: ['ETH'], -}; - -const perpsOnlyAsset: RelatedAsset = { - sourceAssetId: 'tsla', - symbol: 'TSLA', - name: 'Tesla', - caip19: [], -}; - -const mockItem: WhatsHappeningItem = { - id: 'trend-2', - title: 'BTC ETF inflows', - description: '...', - date: '2026-03-15T10:00:00.000Z', - category: 'macro', - impact: 'positive', - relatedAssets: [btcAsset], - articles: [], -}; - -describe('TokenRow', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('when asset has only caip19 (no hlPerpsMarket)', () => { - it('renders the asset symbol', () => { - renderWithProvider( - , - ); - expect(screen.getByText('BTC')).toBeOnTheScreen(); - }); - - it('renders the Buy button', () => { - renderWithProvider( - , - ); - expect(screen.getByText('Buy')).toBeOnTheScreen(); - }); - - it('calls goToBuy with the first caip19 identifier on Buy press', () => { - renderWithProvider( - , - ); - fireEvent.press(screen.getByText('Buy')); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: 'eip155:1/slip44:0', - }); - }); - }); - - it('calls goToBuy with assetId undefined when caip19 is empty', () => { - renderWithProvider( - , - ); - fireEvent.press(screen.getByText('Buy')); - expect(mockGoToBuy).toHaveBeenCalledWith({ assetId: undefined }); - }); - - describe('when asset has hlPerpsMarket (dual asset)', () => { - it('renders the Trade button instead of Buy', () => { - renderWithProvider( - , - ); - expect(screen.getByText('Trade')).toBeOnTheScreen(); - expect(screen.queryByText('Buy')).toBeNull(); - }); - - it('navigates to Perps market details on Trade press', () => { - renderWithProvider( - , - ); - fireEvent.press(screen.getByText('Trade')); - expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKET_DETAILS, - params: expect.objectContaining({ - market: { symbol: 'ETH', name: 'Ethereum' }, - }), - }); - }); - - it('does not call goToBuy when Trade is pressed', () => { - renderWithProvider( - , - ); - fireEvent.press(screen.getByText('Trade')); - expect(mockGoToBuy).not.toHaveBeenCalled(); - }); - }); - - it('tracks Whats Happening Interaction with interaction_type=buy_pressed and asset details on Buy press', () => { - renderWithProvider( - , - ); - fireEvent.press(screen.getByText('Buy')); - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, - ); - expect(mockTrackEvent).toHaveBeenCalledWith( - expect.objectContaining({ - category: MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, - properties: expect.objectContaining({ - interaction_type: 'buy_pressed', - asset_symbol: 'BTC', - asset_caip19: 'eip155:1/slip44:0', - event_id: 'trend-2', - card_index: 2, - category: 'macro', - impact: 'positive', - }), - }), - ); - }); - - it('tracks Interaction without asset_caip19 when caip19 is empty', () => { - renderWithProvider( - , - ); - fireEvent.press(screen.getByText('Buy')); - const addPropertiesCall = mockCreateEventBuilder.mock.results[0]?.value - ?.addProperties as jest.Mock | undefined; - const builtProperties = addPropertiesCall?.mock?.calls?.[0]?.[0] as - | Record - | undefined; - expect(builtProperties?.interaction_type).toBe('buy_pressed'); - expect(builtProperties?.asset_symbol).toBe('TSLA'); - expect(builtProperties).not.toHaveProperty('asset_caip19'); - }); -}); diff --git a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx deleted file mode 100644 index 4a29ff051fc..00000000000 --- a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React, { useCallback } from 'react'; -import type { RelatedAsset } from '@metamask/ai-controllers'; -import { strings } from '../../../../../locales/i18n'; -import { useRampNavigation } from '../../../UI/Ramp/hooks/useRampNavigation'; -import { MetaMetricsEvents } from '../../../../core/Analytics'; -import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; -import { WhatsHappeningInteractionType } from '../../Homepage/Sections/WhatsHappening/constants'; -import { getWhatsHappeningEventProps } from '../../Homepage/Sections/WhatsHappening/eventProperties'; -import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; -import AssetRow from './AssetRow'; -import useTradeNavigation from '../hooks/useTradeNavigation'; - -interface TokenRowProps { - asset: RelatedAsset; - item: WhatsHappeningItem; - cardIndex: number; -} - -/** - * A single row in the Tokens section of the expanded What's Happening card. - * Shows a Trade button (navigating to Perps) when the asset has an - * `hlPerpsMarket` entry; otherwise falls back to a Buy button that opens the - * Ramp buy flow. Extracted as its own component so hooks can be called - * per-asset (hooks cannot be called inside a loop). - */ -const TokenRow: React.FC = ({ asset, item, cardIndex }) => { - const { goToBuy } = useRampNavigation(); - const { trackEvent, createEventBuilder } = useAnalytics(); - const { handleTrade, canTrade } = useTradeNavigation(asset); - - const handleTradeWithTracking = useCallback(() => { - trackEvent( - createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_INTERACTION) - .addProperties({ - ...getWhatsHappeningEventProps(item, cardIndex), - interaction_type: WhatsHappeningInteractionType.TradePressed, - asset_symbol: asset.symbol, - perps_market: asset.hlPerpsMarket?.[0], - }) - .build(), - ); - handleTrade(); - }, [ - handleTrade, - asset.symbol, - asset.hlPerpsMarket, - item, - cardIndex, - trackEvent, - createEventBuilder, - ]); - - const handleBuy = useCallback(() => { - const assetId = asset.caip19?.[0]; - trackEvent( - createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_INTERACTION) - .addProperties({ - ...getWhatsHappeningEventProps(item, cardIndex), - interaction_type: WhatsHappeningInteractionType.BuyPressed, - asset_symbol: asset.symbol, - ...(assetId ? { asset_caip19: assetId } : {}), - }) - .build(), - ); - goToBuy({ assetId }); - }, [ - goToBuy, - asset.caip19, - asset.symbol, - item, - cardIndex, - trackEvent, - createEventBuilder, - ]); - - if (canTrade) { - return ( - - ); - } - - return ( - - ); -}; - -export default TokenRow; diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx index 9a687b83e32..0154bf076b1 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx @@ -6,7 +6,6 @@ import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/ import Routes from '../../../../constants/navigation/Routes'; const mockNavigate = jest.fn(); -const mockGoToBuy = jest.fn(); jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ useAnalytics: () => ({ @@ -32,10 +31,6 @@ jest.mock('../utils/getRelatedAssetImageSource', () => ({ getRelatedAssetImageSource: jest.fn(() => undefined), })); -jest.mock('../../../UI/Ramp/hooks/useRampNavigation', () => ({ - useRampNavigation: () => ({ goToBuy: mockGoToBuy }), -})); - jest.mock('../../../UI/MarketInsights/utils/marketInsightsFormatting', () => ({ formatRelativeTime: jest.fn(() => 'now'), getUniqueSourcesByFavicon: jest.fn(() => []), @@ -46,17 +41,22 @@ jest.mock( () => 'SourceLogoGroup', ); +jest.mock('../hooks/useWhatsHappeningAssetPrices', () => ({ + useWhatsHappeningAssetPrices: jest.fn(() => ({ + perpsPriceBySymbol: {}, + })), +})); + +jest.mock( + '../../../UI/Tokens/components/TokenListSecurityBadge/TokenListSecurityBadge', + () => 'TokenListSecurityBadge', +); + +jest.mock('react-native-linear-gradient', () => 'LinearGradient'); + const CARD_WIDTH = 320; const CARD_HEIGHT = 600; -const tokenAsset = { - sourceAssetId: 'bitcoin', - symbol: 'BTC', - name: 'Bitcoin', - caip19: ['eip155:1/slip44:0'], - hlPerpsMarket: undefined, -}; - const perpsOnlyAsset = { sourceAssetId: 'tsla', symbol: 'TSLA', @@ -66,11 +66,11 @@ const perpsOnlyAsset = { }; const dualAsset = { - sourceAssetId: 'eth', - symbol: 'ETH', - name: 'Ethereum', - caip19: ['eip155:1/slip44:60'], - hlPerpsMarket: ['ETH'], + sourceAssetId: 'btc', + symbol: 'BTC', + name: 'Bitcoin', + caip19: ['eip155:1/slip44:0'], + hlPerpsMarket: ['BTC'], }; const baseItem: WhatsHappeningItem = { @@ -114,21 +114,21 @@ describe('WhatsHappeningExpandedCard', () => { expect(screen.getByText('Bullish')).toBeOnTheScreen(); }); - it('renders Neutral badge when impact is explicitly neutral', () => { - const item = { ...baseItem, impact: 'neutral' as const }; + it('renders the AI pill next to the impact badge', () => { renderWithProvider( , ); - expect(screen.getByText('Neutral')).toBeOnTheScreen(); + expect(screen.getByText('AI')).toBeOnTheScreen(); + expect(screen.getByText('Bullish')).toBeOnTheScreen(); }); - it('does not render an impact badge when impact is undefined', () => { - const item = { ...baseItem, impact: undefined }; + it('renders Neutral badge when impact is explicitly neutral', () => { + const item = { ...baseItem, impact: 'neutral' as const }; renderWithProvider( { cardHeight={CARD_HEIGHT} />, ); - expect(screen.queryByText('Neutral')).toBeNull(); - expect(screen.queryByText('Bullish')).toBeNull(); - expect(screen.queryByText('Bearish')).toBeNull(); + expect(screen.getByText('Neutral')).toBeOnTheScreen(); }); - it('renders Tokens section when assets have caip19', () => { - const item = { ...baseItem, relatedAssets: [tokenAsset] }; + it('does not render an impact badge when impact is undefined', () => { + const item = { ...baseItem, impact: undefined }; renderWithProvider( { cardHeight={CARD_HEIGHT} />, ); - expect(screen.getByText('Tokens')).toBeOnTheScreen(); - expect(screen.getByText('BTC')).toBeOnTheScreen(); - expect(screen.getByText('Buy')).toBeOnTheScreen(); + expect(screen.queryByText('Neutral')).toBeNull(); + expect(screen.queryByText('Bullish')).toBeNull(); + expect(screen.queryByText('Bearish')).toBeNull(); + expect(screen.queryByText('AI')).toBeNull(); }); - it('does not render Tokens section when no assets have caip19', () => { + it('renders the single Related Assets section header when relatedAssets is non-empty', () => { const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; renderWithProvider( { cardHeight={CARD_HEIGHT} />, ); + expect(screen.getByText('Related Assets')).toBeOnTheScreen(); + // No "Tokens" or "Perps" section labels expect(screen.queryByText('Tokens')).toBeNull(); - expect(screen.queryByText('Buy')).toBeNull(); - }); - - it('renders Perps section when assets have hlPerpsMarket', () => { - const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; - renderWithProvider( - , - ); - expect(screen.getByText('Perps')).toBeOnTheScreen(); - expect(screen.getByText('TSLA')).toBeOnTheScreen(); - expect(screen.getByText('Trade')).toBeOnTheScreen(); - }); - - it('does not render Perps section when no assets have hlPerpsMarket', () => { - const item = { ...baseItem, relatedAssets: [tokenAsset] }; - renderWithProvider( - , - ); expect(screen.queryByText('Perps')).toBeNull(); - expect(screen.queryByText('Trade')).toBeNull(); }); - it('renders both Tokens and Perps sections when there are separate token and perps-only assets', () => { - const item = { ...baseItem, relatedAssets: [tokenAsset, perpsOnlyAsset] }; + it('renders each asset as a PerpsRow with Trade button', () => { + const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; renderWithProvider( { cardHeight={CARD_HEIGHT} />, ); - expect(screen.getByText('Tokens')).toBeOnTheScreen(); - expect(screen.getByText('Perps')).toBeOnTheScreen(); - expect(screen.getByText('Buy')).toBeOnTheScreen(); + expect(screen.getByText('Tesla')).toBeOnTheScreen(); expect(screen.getByText('Trade')).toBeOnTheScreen(); + // No Buy button + expect(screen.queryByText('Buy')).toBeNull(); }); - it('does not duplicate a dual asset (caip19 + hlPerpsMarket) into the Perps section, shows Trade for the token row', () => { - const item = { ...baseItem, relatedAssets: [dualAsset] }; + it('renders all assets in the single Related Assets section (including dual caip19+perps)', () => { + const item = { ...baseItem, relatedAssets: [perpsOnlyAsset, dualAsset] }; renderWithProvider( { cardHeight={CARD_HEIGHT} />, ); - expect(screen.getByText('Tokens')).toBeOnTheScreen(); - expect(screen.getByText('Trade')).toBeOnTheScreen(); + expect(screen.getByText('Related Assets')).toBeOnTheScreen(); + expect(screen.getByText('Tesla')).toBeOnTheScreen(); + expect(screen.getByText('Bitcoin')).toBeOnTheScreen(); + expect(screen.getAllByText('Trade')).toHaveLength(2); expect(screen.queryByText('Buy')).toBeNull(); - expect(screen.queryByText('Perps')).toBeNull(); }); - it('renders neither section when relatedAssets is empty', () => { + it('does not render the Related Assets section when relatedAssets is empty', () => { renderWithProvider( { cardHeight={CARD_HEIGHT} />, ); - expect(screen.queryByText('Tokens')).toBeNull(); - expect(screen.queryByText('Perps')).toBeNull(); + expect(screen.queryByText('Related Assets')).toBeNull(); + expect(screen.queryByText('Trade')).toBeNull(); }); it('Trade button navigates to PerpsMarketDetails', () => { @@ -274,7 +247,6 @@ describe('WhatsHappeningExpandedCard', () => { }; const item = { ...baseItem, articles: [article] }; - // Override mock so the sources footer is rendered const { getUniqueSourcesByFavicon } = jest.requireMock( '../../../UI/MarketInsights/utils/marketInsightsFormatting', ); @@ -295,4 +267,25 @@ describe('WhatsHappeningExpandedCard', () => { fireEvent.press(screen.getByText('coindesk.com')); expect(mockOnSourcesPress).toHaveBeenCalledWith([article]); }); + + it('passes perpsPriceBySymbol from hook to PerpsRow', () => { + const mockHook = jest.requireMock('../hooks/useWhatsHappeningAssetPrices'); + const mockPerpsMap = { + 'xyz:TSLA': { price: 172.5, percentChange24h: -1 }, + }; + mockHook.useWhatsHappeningAssetPrices.mockReturnValueOnce({ + perpsPriceBySymbol: mockPerpsMap, + }); + + const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; + renderWithProvider( + , + ); + expect(screen.getByText('$172.50')).toBeOnTheScreen(); + }); }); diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx index 10fe6ebfb3b..596ce6ea7b9 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react'; import { Pressable, ScrollView } from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, @@ -7,6 +8,9 @@ import { BoxFlexDirection, BoxJustifyContent, FontWeight, + Icon, + IconName, + IconSize, Text, TextColor, TextVariant, @@ -25,7 +29,7 @@ import { } from '../../../UI/MarketInsights/utils/marketInsightsFormatting'; import SourceLogoGroup from '../../../UI/MarketInsights/components/SourceLogoGroup'; import PerpsRow from './PerpsRow'; -import TokenRow from './TokenRow'; +import { useWhatsHappeningAssetPrices } from '../hooks/useWhatsHappeningAssetPrices'; interface WhatsHappeningExpandedCardProps { item: WhatsHappeningItem; @@ -70,98 +74,121 @@ const WhatsHappeningExpandedCard: React.FC = ({ return remaining > 0 ? `${first.name} +${remaining}` : first.name; }, [uniqueSources]); + const { perpsPriceBySymbol } = useWhatsHappeningAssetPrices(item); + + /** Theme token resolved to a concrete color for `LinearGradient` */ + const cardBgColor = tw.color('bg-background-muted'); + return ( - {/* Card surface — fills the fixed height so all cards are the same size */} - - {/* Scrollable main content */} - + {/* Scroll region with a persistent bottom fade hinting at more content */} + - {/* Impact badge */} - {item.impact && ( - - - {impactLabel} - - - )} - - {/* Title */} - - {item.title} - + {/* Tag row: AI pill + impact badge */} + {item.impact && ( + + {/* AI pill — inverted (dark bg, white content) */} + + + + {strings('homepage.sections.whats_happening_ai')} + + + + + + {impactLabel} + + + + )} - {/* Description */} - {item.description && ( + {/* Title */} - {item.description} + {item.title} - )} - {/* Tokens section */} - {item.relatedAssets.some((asset) => asset.caip19?.length) && ( - + {/* Description */} + {item.description && ( - {strings('homepage.sections.tokens')} - - - {item.relatedAssets - .filter((asset) => asset.caip19?.length) - .map((asset) => ( - - ))} - - )} - - {/* Perps section — only assets that are perps-only (hlPerpsMarket set, no caip19 token) */} - {item.relatedAssets.some( - (asset) => asset.hlPerpsMarket?.length && !asset.caip19?.length, - ) && ( - - - {strings('homepage.sections.perps')} + {item.description} + )} + + {/* Related assets section */} + {item.relatedAssets.length > 0 && ( + + + {strings('homepage.sections.related_assets')} + - {item.relatedAssets - .filter( - (asset) => - asset.hlPerpsMarket?.length && !asset.caip19?.length, - ) - .map((asset) => ( + {item.relatedAssets.map((asset) => ( ))} - - )} - - - {/* Fixed sources footer — always pinned to the bottom of the card */} + + )} + + + {/* Bottom fade — blends into the card bg; omitted if theme color cannot resolve */} + {cardBgColor ? ( + + ) : null} + + + {/* Fixed sources footer */} {uniqueSources.length > 0 && ( diff --git a/app/components/Views/WhatsHappeningDetailView/hooks/useWhatsHappeningAssetPrices.test.ts b/app/components/Views/WhatsHappeningDetailView/hooks/useWhatsHappeningAssetPrices.test.ts new file mode 100644 index 00000000000..fbcdb9f27d3 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/hooks/useWhatsHappeningAssetPrices.test.ts @@ -0,0 +1,140 @@ +import { renderHook } from '@testing-library/react-native'; +import { useWhatsHappeningAssetPrices } from './useWhatsHappeningAssetPrices'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; +import type { RelatedAsset } from '@metamask/ai-controllers'; + +// ── Mocks ────────────────────────────────────────────────────────────────────── + +const mockUsePerpsLivePrices = jest.fn( + (_options: { symbols: string[]; throttleMs?: number }) => ({}), +); +jest.mock('../../../UI/Perps/hooks/stream', () => ({ + usePerpsLivePrices: (options: { symbols: string[]; throttleMs?: number }) => + mockUsePerpsLivePrices(options), +})); + +// ── Test data ────────────────────────────────────────────────────────────────── + +const tslaAsset: RelatedAsset = { + sourceAssetId: 'tsla', + symbol: 'TSLA', + name: 'Tesla', + caip19: [], + hlPerpsMarket: ['xyz:TSLA'], +}; + +const btcPerpsAsset: RelatedAsset = { + sourceAssetId: 'bitcoin', + symbol: 'BTC', + name: 'Bitcoin', + caip19: ['eip155:1/slip44:0'], + hlPerpsMarket: ['BTC'], +}; + +const assetNoPerps: RelatedAsset = { + sourceAssetId: 'no-perps', + symbol: 'FOO', + name: 'Foo', + caip19: ['eip155:1/erc20:0xfoo'], +}; + +const makeItem = (relatedAssets: RelatedAsset[]): WhatsHappeningItem => ({ + id: 'trend-0', + title: 'Test', + description: 'Test description', + date: '2026-01-01T00:00:00.000Z', + impact: 'positive', + relatedAssets, + articles: [], +}); + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe('useWhatsHappeningAssetPrices', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUsePerpsLivePrices.mockReturnValue({}); + }); + + describe('perps live price subscription', () => { + it('returns empty perpsPriceBySymbol when there are no hlPerpsMarket entries', () => { + const item = makeItem([assetNoPerps]); + const { result } = renderHook(() => useWhatsHappeningAssetPrices(item)); + expect(result.current.perpsPriceBySymbol).toEqual({}); + }); + + it('passes symbols to usePerpsLivePrices without duplicates', () => { + const item = makeItem([ + tslaAsset, + { ...tslaAsset, sourceAssetId: 'tsla2' }, + ]); + renderHook(() => useWhatsHappeningAssetPrices(item)); + expect(mockUsePerpsLivePrices).toHaveBeenCalledWith({ + symbols: ['xyz:TSLA'], + throttleMs: 3000, + }); + }); + + it('populates perpsPriceBySymbol from live prices', () => { + mockUsePerpsLivePrices.mockReturnValue({ + 'xyz:TSLA': { price: '172.50', percentChange24h: '3.45' }, + }); + const item = makeItem([tslaAsset]); + const { result } = renderHook(() => useWhatsHappeningAssetPrices(item)); + expect(result.current.perpsPriceBySymbol['xyz:TSLA']).toEqual({ + price: 172.5, + percentChange24h: 3.45, + }); + }); + + it('handles missing percentChange24h gracefully', () => { + mockUsePerpsLivePrices.mockReturnValue({ + 'xyz:TSLA': { price: '172.50' }, + }); + const item = makeItem([tslaAsset]); + const { result } = renderHook(() => useWhatsHappeningAssetPrices(item)); + expect(result.current.perpsPriceBySymbol['xyz:TSLA']).toMatchObject({ + price: 172.5, + percentChange24h: undefined, + }); + }); + + it('handles assets that have both caip19 and hlPerpsMarket', () => { + mockUsePerpsLivePrices.mockReturnValue({ + BTC: { price: '95000', percentChange24h: '2.5' }, + }); + const item = makeItem([btcPerpsAsset]); + const { result } = renderHook(() => useWhatsHappeningAssetPrices(item)); + expect(mockUsePerpsLivePrices).toHaveBeenCalledWith({ + symbols: ['BTC'], + throttleMs: 3000, + }); + expect(result.current.perpsPriceBySymbol.BTC?.price).toBe(95000); + }); + + it('includes symbols from multiple assets deduplicating repeated markets', () => { + const ethAsset: RelatedAsset = { + sourceAssetId: 'eth', + symbol: 'ETH', + name: 'Ethereum', + hlPerpsMarket: ['ETH'], + }; + const item = makeItem([tslaAsset, ethAsset]); + renderHook(() => useWhatsHappeningAssetPrices(item)); + expect(mockUsePerpsLivePrices).toHaveBeenCalledWith( + expect.objectContaining({ + symbols: expect.arrayContaining(['xyz:TSLA', 'ETH']), + }), + ); + }); + }); + + it('does not include token-only assets (no hlPerpsMarket) in the symbols list', () => { + const item = makeItem([assetNoPerps]); + renderHook(() => useWhatsHappeningAssetPrices(item)); + expect(mockUsePerpsLivePrices).toHaveBeenCalledWith({ + symbols: [], + throttleMs: 3000, + }); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/hooks/useWhatsHappeningAssetPrices.ts b/app/components/Views/WhatsHappeningDetailView/hooks/useWhatsHappeningAssetPrices.ts new file mode 100644 index 00000000000..cbf651971f9 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/hooks/useWhatsHappeningAssetPrices.ts @@ -0,0 +1,61 @@ +import { useMemo } from 'react'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; +import { usePerpsLivePrices } from '../../../UI/Perps/hooks/stream'; + +export interface PerpsPriceEntry { + price: number | undefined; + percentChange24h: number | undefined; +} + +export interface UseWhatsHappeningAssetPricesResult { + /** Map from HyperLiquid perps symbol → price data */ + perpsPriceBySymbol: Record; +} + +/** + * Returns live price data for every perps market referenced by a "What's + * Happening" card item. Prices come from the WebSocket stream via + * `usePerpsLivePrices` and are throttled to 3 s to limit re-renders. + * + * All related assets are expected to have an `hlPerpsMarket` entry; assets + * without one are ignored. + */ +export function useWhatsHappeningAssetPrices( + item: WhatsHappeningItem, +): UseWhatsHappeningAssetPricesResult { + const perpsSymbols = useMemo( + () => [ + ...new Set(item.relatedAssets.flatMap((a) => a.hlPerpsMarket ?? [])), + ], + [item.relatedAssets], + ); + + const livePerpsPrices = usePerpsLivePrices({ + symbols: perpsSymbols, + throttleMs: 3000, + }); + + const perpsPriceBySymbol = useMemo>(() => { + const map: Record = {}; + for (const symbol of perpsSymbols) { + const liveEntry = livePerpsPrices[symbol]; + if (!liveEntry) { + map[symbol] = { price: undefined, percentChange24h: undefined }; + continue; + } + const rawPrice = parseFloat(liveEntry.price); + const rawChange = liveEntry.percentChange24h + ? parseFloat(liveEntry.percentChange24h) + : undefined; + map[symbol] = { + price: isNaN(rawPrice) ? undefined : rawPrice, + percentChange24h: + rawChange !== undefined && isNaN(rawChange) ? undefined : rawChange, + }; + } + return map; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [perpsSymbols, livePerpsPrices]); + + return { perpsPriceBySymbol }; +} diff --git a/app/components/Views/WhatsHappeningDetailView/utils/formatAssetPrice.test.ts b/app/components/Views/WhatsHappeningDetailView/utils/formatAssetPrice.test.ts new file mode 100644 index 00000000000..a078f1f29ef --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/utils/formatAssetPrice.test.ts @@ -0,0 +1,79 @@ +import { TextColor } from '@metamask/design-system-react-native'; +import { formatPercentageChange, formatAssetPrice } from './formatAssetPrice'; + +describe('formatPercentageChange', () => { + it('returns dash text and alternative color when change is undefined', () => { + const { text, color } = formatPercentageChange(undefined); + expect(text).toBeUndefined(); + expect(color).toBe(TextColor.TextAlternative); + }); + + it('returns positive sign and success color for positive change', () => { + const { text, color } = formatPercentageChange(3.45); + expect(text).toBe('+3.45%'); + expect(color).toBe(TextColor.SuccessDefault); + }); + + it('returns negative sign and error color for negative change', () => { + const { text, color } = formatPercentageChange(-1.23); + expect(text).toBe('-1.23%'); + expect(color).toBe(TextColor.ErrorDefault); + }); + + it('returns alternative color for zero change', () => { + const { text, color } = formatPercentageChange(0); + expect(text).toBe('+0.00%'); + expect(color).toBe(TextColor.TextAlternative); + }); + + it('returns undefined text and alternative color for NaN', () => { + const { text, color } = formatPercentageChange(NaN); + expect(text).toBeUndefined(); + expect(color).toBe(TextColor.TextAlternative); + }); + + it('returns undefined text and alternative color for Infinity', () => { + const { text, color } = formatPercentageChange(Infinity); + expect(text).toBeUndefined(); + expect(color).toBe(TextColor.TextAlternative); + }); +}); + +describe('formatAssetPrice', () => { + it('returns dash when price is undefined', () => { + const result = formatAssetPrice(undefined, undefined, 'USD'); + expect(result.priceText).toBe('—'); + expect(result.changeText).toBeUndefined(); + expect(result.changeColor).toBe(TextColor.TextAlternative); + }); + + it('returns formatted price with undefined change when change is undefined', () => { + const result = formatAssetPrice(100, undefined, 'USD'); + expect(result.priceText).toBe('$100.00'); + expect(result.changeText).toBeUndefined(); + }); + + it('returns formatted price and positive change with success color', () => { + const result = formatAssetPrice(95000, 2.5, 'USD'); + expect(result.priceText).toBe('$95,000.00'); + expect(result.changeText).toBe('+2.50%'); + expect(result.changeColor).toBe(TextColor.SuccessDefault); + }); + + it('returns formatted price and negative change with error color', () => { + const result = formatAssetPrice(95000, -1.23, 'USD'); + expect(result.priceText).toBe('$95,000.00'); + expect(result.changeText).toBe('-1.23%'); + expect(result.changeColor).toBe(TextColor.ErrorDefault); + }); + + it('returns dash when price is NaN', () => { + const result = formatAssetPrice(NaN, 1, 'USD'); + expect(result.priceText).toBe('—'); + }); + + it('returns dash when price is null-like undefined', () => { + const result = formatAssetPrice(null as unknown as undefined, 0, 'USD'); + expect(result.priceText).toBe('—'); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/utils/formatAssetPrice.ts b/app/components/Views/WhatsHappeningDetailView/utils/formatAssetPrice.ts new file mode 100644 index 00000000000..65f1d9fd5eb --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/utils/formatAssetPrice.ts @@ -0,0 +1,57 @@ +import { TextColor } from '@metamask/design-system-react-native'; +import { formatPerpsFiat } from '@metamask/perps-controller'; + +export interface FormattedAssetPrice { + priceText: string; + changeText: string | undefined; + changeColor: TextColor; +} + +/** + * Returns the text and color for a 24h percentage change value. + * Positive → success green, negative → error red, zero/undefined → muted. + */ +export function formatPercentageChange(change: number | undefined): { + text: string | undefined; + color: TextColor; +} { + if (change === undefined || change === null || !Number.isFinite(change)) { + return { text: undefined, color: TextColor.TextAlternative }; + } + + const sign = change >= 0 ? '+' : ''; + const text = `${sign}${change.toFixed(2)}%`; + + let color: TextColor = TextColor.TextAlternative; + if (change > 0) { + color = TextColor.SuccessDefault; + } else if (change < 0) { + color = TextColor.ErrorDefault; + } + + return { text, color }; +} + +/** + * Builds a `FormattedAssetPrice` display object for a token or perps asset. + * `currency` is expected to be an ISO 4217 code (e.g. "USD", "EUR"). + * For perps, currency should always be "USD". + */ +export function formatAssetPrice( + price: number | undefined, + pricePercentChange: number | undefined, + currency: string | undefined, +): FormattedAssetPrice { + const priceText = + price !== undefined && price !== null && Number.isFinite(price) + ? formatPerpsFiat(price, { + currency: currency ?? 'USD', + stripTrailingZeros: false, + }) + : '—'; + + const { text: changeText, color: changeColor } = + formatPercentageChange(pricePercentChange); + + return { priceText, changeText, changeColor }; +} diff --git a/locales/languages/en.json b/locales/languages/en.json index f4e0a07d80d..b8703d769d5 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -9029,9 +9029,11 @@ }, "tokens": "Tokens", "perps": "Perps", + "related_assets": "Related Assets", "perpetuals": "Perpetuals", "predictions": "Predictions", "whats_happening": "What's happening", + "whats_happening_ai": "AI", "whats_happening_impact": { "bullish": "Bullish", "bearish": "Bearish",