From 8d55d72620e36fcc394f13bf5d2191b2f72ca3d7 Mon Sep 17 00:00:00 2001 From: Joao Santos Date: Fri, 8 May 2026 14:40:50 +0200 Subject: [PATCH 1/4] chore: UI improvements --- .../components/WhatsHappeningCard.test.tsx | 53 ++++---- .../components/WhatsHappeningCard.tsx | 114 ++++++++++-------- .../WhatsHappening/util/formatDate.ts | 29 ++--- .../Sections/WhatsHappening/util/perpsIcon.ts | 18 +++ 4 files changed, 120 insertions(+), 94 deletions(-) create mode 100644 app/components/Views/Homepage/Sections/WhatsHappening/util/perpsIcon.ts 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..d86d656e055 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx @@ -33,13 +33,14 @@ const mockRelatedAsset = { 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(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(), category: 'macro', impact: 'positive', relatedAssets: [mockRelatedAsset], @@ -58,14 +59,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 +90,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 compact relative time when date is valid', () => { renderWithProvider(); - expect(screen.getByText('Mar 15, 2026')).toBeOnTheScreen(); + expect(screen.getByText('4d')).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..3d61fbb0a4a 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx @@ -2,6 +2,8 @@ import React, { useCallback, useMemo } from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { + AvatarToken, + AvatarTokenSize, Box, Text, TextVariant, @@ -9,15 +11,16 @@ 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 { formatShortRelative } from '../util/formatDate'; import { getImpactLabel, getImpactBackgroundClass, getImpactTextColor, } from '../util/impact'; +import { getPerpsIconSource } from '../util/perpsIcon'; import { MetaMetricsEvents } from '../../../../../../core/Analytics'; import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; import { useViewportTracking } from '../../../../../UI/MarketInsights/hooks/useViewportTracking'; @@ -29,13 +32,18 @@ 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( + () => formatShortRelative(item.date), + [item.date], + ); const { trackEvent, createEventBuilder } = useAnalytics(); const handlePress = () => onPress?.(item); @@ -53,6 +61,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 index 4603a2634ae..30033661e39 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/util/formatDate.ts +++ b/app/components/Views/Homepage/Sections/WhatsHappening/util/formatDate.ts @@ -1,19 +1,20 @@ /** - * Formats an ISO date string to a short locale date (e.g. "Mar 15, 2026"). + * Formats an ISO date string as a compact relative time (e.g. "4d", "3h", "5m"). * * @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 + * @returns Compact relative string (e.g. "4d") 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; - } +export const formatShortRelative = (dateString: string): string | null => { + const date = new Date(dateString); + if (isNaN(date.getTime())) return null; + + const diffMs = Date.now() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'now'; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + return `${diffDays}d`; }; diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/util/perpsIcon.ts b/app/components/Views/Homepage/Sections/WhatsHappening/util/perpsIcon.ts new file mode 100644 index 00000000000..6ef6d681fc8 --- /dev/null +++ b/app/components/Views/Homepage/Sections/WhatsHappening/util/perpsIcon.ts @@ -0,0 +1,18 @@ +import type { RelatedAsset } from '@metamask/ai-controllers'; +import { getAssetIconUrls } from '../../../../../UI/Perps/utils/marketUtils'; +import { K_PREFIX_ASSETS } from '../../../../../UI/Perps/components/PerpsTokenLogo/PerpsAssetBgConfig'; + +/** + * Resolves the Perps icon URL for a related asset using its `hlPerpsMarket` + * (Hyperliquid market name). Returns undefined when the asset has no + * Hyperliquid mapping, in which case AvatarToken falls back to its initials. + */ +export const getPerpsIconSource = ( + asset: RelatedAsset, +): { uri: string } | undefined => { + const market = asset.hlPerpsMarket?.[0]; + if (!market) return undefined; + const urls = getAssetIconUrls(market, K_PREFIX_ASSETS); + if (!urls) return undefined; + return { uri: urls.primary }; +}; From 95b5e3fde17c2e04ca5c1dac1d59bbe52d913040 Mon Sep 17 00:00:00 2001 From: Joao Santos Date: Fri, 8 May 2026 16:00:19 +0200 Subject: [PATCH 2/4] fix: icon --- .../components/WhatsHappeningCard.test.tsx | 5 +++++ .../components/WhatsHappeningCard.tsx | 11 ++++------- .../Sections/WhatsHappening/util/perpsIcon.ts | 18 ------------------ 3 files changed, 9 insertions(+), 25 deletions(-) delete mode 100644 app/components/Views/Homepage/Sections/WhatsHappening/util/perpsIcon.ts 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 d86d656e055..9dcbde75121 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx @@ -28,6 +28,11 @@ jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({ }), })); +jest.mock( + '../../../../../UI/Perps/components/PerpsTokenLogo', + () => 'PerpsTokenLogo', +); + const mockRelatedAsset = { sourceAssetId: 'btc-mainnet', symbol: 'BTC', diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx index 3d61fbb0a4a..2721f69adff 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx @@ -2,8 +2,6 @@ import React, { useCallback, useMemo } from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { - AvatarToken, - AvatarTokenSize, Box, Text, TextVariant, @@ -20,7 +18,7 @@ import { getImpactBackgroundClass, getImpactTextColor, } from '../util/impact'; -import { getPerpsIconSource } from '../util/perpsIcon'; +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'; @@ -132,10 +130,9 @@ const WhatsHappeningCard: React.FC = ({ key={asset.sourceAssetId} twClassName={index > 0 ? '-ml-1' : ''} > - ))} diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/util/perpsIcon.ts b/app/components/Views/Homepage/Sections/WhatsHappening/util/perpsIcon.ts deleted file mode 100644 index 6ef6d681fc8..00000000000 --- a/app/components/Views/Homepage/Sections/WhatsHappening/util/perpsIcon.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { RelatedAsset } from '@metamask/ai-controllers'; -import { getAssetIconUrls } from '../../../../../UI/Perps/utils/marketUtils'; -import { K_PREFIX_ASSETS } from '../../../../../UI/Perps/components/PerpsTokenLogo/PerpsAssetBgConfig'; - -/** - * Resolves the Perps icon URL for a related asset using its `hlPerpsMarket` - * (Hyperliquid market name). Returns undefined when the asset has no - * Hyperliquid mapping, in which case AvatarToken falls back to its initials. - */ -export const getPerpsIconSource = ( - asset: RelatedAsset, -): { uri: string } | undefined => { - const market = asset.hlPerpsMarket?.[0]; - if (!market) return undefined; - const urls = getAssetIconUrls(market, K_PREFIX_ASSETS); - if (!urls) return undefined; - return { uri: urls.primary }; -}; From 95dad60aa2745c0ff4093fc520f94079cb213a12 Mon Sep 17 00:00:00 2001 From: Joao Santos Date: Fri, 8 May 2026 16:16:05 +0200 Subject: [PATCH 3/4] chore: reuse date utility --- .../components/WhatsHappeningCard.test.tsx | 6 +++--- .../components/WhatsHappeningCard.tsx | 5 +++-- .../WhatsHappening/util/formatDate.ts | 20 ------------------- 3 files changed, 6 insertions(+), 25 deletions(-) delete mode 100644 app/components/Views/Homepage/Sections/WhatsHappening/util/formatDate.ts 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 9dcbde75121..5bd62f66575 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx @@ -45,7 +45,7 @@ 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: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(), + date: new Date(new Date().getTime() - 4 * 24 * 60 * 60 * 1000).toISOString(), category: 'macro', impact: 'positive', relatedAssets: [mockRelatedAsset], @@ -129,9 +129,9 @@ describe('WhatsHappeningCard', () => { expect(screen.queryByText('BTC')).toBeNull(); }); - it('renders compact relative time when date is valid', () => { + it('renders relative time when date is valid', () => { renderWithProvider(); - expect(screen.getByText('4d')).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 2721f69adff..eca9334ec2c 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx @@ -12,7 +12,6 @@ import { BoxJustifyContent, } from '@metamask/design-system-react-native'; import type { WhatsHappeningItem } from '../types'; -import { formatShortRelative } from '../util/formatDate'; import { getImpactLabel, getImpactBackgroundClass, @@ -22,6 +21,7 @@ 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 { @@ -39,7 +39,8 @@ const WhatsHappeningCard: React.FC = ({ }) => { const tw = useTailwind(); const formattedDate = useMemo( - () => formatShortRelative(item.date), + () => + item.date ? formatRelativeTime(item.date, { nowLabel: 'now' }) : null, [item.date], ); const { trackEvent, createEventBuilder } = useAnalytics(); 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 30033661e39..00000000000 --- a/app/components/Views/Homepage/Sections/WhatsHappening/util/formatDate.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Formats an ISO date string as a compact relative time (e.g. "4d", "3h", "5m"). - * - * @param dateString - ISO 8601 date string (e.g. "2026-03-15T10:00:00.000Z") - * @returns Compact relative string (e.g. "4d") or null if invalid - */ -export const formatShortRelative = (dateString: string): string | null => { - const date = new Date(dateString); - if (isNaN(date.getTime())) return null; - - const diffMs = Date.now() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffMins < 1) return 'now'; - if (diffMins < 60) return `${diffMins}m`; - if (diffHours < 24) return `${diffHours}h`; - return `${diffDays}d`; -}; From 0aa690acb50b1162d361dc8d597da3d5f9f7718f Mon Sep 17 00:00:00 2001 From: Joao Santos Date: Fri, 8 May 2026 16:25:55 +0200 Subject: [PATCH 4/4] chore: fix date bug display --- .../utils/marketInsightsFormatting.test.ts | 43 ++++++++++++++++++- .../utils/marketInsightsFormatting.ts | 1 + 2 files changed, 43 insertions(+), 1 deletion(-) 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);