Skip to content

Commit b2b891c

Browse files
authored
chore: UI improvements in expanded card (#29918)
<!-- 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** The expanded “What’s happening” cards now treat every related asset as a perps market: a single Markets section with Trade only, live prices from the perps stream, verified badge when caip19 + flags allow, and TokenRow removed. <img height="800" alt="Simulator Screenshot - iPhone 17 Pro - 2026-05-08 at 14 53 15" src="https://github.com/user-attachments/assets/d21e3992-e9ee-4fa4-8fb9-576555c80fe9" /> ## **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: null ## **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. --> - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] 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] > **Medium Risk** > Medium risk because it changes expanded card behavior and starts a perps WebSocket price subscription in `WhatsHappeningDetailView`, which can affect performance/render frequency and trade CTA visibility. > > **Overview** > **Updates the expanded “What’s happening” detail cards to treat all `relatedAssets` as perps markets.** The card now shows a single **`Related Assets`** section with `Trade`-only rows (no `Buy` flow) and hides the CTA when an asset has no `hlPerpsMarket`. > > Adds live perps price + 24h % change display per asset via a new `useWhatsHappeningAssetPrices` hook (deduped symbols, 3s throttling) and wraps the detail view in `PerpsStreamProvider` to enable the stream. UI polish includes an **AI** pill next to the impact badge, a bottom fade gradient hint for scrollable content, and a pill-shaped active dot in `PageIndicator`. > > Cleans up by removing `TokenRow` and adding unit tests for the new/updated components and formatting utilities, plus new i18n strings (`related_assets`, `whats_happening_ai`). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 36e1821. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent dd9d99f commit b2b891c

16 files changed

Lines changed: 904 additions & 544 deletions

app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx

Lines changed: 55 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import ErrorState from '../Homepage/components/ErrorState/ErrorState';
3131
import WhatsHappeningExpandedCard from './components/WhatsHappeningExpandedCard';
3232
import WhatsHappeningSourcesBottomSheet from './components/WhatsHappeningSourcesBottomSheet';
3333
import PageIndicator from './components/PageIndicator';
34+
import { PerpsStreamProvider } from '../../UI/Perps/providers/PerpsStreamManager';
3435
import { MetaMetricsEvents } from '../../../core/Analytics';
3536
import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics';
3637

@@ -210,64 +211,66 @@ const WhatsHappeningDetailView = () => {
210211
<Box twClassName="w-10" />
211212
</Box>
212213

213-
<Box twClassName="flex-1">
214-
{isLoading ? (
215-
<ScrollView
216-
horizontal
217-
showsHorizontalScrollIndicator={false}
218-
contentContainerStyle={tw.style('px-4 gap-3 items-stretch')}
219-
testID="whats-happening-detail-skeleton"
220-
>
221-
{SKELETON_KEYS.map((key) => (
222-
<WhatsHappeningCardSkeleton key={key} />
223-
))}
224-
</ScrollView>
225-
) : hasError ? (
226-
<ErrorState
227-
title={strings('homepage.error.unable_to_load', {
228-
section: strings(
229-
'homepage.sections.whats_happening',
230-
).toLowerCase(),
231-
})}
232-
onRetry={refresh}
233-
/>
234-
) : (
235-
<>
214+
<PerpsStreamProvider>
215+
<Box twClassName="flex-1">
216+
{isLoading ? (
236217
<ScrollView
237-
ref={scrollViewRef}
238218
horizontal
239219
showsHorizontalScrollIndicator={false}
240-
decelerationRate="fast"
241-
snapToInterval={SNAP_INTERVAL}
242-
snapToAlignment="start"
243-
style={tw`flex-1`}
244-
contentContainerStyle={tw.style('px-4 gap-3')}
245-
onLayout={handleCarouselLayout}
246-
onContentSizeChange={handleContentSizeChange}
247-
onScroll={handleScroll}
248-
scrollEventThrottle={16}
249-
onMomentumScrollEnd={handleScrollEnd}
250-
testID="whats-happening-detail-carousel"
220+
contentContainerStyle={tw.style('px-4 gap-3 items-stretch')}
221+
testID="whats-happening-detail-skeleton"
251222
>
252-
{cardHeight > 0 &&
253-
items.map((item, index) => (
254-
<WhatsHappeningExpandedCard
255-
key={item.id}
256-
item={item}
257-
cardIndex={index}
258-
cardWidth={CARD_WIDTH}
259-
cardHeight={cardHeight}
260-
onSourcesPress={(articles) =>
261-
handleSourcesPress(articles, item, index)
262-
}
263-
/>
264-
))}
223+
{SKELETON_KEYS.map((key) => (
224+
<WhatsHappeningCardSkeleton key={key} />
225+
))}
265226
</ScrollView>
227+
) : hasError ? (
228+
<ErrorState
229+
title={strings('homepage.error.unable_to_load', {
230+
section: strings(
231+
'homepage.sections.whats_happening',
232+
).toLowerCase(),
233+
})}
234+
onRetry={refresh}
235+
/>
236+
) : (
237+
<>
238+
<ScrollView
239+
ref={scrollViewRef}
240+
horizontal
241+
showsHorizontalScrollIndicator={false}
242+
decelerationRate="fast"
243+
snapToInterval={SNAP_INTERVAL}
244+
snapToAlignment="start"
245+
style={tw`flex-1`}
246+
contentContainerStyle={tw.style('px-4 gap-3')}
247+
onLayout={handleCarouselLayout}
248+
onContentSizeChange={handleContentSizeChange}
249+
onScroll={handleScroll}
250+
scrollEventThrottle={16}
251+
onMomentumScrollEnd={handleScrollEnd}
252+
testID="whats-happening-detail-carousel"
253+
>
254+
{cardHeight > 0 &&
255+
items.map((item, index) => (
256+
<WhatsHappeningExpandedCard
257+
key={item.id}
258+
item={item}
259+
cardIndex={index}
260+
cardWidth={CARD_WIDTH}
261+
cardHeight={cardHeight}
262+
onSourcesPress={(articles) =>
263+
handleSourcesPress(articles, item, index)
264+
}
265+
/>
266+
))}
267+
</ScrollView>
266268

267-
<PageIndicator count={items.length} activeIndex={currentIndex} />
268-
</>
269-
)}
270-
</Box>
269+
<PageIndicator count={items.length} activeIndex={currentIndex} />
270+
</>
271+
)}
272+
</Box>
273+
</PerpsStreamProvider>
271274
{sourcesContext && (
272275
<WhatsHappeningSourcesBottomSheet
273276
onClose={handleSourcesClose}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import React from 'react';
2+
import { screen, fireEvent } from '@testing-library/react-native';
3+
import { TextColor } from '@metamask/design-system-react-native';
4+
import type { RelatedAsset } from '@metamask/ai-controllers';
5+
import renderWithProvider from '../../../../util/test/renderWithProvider';
6+
import AssetRow from './AssetRow';
7+
8+
jest.mock('../utils/getRelatedAssetImageSource', () => ({
9+
getRelatedAssetImageSource: jest.fn(() => undefined),
10+
}));
11+
12+
const btcAsset: RelatedAsset = {
13+
sourceAssetId: 'bitcoin',
14+
symbol: 'BTC',
15+
name: 'Bitcoin',
16+
caip19: ['eip155:1/slip44:0'],
17+
};
18+
19+
const symbolOnlyAsset: RelatedAsset = {
20+
sourceAssetId: 'unknown',
21+
symbol: 'UNK',
22+
name: '',
23+
caip19: [],
24+
};
25+
26+
describe('AssetRow', () => {
27+
const onAction = jest.fn();
28+
29+
beforeEach(() => {
30+
jest.clearAllMocks();
31+
});
32+
33+
it('displays asset.name when name is present', () => {
34+
renderWithProvider(
35+
<AssetRow
36+
asset={btcAsset}
37+
actionLabel="Buy"
38+
accessibilityLabel="Buy BTC"
39+
onAction={onAction}
40+
/>,
41+
);
42+
expect(screen.getByText('Bitcoin')).toBeOnTheScreen();
43+
});
44+
45+
it('falls back to asset.symbol when name is empty', () => {
46+
renderWithProvider(
47+
<AssetRow
48+
asset={symbolOnlyAsset}
49+
actionLabel="Buy"
50+
accessibilityLabel="Buy UNK"
51+
onAction={onAction}
52+
/>,
53+
);
54+
expect(screen.getByText('UNK')).toBeOnTheScreen();
55+
});
56+
57+
it('does not render an action button when onAction is omitted', () => {
58+
renderWithProvider(<AssetRow asset={btcAsset} />);
59+
expect(screen.queryByText('Buy')).toBeNull();
60+
expect(screen.queryByText('Trade')).toBeNull();
61+
});
62+
63+
it('renders the action button with the provided label', () => {
64+
renderWithProvider(
65+
<AssetRow
66+
asset={btcAsset}
67+
actionLabel="Trade"
68+
accessibilityLabel="Trade BTC"
69+
onAction={onAction}
70+
/>,
71+
);
72+
expect(screen.getByText('Trade')).toBeOnTheScreen();
73+
});
74+
75+
it('calls onAction when the button is pressed', () => {
76+
renderWithProvider(
77+
<AssetRow
78+
asset={btcAsset}
79+
actionLabel="Buy"
80+
accessibilityLabel="Buy BTC"
81+
onAction={onAction}
82+
/>,
83+
);
84+
fireEvent.press(screen.getByText('Buy'));
85+
expect(onAction).toHaveBeenCalledTimes(1);
86+
});
87+
88+
it('renders the price secondary line when secondaryLine is provided', () => {
89+
renderWithProvider(
90+
<AssetRow
91+
asset={btcAsset}
92+
actionLabel="Buy"
93+
accessibilityLabel="Buy BTC"
94+
onAction={onAction}
95+
secondaryLine={{
96+
priceText: '$95,000.00',
97+
changeText: '+2.50%',
98+
changeColor: TextColor.SuccessDefault,
99+
}}
100+
/>,
101+
);
102+
expect(screen.getByText('$95,000.00')).toBeOnTheScreen();
103+
expect(screen.getByText('+2.50%')).toBeOnTheScreen();
104+
});
105+
106+
it('renders just the price without change text when changeText is undefined', () => {
107+
renderWithProvider(
108+
<AssetRow
109+
asset={btcAsset}
110+
actionLabel="Buy"
111+
accessibilityLabel="Buy BTC"
112+
onAction={onAction}
113+
secondaryLine={{
114+
priceText: '$95,000.00',
115+
changeText: undefined,
116+
changeColor: TextColor.TextAlternative,
117+
}}
118+
/>,
119+
);
120+
expect(screen.getByText('$95,000.00')).toBeOnTheScreen();
121+
expect(screen.queryByText('%')).toBeNull();
122+
});
123+
124+
it('does not render secondary line when secondaryLine is not provided', () => {
125+
renderWithProvider(
126+
<AssetRow
127+
asset={btcAsset}
128+
actionLabel="Buy"
129+
accessibilityLabel="Buy BTC"
130+
onAction={onAction}
131+
/>,
132+
);
133+
expect(screen.queryByText('$')).toBeNull();
134+
});
135+
});

app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,31 @@ import {
1717
import type { RelatedAsset } from '@metamask/ai-controllers';
1818
import { getRelatedAssetImageSource } from '../utils/getRelatedAssetImageSource';
1919

20+
export interface AssetRowSecondaryLine {
21+
priceText: string;
22+
changeText: string | undefined;
23+
changeColor: TextColor;
24+
}
25+
2026
interface AssetRowProps {
2127
asset: RelatedAsset;
22-
actionLabel: string;
23-
accessibilityLabel: string;
24-
onAction: () => void;
28+
actionLabel?: string;
29+
accessibilityLabel?: string;
30+
onAction?: () => void;
31+
/** When provided, renders price + 24h change below the asset name. */
32+
secondaryLine?: AssetRowSecondaryLine;
2533
}
2634

2735
/**
28-
* Shared layout for a single asset row (logo + symbol + action button).
29-
* Used by TokenRow (Buy/Trade) and PerpsRow (Trade); each wrapper supplies its
30-
* own hook logic and passes the resolved label and handler here.
36+
* Shared layout for a single asset row (logo + name + optional badge + optional
37+
* price/change + optional action button). Used by PerpsRow (Trade when tradable).
3138
*/
3239
const AssetRow: React.FC<AssetRowProps> = ({
3340
asset,
3441
actionLabel,
3542
accessibilityLabel,
3643
onAction,
44+
secondaryLine,
3745
}) => {
3846
const rawImageSource = getRelatedAssetImageSource(asset);
3947
const imageSource = Array.isArray(rawImageSource)
@@ -59,22 +67,58 @@ const AssetRow: React.FC<AssetRowProps> = ({
5967
alignItems={BoxAlignItems.Center}
6068
justifyContent={BoxJustifyContent.Between}
6169
>
62-
<Text
63-
variant={TextVariant.BodyMd}
64-
fontWeight={FontWeight.Medium}
65-
color={TextColor.TextDefault}
66-
>
67-
{asset.symbol}
68-
</Text>
70+
{/* Left: name + optional badge + optional price/change */}
71+
<Box twClassName="flex-1 mr-2">
72+
<Text
73+
variant={TextVariant.BodyMd}
74+
fontWeight={FontWeight.Medium}
75+
color={TextColor.TextDefault}
76+
numberOfLines={1}
77+
>
78+
{asset.name || asset.symbol}
79+
</Text>
80+
81+
{secondaryLine && (
82+
<Box
83+
flexDirection={BoxFlexDirection.Row}
84+
alignItems={BoxAlignItems.Center}
85+
>
86+
<Text
87+
variant={TextVariant.BodySm}
88+
color={TextColor.TextAlternative}
89+
>
90+
{secondaryLine.priceText}
91+
</Text>
92+
{secondaryLine.changeText ? (
93+
<>
94+
<Text
95+
variant={TextVariant.BodySm}
96+
color={TextColor.TextAlternative}
97+
>
98+
{' \u2022 '}
99+
</Text>
100+
<Text
101+
variant={TextVariant.BodySm}
102+
color={secondaryLine.changeColor}
103+
>
104+
{secondaryLine.changeText}
105+
</Text>
106+
</>
107+
) : null}
108+
</Box>
109+
)}
110+
</Box>
69111

70-
<Button
71-
variant={ButtonVariant.Primary}
72-
size={ButtonSize.Md}
73-
onPress={onAction}
74-
accessibilityLabel={accessibilityLabel}
75-
>
76-
{actionLabel}
77-
</Button>
112+
{onAction && actionLabel && accessibilityLabel ? (
113+
<Button
114+
variant={ButtonVariant.Secondary}
115+
size={ButtonSize.Md}
116+
onPress={onAction}
117+
accessibilityLabel={accessibilityLabel}
118+
>
119+
{actionLabel}
120+
</Button>
121+
) : null}
78122
</Box>
79123
</Box>
80124
);

0 commit comments

Comments
 (0)