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",