Skip to content

Commit e8e742e

Browse files
feat: TSA 488-2 Whats Happening expanded view + Explore page (#29681)
<!-- 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** Adds the expanded view of the Whats Happening section and adds it to the Explore page. https://github.com/user-attachments/assets/3f3da5ca-6f5d-4568-8ab9-2220e8ba706b <!-- 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: 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** > Adds a new navigation route and substantial new UI flow (carousel detail view, bottom sheet link-out, and Buy/Trade actions) which could impact navigation behavior and external URL handling if not covered by integration testing. > > **Overview** > Adds a new `Routes.WHATS_HAPPENING_DETAIL` screen and wires the Homepage “What’s happening” carousel to navigate to it using only an `initialIndex` param (the detail view fetches its own data). > > Introduces `WhatsHappeningDetailView` with a horizontally snapping expanded-card carousel, loading skeletons, error/retry state, and a page indicator. Expanded cards now support impact badges (new i18n strings), token/perps action rows (Buy via Ramp, Trade via Perps), and a sources bottom sheet that lists articles using a shared `ArticleRow` component with safe URL link-out. > > Refactors Market Insights trend sources bottom sheet to reuse the new `ArticleRow`, and adds unit tests covering the new screen/components, navigation params, impact badge rendering, and related-asset icon resolution (`getRelatedAssetImageSource`). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c3dd999. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Antonio Regadas <antonio.regadas@consensys.net>
1 parent f5da506 commit e8e742e

29 files changed

Lines changed: 1839 additions & 113 deletions

app/components/Nav/Main/MainNavigator.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import ContactForm from '../../Views/Settings/Contacts/ContactForm';
5151
import ActivityView from '../../Views/ActivityView';
5252
import RewardsNavigator from '../../UI/Rewards/RewardsNavigator';
5353
import { ExploreFeed } from '../../Views/TrendingView/TrendingView';
54+
import WhatsHappeningDetailView from '../../Views/WhatsHappeningDetailView';
5455
import ExploreSearchScreen from '../../Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen';
5556
import ExploreSectionResultsFullView from '../../Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView';
5657
import TrendingFeedSessionManager from '../../UI/Trending/services/TrendingFeedSessionManager';
@@ -1368,6 +1369,11 @@ const MainNavigator = () => {
13681369
component={SitesFullView}
13691370
options={{ headerShown: false, ...slideFromRightAnimation }}
13701371
/>
1372+
<Stack.Screen
1373+
name={Routes.WHATS_HAPPENING_DETAIL}
1374+
component={WhatsHappeningDetailView}
1375+
options={{ headerShown: false, ...slideFromRightAnimation }}
1376+
/>
13711377
<Stack.Screen
13721378
name={Routes.EXPLORE_SECTION_RESULTS_FULL_VIEW}
13731379
component={ExploreSectionResultsFullView}

app/components/Nav/Main/MainNavigator.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,6 +1045,19 @@ describe('MainNavigator', () => {
10451045
expect(screen).toBeDefined();
10461046
});
10471047

1048+
it('includes WhatsHappeningDetailView screen', () => {
1049+
const container = renderWithProvider(<MainNavigator />, {
1050+
state: initialRootState,
1051+
});
1052+
1053+
const screenProps = getScreenProps(container);
1054+
const screen = screenProps?.find(
1055+
(s) => s?.name === Routes.WHATS_HAPPENING_DETAIL,
1056+
);
1057+
1058+
expect(screen).toBeDefined();
1059+
});
1060+
10481061
it('includes Browser home screen in main navigator', () => {
10491062
const container = renderWithProvider(<MainNavigator />, {
10501063
state: initialRootState,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React from 'react';
2+
import { screen, fireEvent } from '@testing-library/react-native';
3+
import renderWithProvider from '../../../../util/test/renderWithProvider';
4+
import ArticleRow from './ArticleRow';
5+
import type { Article } from '@metamask/ai-controllers';
6+
7+
const mockArticle: Article = {
8+
title: 'Bitcoin ETF inflows hit record high',
9+
url: 'https://coindesk.com/news/btc-etf-inflows',
10+
source: 'CoinDesk',
11+
date: '2026-03-15T10:00:00.000Z',
12+
};
13+
14+
const mockArticleNoDate: Article = {
15+
title: 'Ethereum upgrade expected next quarter',
16+
url: 'https://theblock.co/news/eth-upgrade',
17+
source: 'The Block',
18+
date: '',
19+
};
20+
21+
describe('ArticleRow', () => {
22+
it('renders the article title', () => {
23+
renderWithProvider(
24+
<ArticleRow article={mockArticle} onPress={jest.fn()} />,
25+
);
26+
expect(screen.getByText(mockArticle.title)).toBeOnTheScreen();
27+
});
28+
29+
it('renders the article source', () => {
30+
renderWithProvider(
31+
<ArticleRow article={mockArticle} onPress={jest.fn()} />,
32+
);
33+
expect(screen.getByText(mockArticle.source)).toBeOnTheScreen();
34+
});
35+
36+
it('renders relative date when article has a date', () => {
37+
renderWithProvider(
38+
<ArticleRow article={mockArticle} onPress={jest.fn()} />,
39+
);
40+
// A separator dot is shown between source and date
41+
expect(screen.getByText('•')).toBeOnTheScreen();
42+
});
43+
44+
it('does not render date separator when article has no date', () => {
45+
renderWithProvider(
46+
<ArticleRow article={mockArticleNoDate} onPress={jest.fn()} />,
47+
);
48+
expect(screen.queryByText('•')).toBeNull();
49+
});
50+
51+
it('calls onPress with the article URL when tapped', () => {
52+
const onPress = jest.fn();
53+
renderWithProvider(<ArticleRow article={mockArticle} onPress={onPress} />);
54+
fireEvent.press(screen.getByText(mockArticle.title));
55+
expect(onPress).toHaveBeenCalledTimes(1);
56+
expect(onPress).toHaveBeenCalledWith(mockArticle.url);
57+
});
58+
59+
it('renders a bottom border when isLastItem is false', () => {
60+
const { toJSON } = renderWithProvider(
61+
<ArticleRow
62+
article={mockArticle}
63+
onPress={jest.fn()}
64+
isLastItem={false}
65+
/>,
66+
);
67+
// Tailwind compiles 'border-b border-muted' to resolved style props at render time.
68+
const json = JSON.stringify(toJSON());
69+
expect(json).toContain('"borderBottomWidth":1');
70+
});
71+
72+
it('does not render a bottom border when isLastItem is true', () => {
73+
const { toJSON } = renderWithProvider(
74+
<ArticleRow article={mockArticle} onPress={jest.fn()} isLastItem />,
75+
);
76+
const json = JSON.stringify(toJSON());
77+
expect(json).not.toContain('"borderBottomWidth":1');
78+
});
79+
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import React, { useCallback } from 'react';
2+
import { Image, Pressable } from 'react-native';
3+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
4+
import {
5+
Box,
6+
BoxAlignItems,
7+
BoxFlexDirection,
8+
FontWeight,
9+
Icon,
10+
IconColor,
11+
IconName,
12+
IconSize,
13+
Text,
14+
TextColor,
15+
TextVariant,
16+
} from '@metamask/design-system-react-native';
17+
import type { Article } from '@metamask/ai-controllers';
18+
import {
19+
formatRelativeTime,
20+
getFaviconUrl,
21+
} from '../utils/marketInsightsFormatting';
22+
23+
interface ArticleRowProps {
24+
article: Article;
25+
onPress: (url: string) => void;
26+
isLastItem?: boolean;
27+
}
28+
29+
/**
30+
* A single article row used in sources bottom sheets (What's Happening and
31+
* Market Insights). Displays the article title, favicon, source domain,
32+
* relative date, and an export icon. Tapping the row calls onPress with the
33+
* article URL.
34+
*/
35+
const ArticleRow: React.FC<ArticleRowProps> = ({
36+
article,
37+
onPress,
38+
isLastItem,
39+
}) => {
40+
const tw = useTailwind();
41+
42+
const handlePress = useCallback(() => {
43+
onPress(article.url);
44+
}, [onPress, article.url]);
45+
46+
return (
47+
<Pressable
48+
onPress={handlePress}
49+
accessibilityRole="link"
50+
style={({ pressed }) =>
51+
tw.style(
52+
'flex-row items-start py-3',
53+
!isLastItem && 'border-b border-muted',
54+
pressed && 'opacity-70',
55+
)
56+
}
57+
>
58+
<Box twClassName="flex-1">
59+
<Box
60+
flexDirection={BoxFlexDirection.Row}
61+
alignItems={BoxAlignItems.Start}
62+
twClassName="pr-1"
63+
>
64+
<Text
65+
variant={TextVariant.BodyMd}
66+
color={TextColor.TextDefault}
67+
twClassName="flex-1 pr-2"
68+
>
69+
{article.title}
70+
</Text>
71+
<Box paddingTop={1}>
72+
<Icon
73+
name={IconName.Export}
74+
size={IconSize.Sm}
75+
color={IconColor.IconAlternative}
76+
/>
77+
</Box>
78+
</Box>
79+
80+
<Box
81+
flexDirection={BoxFlexDirection.Row}
82+
alignItems={BoxAlignItems.Center}
83+
twClassName="pt-3"
84+
>
85+
<Box twClassName="w-4 h-4 rounded-full overflow-hidden mr-2">
86+
<Image
87+
source={{ uri: getFaviconUrl(article.url || article.source) }}
88+
style={tw.style('w-4 h-4 rounded-full')}
89+
/>
90+
</Box>
91+
<Box
92+
flexDirection={BoxFlexDirection.Row}
93+
alignItems={BoxAlignItems.Center}
94+
gap={1}
95+
>
96+
<Text
97+
variant={TextVariant.BodySm}
98+
fontWeight={FontWeight.Medium}
99+
color={TextColor.TextAlternative}
100+
>
101+
{article.source}
102+
</Text>
103+
{article.date ? (
104+
<>
105+
<Text
106+
variant={TextVariant.BodySm}
107+
color={TextColor.TextAlternative}
108+
>
109+
{'•'}
110+
</Text>
111+
<Text
112+
variant={TextVariant.BodySm}
113+
color={TextColor.TextAlternative}
114+
>
115+
{formatRelativeTime(article.date, { nowLabel: 'now' })}
116+
</Text>
117+
</>
118+
) : null}
119+
</Box>
120+
</Box>
121+
</Box>
122+
</Pressable>
123+
);
124+
};
125+
126+
export default ArticleRow;

app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.tsx

Lines changed: 7 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useCallback, useEffect, useRef } from 'react';
2-
import { Image, Pressable, ScrollView } from 'react-native';
2+
import { Pressable, ScrollView } from 'react-native';
33
import { useTailwind } from '@metamask/design-system-twrnc-preset';
44
import {
55
Box,
@@ -25,9 +25,9 @@ import BottomSheetHeader from '../../../../component-library/components/BottomSh
2525
import { strings } from '../../../../../locales/i18n';
2626
import {
2727
formatRelativeTime,
28-
getFaviconUrl,
2928
getNormalizedHandle,
3029
} from '../utils/marketInsightsFormatting';
30+
import ArticleRow from './ArticleRow';
3131

3232
interface MarketInsightsTrendSourcesBottomSheetProps {
3333
isVisible: boolean;
@@ -79,85 +79,12 @@ const MarketInsightsTrendSourcesBottomSheet: React.FC<
7979
const isLastItem =
8080
index === articles.length - 1 && tweets.length === 0;
8181
return (
82-
<Pressable
82+
<ArticleRow
8383
key={article.url}
84-
onPress={() => handleSourcePress(article.url)}
85-
style={({ pressed }) =>
86-
tw.style(
87-
'flex-row items-start py-3',
88-
!isLastItem && 'border-b border-muted',
89-
pressed && 'opacity-70',
90-
)
91-
}
92-
>
93-
<Box twClassName="flex-1">
94-
<Box
95-
flexDirection={BoxFlexDirection.Row}
96-
alignItems={BoxAlignItems.Start}
97-
twClassName="pr-1"
98-
>
99-
<Text
100-
variant={TextVariant.BodyMd}
101-
color={TextColor.TextDefault}
102-
twClassName="flex-1 pr-2"
103-
>
104-
{article.title}
105-
</Text>
106-
<Box paddingTop={1}>
107-
<Icon
108-
name={IconName.Export}
109-
size={IconSize.Sm}
110-
color={IconColor.IconAlternative}
111-
/>
112-
</Box>
113-
</Box>
114-
<Box
115-
flexDirection={BoxFlexDirection.Row}
116-
alignItems={BoxAlignItems.Center}
117-
twClassName="pt-3"
118-
>
119-
<Box twClassName="w-4 h-4 rounded-full overflow-hidden mr-2">
120-
<Image
121-
source={{
122-
uri: getFaviconUrl(article.url || article.source),
123-
}}
124-
style={tw.style('w-4 h-4 rounded-full')}
125-
/>
126-
</Box>
127-
<Box
128-
flexDirection={BoxFlexDirection.Row}
129-
alignItems={BoxAlignItems.Center}
130-
gap={1}
131-
>
132-
<Text
133-
variant={TextVariant.BodySm}
134-
fontWeight={FontWeight.Medium}
135-
color={TextColor.TextAlternative}
136-
>
137-
{article.source}
138-
</Text>
139-
{article.date ? (
140-
<>
141-
<Text
142-
variant={TextVariant.BodySm}
143-
color={TextColor.TextAlternative}
144-
>
145-
{'•'}
146-
</Text>
147-
<Text
148-
variant={TextVariant.BodySm}
149-
color={TextColor.TextAlternative}
150-
>
151-
{formatRelativeTime(article.date, {
152-
nowLabel: 'now',
153-
})}
154-
</Text>
155-
</>
156-
) : null}
157-
</Box>
158-
</Box>
159-
</Box>
160-
</Pressable>
84+
article={article}
85+
onPress={handleSourcePress}
86+
isLastItem={isLastItem}
87+
/>
16188
);
16289
})}
16390

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

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,9 @@ describe('WhatsHappeningSection', () => {
138138
});
139139
renderWithProvider(<WhatsHappeningSection {...defaultProps} />);
140140
fireEvent.press(screen.getByText(mockItem.title));
141-
expect(mockNavigate).toHaveBeenCalledWith(
142-
Routes.WHATS_HAPPENING_DETAIL,
143-
expect.objectContaining({ items: [mockItem], initialIndex: 0 }),
144-
);
141+
expect(mockNavigate).toHaveBeenCalledWith(Routes.WHATS_HAPPENING_DETAIL, {
142+
initialIndex: 0,
143+
});
145144
});
146145

147146
it('navigates to detail view at index 0 when ViewMore card is pressed', () => {
@@ -153,10 +152,9 @@ describe('WhatsHappeningSection', () => {
153152
});
154153
renderWithProvider(<WhatsHappeningSection {...defaultProps} />);
155154
fireEvent.press(screen.getByText(/view more/i));
156-
expect(mockNavigate).toHaveBeenCalledWith(
157-
Routes.WHATS_HAPPENING_DETAIL,
158-
expect.objectContaining({ items: [mockItem], initialIndex: 0 }),
159-
);
155+
expect(mockNavigate).toHaveBeenCalledWith(Routes.WHATS_HAPPENING_DETAIL, {
156+
initialIndex: 0,
157+
});
160158
});
161159

162160
it('navigates with correct index when second card is pressed', () => {
@@ -173,12 +171,8 @@ describe('WhatsHappeningSection', () => {
173171
});
174172
renderWithProvider(<WhatsHappeningSection {...defaultProps} />);
175173
fireEvent.press(screen.getByText(secondItem.title));
176-
expect(mockNavigate).toHaveBeenCalledWith(
177-
Routes.WHATS_HAPPENING_DETAIL,
178-
expect.objectContaining({
179-
items: [mockItem, secondItem],
180-
initialIndex: 1,
181-
}),
182-
);
174+
expect(mockNavigate).toHaveBeenCalledWith(Routes.WHATS_HAPPENING_DETAIL, {
175+
initialIndex: 1,
176+
});
183177
});
184178
});

0 commit comments

Comments
 (0)