diff --git a/app/components/UI/MarketInsights/utils/marketInsightsFormatting.test.ts b/app/components/UI/MarketInsights/utils/marketInsightsFormatting.test.ts index b966d8f3e76..798591c6360 100644 --- a/app/components/UI/MarketInsights/utils/marketInsightsFormatting.test.ts +++ b/app/components/UI/MarketInsights/utils/marketInsightsFormatting.test.ts @@ -1,4 +1,45 @@ -import { getFaviconUrl, isXSourceUrl } from './marketInsightsFormatting'; +import { + formatRelativeTime, + getFaviconUrl, + isXSourceUrl, +} from './marketInsightsFormatting'; + +describe('formatRelativeTime', () => { + const ANCHOR = new Date('2026-05-07T12:00:00.000Z'); + + const minutesBeforeAnchor = (n: number) => + new Date(ANCHOR.getTime() - n * 60 * 1000).toISOString(); + + beforeEach(() => { + jest.useFakeTimers({ now: ANCHOR }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns empty string for an invalid date string', () => { + expect(formatRelativeTime('not-a-date')).toBe(''); + }); + + it('returns nowLabel when diff is under 1 minute', () => { + expect( + formatRelativeTime(minutesBeforeAnchor(0), { nowLabel: 'now' }), + ).toBe('now'); + }); + + it('returns Xm ago for diffs under 1 hour', () => { + expect(formatRelativeTime(minutesBeforeAnchor(5))).toBe('5m ago'); + }); + + it('returns Xh ago for diffs under 1 day', () => { + expect(formatRelativeTime(minutesBeforeAnchor(3 * 60))).toBe('3h ago'); + }); + + it('returns Xd ago for diffs of 1 day or more', () => { + expect(formatRelativeTime(minutesBeforeAnchor(4 * 24 * 60))).toBe('4d ago'); + }); +}); describe('getFaviconUrl', () => { it('uses hostname when source is a full URL', () => { diff --git a/app/components/UI/MarketInsights/utils/marketInsightsFormatting.ts b/app/components/UI/MarketInsights/utils/marketInsightsFormatting.ts index 08df00e09db..7944a96b60e 100644 --- a/app/components/UI/MarketInsights/utils/marketInsightsFormatting.ts +++ b/app/components/UI/MarketInsights/utils/marketInsightsFormatting.ts @@ -30,6 +30,7 @@ export const formatRelativeTime = ( const { nowLabel = 'just now' } = options; const now = new Date(); const date = new Date(dateString); + if (isNaN(date.getTime())) return ''; const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMins / 60); diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx index ab46f9cd626..5bd62f66575 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx @@ -28,18 +28,24 @@ jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({ }), })); +jest.mock( + '../../../../../UI/Perps/components/PerpsTokenLogo', + () => 'PerpsTokenLogo', +); + const mockRelatedAsset = { sourceAssetId: 'btc-mainnet', symbol: 'BTC', name: 'Bitcoin', caip19: ['eip155:1/slip44:0'], + hlPerpsMarket: ['BTC'], }; const baseItem: WhatsHappeningItem = { id: 'trend-0', title: 'Bitcoin ETF inflows hit record high', description: 'Spot Bitcoin ETFs recorded over $1.2B in net inflows.', - date: '2026-03-15T10:00:00.000Z', + date: new Date(new Date().getTime() - 4 * 24 * 60 * 60 * 1000).toISOString(), category: 'macro', impact: 'positive', relatedAssets: [mockRelatedAsset], @@ -58,14 +64,8 @@ describe('WhatsHappeningCard', () => { expect(screen.getByText(baseItem.description)).toBeOnTheScreen(); }); - it('renders category badge when category is provided', () => { + it('does not render category badge', () => { renderWithProvider(); - expect(screen.getByText('Macro')).toBeOnTheScreen(); - }); - - it('does not render category badge when category is absent', () => { - const item = { ...baseItem, category: undefined }; - renderWithProvider(); expect(screen.queryByText('Macro')).toBeNull(); }); @@ -95,39 +95,43 @@ describe('WhatsHappeningCard', () => { expect(screen.queryByText('Neutral')).toBeNull(); }); - it('renders impact badge alongside category badge', () => { - renderWithProvider(); - expect(screen.getByText('Bullish')).toBeOnTheScreen(); - expect(screen.getByText('Macro')).toBeOnTheScreen(); - }); - - it('renders related asset symbol pills', () => { + it('renders the asset symbol label when there is a single related asset', () => { renderWithProvider(); expect(screen.getByText('BTC')).toBeOnTheScreen(); }); - it('does not render asset pills when relatedAssets is empty', () => { - const item = { ...baseItem, relatedAssets: [] }; - renderWithProvider(); - expect(screen.queryByText('BTC')).toBeNull(); - }); - - it('renders multiple related asset symbols', () => { + it('renders " +N" label when there are multiple related assets', () => { const ethAsset = { sourceAssetId: 'eth-mainnet', symbol: 'ETH', name: 'Ethereum', caip19: ['eip155:1/slip44:60'], + hlPerpsMarket: ['ETH'], + }; + const solAsset = { + sourceAssetId: 'sol-mainnet', + symbol: 'SOL', + name: 'Solana', + caip19: ['solana:mainnet/slip44:501'], + hlPerpsMarket: ['SOL'], + }; + const item = { + ...baseItem, + relatedAssets: [mockRelatedAsset, ethAsset, solAsset], }; - const item = { ...baseItem, relatedAssets: [mockRelatedAsset, ethAsset] }; renderWithProvider(); - expect(screen.getByText('BTC')).toBeOnTheScreen(); - expect(screen.getByText('ETH')).toBeOnTheScreen(); + expect(screen.getByText('BTC +2')).toBeOnTheScreen(); + }); + + it('does not render asset label when relatedAssets is empty', () => { + const item = { ...baseItem, relatedAssets: [] }; + renderWithProvider(); + expect(screen.queryByText('BTC')).toBeNull(); }); - it('renders formatted date when date is valid', () => { + it('renders relative time when date is valid', () => { renderWithProvider(); - expect(screen.getByText('Mar 15, 2026')).toBeOnTheScreen(); + expect(screen.getByText('4d ago')).toBeOnTheScreen(); }); it('does not render date when date string is invalid', () => { diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx index e1c7065fa69..eca9334ec2c 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx @@ -9,18 +9,19 @@ import { FontWeight, BoxFlexDirection, BoxAlignItems, + BoxJustifyContent, } from '@metamask/design-system-react-native'; -import { strings } from '../../../../../../../locales/i18n'; import type { WhatsHappeningItem } from '../types'; -import { formatShortDate } from '../util/formatDate'; import { getImpactLabel, getImpactBackgroundClass, getImpactTextColor, } from '../util/impact'; +import PerpsTokenLogo from '../../../../../UI/Perps/components/PerpsTokenLogo'; import { MetaMetricsEvents } from '../../../../../../core/Analytics'; import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; import { useViewportTracking } from '../../../../../UI/MarketInsights/hooks/useViewportTracking'; +import { formatRelativeTime } from '../../../../../UI/MarketInsights/utils/marketInsightsFormatting'; import { getWhatsHappeningEventProps } from '../eventProperties'; interface WhatsHappeningCardProps { @@ -29,13 +30,19 @@ interface WhatsHappeningCardProps { onPress?: (item: WhatsHappeningItem) => void; } +const MAX_VISIBLE_ASSET_ICONS = 3; + const WhatsHappeningCard: React.FC = ({ item, cardIndex, onPress, }) => { const tw = useTailwind(); - const formattedDate = useMemo(() => formatShortDate(item.date), [item.date]); + const formattedDate = useMemo( + () => + item.date ? formatRelativeTime(item.date, { nowLabel: 'now' }) : null, + [item.date], + ); const { trackEvent, createEventBuilder } = useAnalytics(); const handlePress = () => onPress?.(item); @@ -53,6 +60,15 @@ const WhatsHappeningCard: React.FC = ({ const { ref: cardRef, onLayout: onVisibilityLayout } = useViewportTracking(handleVisible); + const visibleAssets = item.relatedAssets.slice(0, MAX_VISIBLE_ASSET_ICONS); + const firstAsset = item.relatedAssets[0]; + const remainingAssetCount = Math.max(0, item.relatedAssets.length - 1); + const assetLabel = firstAsset + ? remainingAssetCount > 0 + ? `${firstAsset.symbol} +${remainingAssetCount}` + : firstAsset.symbol + : null; + return ( = ({ )} > - {/* Impact + Category badges */} - {(item.impact || item.category) && ( + {item.impact && ( - {item.impact && ( - - - {getImpactLabel(item.impact)} - - - )} - {item.category && ( - - - {strings( - `homepage.sections.whats_happening_categories.${item.category}`, - )} - - - )} + + {getImpactLabel(item.impact)} + )} - {/* Title */} = ({ {item.title} - {/* Description */} = ({ - {/* Footer: asset pills + date */} - - {item.relatedAssets.length > 0 && ( + + {assetLabel && ( - {item.relatedAssets.map((asset) => ( - - - {asset.symbol} - + {visibleAssets.length > 0 && ( + + {visibleAssets.map((asset, index) => ( + 0 ? '-ml-1' : ''} + > + + + ))} - ))} + )} + + {assetLabel} + )} diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/util/formatDate.ts b/app/components/Views/Homepage/Sections/WhatsHappening/util/formatDate.ts deleted file mode 100644 index 4603a2634ae..00000000000 --- a/app/components/Views/Homepage/Sections/WhatsHappening/util/formatDate.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Formats an ISO date string to a short locale date (e.g. "Mar 15, 2026"). - * - * @param dateString - ISO 8601 date string (e.g. "2026-03-15T10:00:00.000Z") - * @returns Formatted string (e.g. "Mar 15, 2026") or null if invalid - */ -export const formatShortDate = (dateString: string): string | null => { - try { - const date = new Date(dateString); - if (isNaN(date.getTime())) return null; - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); - } catch { - return null; - } -};