Skip to content

Commit a62d62e

Browse files
authored
chore: TSA-501 whats happening UI fixes (#29920)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** Small UI improvements to the What's Happening cards. <img width="472" height="470" alt="image" src="https://github.com/user-attachments/assets/1bea3396-6055-4ad6-b6f2-d8ef38e8ef89" /> <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: no-changelog ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk UI-only changes to card presentation and date formatting; main risk is minor regressions in layout or relative-time output for edge-case dates. > > **Overview** > Updates the Homepage **What’s Happening** cards to simplify badges and improve the footer: the category badge is removed, impact badge styling is adjusted, and related assets are shown as up to 3 overlapping token icons plus a compact label (e.g., `BTC +2`) instead of multiple symbol pills. > > Switches the card timestamp from a locale date to a **relative time** string via `formatRelativeTime` (with `now` support) and removes the unused `formatShortDate` helper. `formatRelativeTime` now returns an empty string for invalid dates, with new unit tests covering invalid inputs and minute/hour/day formatting. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 0aa690a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 455d908 commit a62d62e

5 files changed

Lines changed: 133 additions & 100 deletions

File tree

app/components/UI/MarketInsights/utils/marketInsightsFormatting.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,45 @@
1-
import { getFaviconUrl, isXSourceUrl } from './marketInsightsFormatting';
1+
import {
2+
formatRelativeTime,
3+
getFaviconUrl,
4+
isXSourceUrl,
5+
} from './marketInsightsFormatting';
6+
7+
describe('formatRelativeTime', () => {
8+
const ANCHOR = new Date('2026-05-07T12:00:00.000Z');
9+
10+
const minutesBeforeAnchor = (n: number) =>
11+
new Date(ANCHOR.getTime() - n * 60 * 1000).toISOString();
12+
13+
beforeEach(() => {
14+
jest.useFakeTimers({ now: ANCHOR });
15+
});
16+
17+
afterEach(() => {
18+
jest.useRealTimers();
19+
});
20+
21+
it('returns empty string for an invalid date string', () => {
22+
expect(formatRelativeTime('not-a-date')).toBe('');
23+
});
24+
25+
it('returns nowLabel when diff is under 1 minute', () => {
26+
expect(
27+
formatRelativeTime(minutesBeforeAnchor(0), { nowLabel: 'now' }),
28+
).toBe('now');
29+
});
30+
31+
it('returns Xm ago for diffs under 1 hour', () => {
32+
expect(formatRelativeTime(minutesBeforeAnchor(5))).toBe('5m ago');
33+
});
34+
35+
it('returns Xh ago for diffs under 1 day', () => {
36+
expect(formatRelativeTime(minutesBeforeAnchor(3 * 60))).toBe('3h ago');
37+
});
38+
39+
it('returns Xd ago for diffs of 1 day or more', () => {
40+
expect(formatRelativeTime(minutesBeforeAnchor(4 * 24 * 60))).toBe('4d ago');
41+
});
42+
});
243

344
describe('getFaviconUrl', () => {
445
it('uses hostname when source is a full URL', () => {

app/components/UI/MarketInsights/utils/marketInsightsFormatting.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const formatRelativeTime = (
3030
const { nowLabel = 'just now' } = options;
3131
const now = new Date();
3232
const date = new Date(dateString);
33+
if (isNaN(date.getTime())) return '';
3334
const diffMs = now.getTime() - date.getTime();
3435
const diffMins = Math.floor(diffMs / 60000);
3536
const diffHours = Math.floor(diffMins / 60);

app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,24 @@ jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({
2828
}),
2929
}));
3030

31+
jest.mock(
32+
'../../../../../UI/Perps/components/PerpsTokenLogo',
33+
() => 'PerpsTokenLogo',
34+
);
35+
3136
const mockRelatedAsset = {
3237
sourceAssetId: 'btc-mainnet',
3338
symbol: 'BTC',
3439
name: 'Bitcoin',
3540
caip19: ['eip155:1/slip44:0'],
41+
hlPerpsMarket: ['BTC'],
3642
};
3743

3844
const baseItem: WhatsHappeningItem = {
3945
id: 'trend-0',
4046
title: 'Bitcoin ETF inflows hit record high',
4147
description: 'Spot Bitcoin ETFs recorded over $1.2B in net inflows.',
42-
date: '2026-03-15T10:00:00.000Z',
48+
date: new Date(new Date().getTime() - 4 * 24 * 60 * 60 * 1000).toISOString(),
4349
category: 'macro',
4450
impact: 'positive',
4551
relatedAssets: [mockRelatedAsset],
@@ -58,14 +64,8 @@ describe('WhatsHappeningCard', () => {
5864
expect(screen.getByText(baseItem.description)).toBeOnTheScreen();
5965
});
6066

61-
it('renders category badge when category is provided', () => {
67+
it('does not render category badge', () => {
6268
renderWithProvider(<WhatsHappeningCard item={baseItem} cardIndex={0} />);
63-
expect(screen.getByText('Macro')).toBeOnTheScreen();
64-
});
65-
66-
it('does not render category badge when category is absent', () => {
67-
const item = { ...baseItem, category: undefined };
68-
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
6969
expect(screen.queryByText('Macro')).toBeNull();
7070
});
7171

@@ -95,39 +95,43 @@ describe('WhatsHappeningCard', () => {
9595
expect(screen.queryByText('Neutral')).toBeNull();
9696
});
9797

98-
it('renders impact badge alongside category badge', () => {
99-
renderWithProvider(<WhatsHappeningCard item={baseItem} cardIndex={0} />);
100-
expect(screen.getByText('Bullish')).toBeOnTheScreen();
101-
expect(screen.getByText('Macro')).toBeOnTheScreen();
102-
});
103-
104-
it('renders related asset symbol pills', () => {
98+
it('renders the asset symbol label when there is a single related asset', () => {
10599
renderWithProvider(<WhatsHappeningCard item={baseItem} cardIndex={0} />);
106100
expect(screen.getByText('BTC')).toBeOnTheScreen();
107101
});
108102

109-
it('does not render asset pills when relatedAssets is empty', () => {
110-
const item = { ...baseItem, relatedAssets: [] };
111-
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
112-
expect(screen.queryByText('BTC')).toBeNull();
113-
});
114-
115-
it('renders multiple related asset symbols', () => {
103+
it('renders "<symbol> +N" label when there are multiple related assets', () => {
116104
const ethAsset = {
117105
sourceAssetId: 'eth-mainnet',
118106
symbol: 'ETH',
119107
name: 'Ethereum',
120108
caip19: ['eip155:1/slip44:60'],
109+
hlPerpsMarket: ['ETH'],
110+
};
111+
const solAsset = {
112+
sourceAssetId: 'sol-mainnet',
113+
symbol: 'SOL',
114+
name: 'Solana',
115+
caip19: ['solana:mainnet/slip44:501'],
116+
hlPerpsMarket: ['SOL'],
117+
};
118+
const item = {
119+
...baseItem,
120+
relatedAssets: [mockRelatedAsset, ethAsset, solAsset],
121121
};
122-
const item = { ...baseItem, relatedAssets: [mockRelatedAsset, ethAsset] };
123122
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
124-
expect(screen.getByText('BTC')).toBeOnTheScreen();
125-
expect(screen.getByText('ETH')).toBeOnTheScreen();
123+
expect(screen.getByText('BTC +2')).toBeOnTheScreen();
124+
});
125+
126+
it('does not render asset label when relatedAssets is empty', () => {
127+
const item = { ...baseItem, relatedAssets: [] };
128+
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
129+
expect(screen.queryByText('BTC')).toBeNull();
126130
});
127131

128-
it('renders formatted date when date is valid', () => {
132+
it('renders relative time when date is valid', () => {
129133
renderWithProvider(<WhatsHappeningCard item={baseItem} cardIndex={0} />);
130-
expect(screen.getByText('Mar 15, 2026')).toBeOnTheScreen();
134+
expect(screen.getByText('4d ago')).toBeOnTheScreen();
131135
});
132136

133137
it('does not render date when date string is invalid', () => {

app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx

Lines changed: 59 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,19 @@ import {
99
FontWeight,
1010
BoxFlexDirection,
1111
BoxAlignItems,
12+
BoxJustifyContent,
1213
} from '@metamask/design-system-react-native';
13-
import { strings } from '../../../../../../../locales/i18n';
1414
import type { WhatsHappeningItem } from '../types';
15-
import { formatShortDate } from '../util/formatDate';
1615
import {
1716
getImpactLabel,
1817
getImpactBackgroundClass,
1918
getImpactTextColor,
2019
} from '../util/impact';
20+
import PerpsTokenLogo from '../../../../../UI/Perps/components/PerpsTokenLogo';
2121
import { MetaMetricsEvents } from '../../../../../../core/Analytics';
2222
import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
2323
import { useViewportTracking } from '../../../../../UI/MarketInsights/hooks/useViewportTracking';
24+
import { formatRelativeTime } from '../../../../../UI/MarketInsights/utils/marketInsightsFormatting';
2425
import { getWhatsHappeningEventProps } from '../eventProperties';
2526

2627
interface WhatsHappeningCardProps {
@@ -29,13 +30,19 @@ interface WhatsHappeningCardProps {
2930
onPress?: (item: WhatsHappeningItem) => void;
3031
}
3132

33+
const MAX_VISIBLE_ASSET_ICONS = 3;
34+
3235
const WhatsHappeningCard: React.FC<WhatsHappeningCardProps> = ({
3336
item,
3437
cardIndex,
3538
onPress,
3639
}) => {
3740
const tw = useTailwind();
38-
const formattedDate = useMemo(() => formatShortDate(item.date), [item.date]);
41+
const formattedDate = useMemo(
42+
() =>
43+
item.date ? formatRelativeTime(item.date, { nowLabel: 'now' }) : null,
44+
[item.date],
45+
);
3946
const { trackEvent, createEventBuilder } = useAnalytics();
4047

4148
const handlePress = () => onPress?.(item);
@@ -53,6 +60,15 @@ const WhatsHappeningCard: React.FC<WhatsHappeningCardProps> = ({
5360
const { ref: cardRef, onLayout: onVisibilityLayout } =
5461
useViewportTracking(handleVisible);
5562

63+
const visibleAssets = item.relatedAssets.slice(0, MAX_VISIBLE_ASSET_ICONS);
64+
const firstAsset = item.relatedAssets[0];
65+
const remainingAssetCount = Math.max(0, item.relatedAssets.length - 1);
66+
const assetLabel = firstAsset
67+
? remainingAssetCount > 0
68+
? `${firstAsset.symbol} +${remainingAssetCount}`
69+
: firstAsset.symbol
70+
: null;
71+
5672
return (
5773
<View ref={cardRef} collapsable={false} onLayout={onVisibilityLayout}>
5874
<TouchableOpacity
@@ -63,43 +79,20 @@ const WhatsHappeningCard: React.FC<WhatsHappeningCardProps> = ({
6379
)}
6480
>
6581
<Box gap={3}>
66-
{/* Impact + Category badges */}
67-
{(item.impact || item.category) && (
82+
{item.impact && (
6883
<Box
69-
flexDirection={BoxFlexDirection.Row}
70-
alignItems={BoxAlignItems.Center}
71-
twClassName="flex-wrap gap-2"
84+
twClassName={`self-start rounded ${getImpactBackgroundClass(item.impact)} px-2 py-0.5`}
7285
>
73-
{item.impact && (
74-
<Box
75-
twClassName={`self-start rounded-full ${getImpactBackgroundClass(item.impact)} px-2 py-0.5`}
76-
>
77-
<Text
78-
variant={TextVariant.BodyXs}
79-
color={getImpactTextColor(item.impact)}
80-
fontWeight={FontWeight.Medium}
81-
>
82-
{getImpactLabel(item.impact)}
83-
</Text>
84-
</Box>
85-
)}
86-
{item.category && (
87-
<Box twClassName="self-start rounded-full bg-background-default px-2 py-0.5">
88-
<Text
89-
variant={TextVariant.BodyXs}
90-
color={TextColor.TextAlternative}
91-
fontWeight={FontWeight.Medium}
92-
>
93-
{strings(
94-
`homepage.sections.whats_happening_categories.${item.category}`,
95-
)}
96-
</Text>
97-
</Box>
98-
)}
86+
<Text
87+
variant={TextVariant.BodyXs}
88+
color={getImpactTextColor(item.impact)}
89+
fontWeight={FontWeight.Medium}
90+
>
91+
{getImpactLabel(item.impact)}
92+
</Text>
9993
</Box>
10094
)}
10195

102-
{/* Title */}
10396
<Text
10497
variant={TextVariant.BodyMd}
10598
fontWeight={FontWeight.Medium}
@@ -109,7 +102,6 @@ const WhatsHappeningCard: React.FC<WhatsHappeningCardProps> = ({
109102
{item.title}
110103
</Text>
111104

112-
{/* Description */}
113105
<Text
114106
variant={TextVariant.BodySm}
115107
color={TextColor.TextAlternative}
@@ -119,28 +111,42 @@ const WhatsHappeningCard: React.FC<WhatsHappeningCardProps> = ({
119111
</Text>
120112
</Box>
121113

122-
{/* Footer: asset pills + date */}
123-
<Box gap={2}>
124-
{item.relatedAssets.length > 0 && (
114+
<Box
115+
flexDirection={BoxFlexDirection.Row}
116+
alignItems={BoxAlignItems.Center}
117+
justifyContent={BoxJustifyContent.Between}
118+
gap={2}
119+
>
120+
{assetLabel && (
125121
<Box
126122
flexDirection={BoxFlexDirection.Row}
127123
alignItems={BoxAlignItems.Center}
128-
twClassName="flex-wrap gap-1"
124+
gap={1}
125+
twClassName="flex-shrink"
129126
>
130-
{item.relatedAssets.map((asset) => (
131-
<Box
132-
key={asset.sourceAssetId}
133-
twClassName="rounded-full bg-background-default px-2 py-0.5"
134-
>
135-
<Text
136-
variant={TextVariant.BodyXs}
137-
color={TextColor.TextDefault}
138-
fontWeight={FontWeight.Medium}
139-
>
140-
{asset.symbol}
141-
</Text>
127+
{visibleAssets.length > 0 && (
128+
<Box flexDirection={BoxFlexDirection.Row}>
129+
{visibleAssets.map((asset, index) => (
130+
<Box
131+
key={asset.sourceAssetId}
132+
twClassName={index > 0 ? '-ml-1' : ''}
133+
>
134+
<PerpsTokenLogo
135+
symbol={asset.hlPerpsMarket?.[0] ?? asset.symbol}
136+
size={16}
137+
/>
138+
</Box>
139+
))}
142140
</Box>
143-
))}
141+
)}
142+
<Text
143+
variant={TextVariant.BodyXs}
144+
color={TextColor.TextAlternative}
145+
fontWeight={FontWeight.Medium}
146+
numberOfLines={1}
147+
>
148+
{assetLabel}
149+
</Text>
144150
</Box>
145151
)}
146152

app/components/Views/Homepage/Sections/WhatsHappening/util/formatDate.ts

Lines changed: 0 additions & 19 deletions
This file was deleted.

0 commit comments

Comments
 (0)