Skip to content

Commit 99e4557

Browse files
authored
chore: add Whats Happening segment events analytics (#29803)
<!-- 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** Wires 5 events for Whats Happening analytics: Card Scrolled to View, emitted via `useViewportTracking` in WhatsHappeningCard Opened — emitted in `WhatsHappeningSection` on card press and View All Viewed — emitted in `WhatsHappeningDetailView` on initial mount and on each carousel scroll Interaction — emitted in `TokenRow` Closed — emitted in `WhatsHappeningDetailView` on back press ## **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 new analytics instrumentation across the Whats Happening homepage section and detail flow, including new component props and scroll/visibility hooks that could subtly affect rendering or event duplication if indices/items drift. > > **Overview** > Adds a full set of **MetaMetrics Whats Happening** events (new constants in `MetaMetrics.events.ts`) and wires them through the homepage section and detail experience. > > Homepage now tracks `WHATS_HAPPENING_OPENED` with an `entry_point` for both card taps and *View more*, and cards emit `WHATS_HAPPENING_CARD_SCROLLED_TO_VIEW` via `useViewportTracking` (introducing a required `cardIndex` prop). > > Detail view now tracks `WHATS_HAPPENING_VIEWED` once on initial mount and again when the carousel settles on a new index, and tracks `WHATS_HAPPENING_CLOSED` on back. Token/perps CTAs and source link presses now emit `WHATS_HAPPENING_INTERACTION` with standardized properties via the new shared helper `getWhatsHappeningEventProps` (plus `interaction_type` and asset/source fields). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9d3ed1a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e34c66e commit 99e4557

17 files changed

Lines changed: 950 additions & 142 deletions

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@ import { screen, fireEvent } from '@testing-library/react-native';
33
import renderWithProvider from '../../../../../util/test/renderWithProvider';
44
import WhatsHappeningSection from './WhatsHappeningSection';
55
import Routes from '../../../../../constants/navigation/Routes';
6+
import { MetaMetricsEvents } from '../../../../../core/Analytics/MetaMetrics.events';
67

78
const mockNavigate = jest.fn();
9+
const mockTrackEvent = jest.fn();
10+
const mockCreateEventBuilder = jest.fn((eventName: string) => ({
11+
addProperties: jest.fn((properties: Record<string, unknown>) => ({
12+
build: jest.fn(() => ({ category: eventName, properties })),
13+
})),
14+
build: jest.fn(() => ({ category: eventName })),
15+
}));
816

917
jest.mock('@react-navigation/native', () => {
1018
const actual = jest.requireActual('@react-navigation/native');
@@ -30,6 +38,13 @@ jest.mock('./hooks', () => ({
3038
})),
3139
}));
3240

41+
jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({
42+
useAnalytics: () => ({
43+
trackEvent: mockTrackEvent,
44+
createEventBuilder: mockCreateEventBuilder,
45+
}),
46+
}));
47+
3348
const mockUseWhatsHappening = jest.requireMock('./hooks').useWhatsHappening;
3449
const mockSelectWhatsHappeningEnabled = jest.requireMock(
3550
'../../../../../selectors/featureFlagController/whatsHappening',
@@ -175,4 +190,50 @@ describe('WhatsHappeningSection', () => {
175190
initialIndex: 1,
176191
});
177192
});
193+
194+
it('tracks Whats Happening Opened with entry_point=card when a card is pressed', () => {
195+
mockUseWhatsHappening.mockReturnValue({
196+
items: [mockItem],
197+
isLoading: false,
198+
error: null,
199+
refresh: jest.fn(),
200+
});
201+
renderWithProvider(<WhatsHappeningSection {...defaultProps} />);
202+
fireEvent.press(screen.getByText(mockItem.title));
203+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
204+
MetaMetricsEvents.WHATS_HAPPENING_OPENED,
205+
);
206+
expect(mockTrackEvent).toHaveBeenCalledWith(
207+
expect.objectContaining({
208+
category: MetaMetricsEvents.WHATS_HAPPENING_OPENED,
209+
properties: expect.objectContaining({
210+
entry_point: 'card',
211+
event_id: mockItem.id,
212+
card_index: 0,
213+
category: 'macro',
214+
impact: 'positive',
215+
asset_symbols: [],
216+
}),
217+
}),
218+
);
219+
});
220+
221+
it('tracks Whats Happening Opened with entry_point=view_all when View More is pressed', () => {
222+
mockUseWhatsHappening.mockReturnValue({
223+
items: [mockItem],
224+
isLoading: false,
225+
error: null,
226+
refresh: jest.fn(),
227+
});
228+
renderWithProvider(<WhatsHappeningSection {...defaultProps} />);
229+
fireEvent.press(screen.getByText(/view more/i));
230+
expect(mockTrackEvent).toHaveBeenCalledWith(
231+
expect.objectContaining({
232+
category: MetaMetricsEvents.WHATS_HAPPENING_OPENED,
233+
properties: expect.objectContaining({
234+
entry_point: 'view_all',
235+
}),
236+
}),
237+
);
238+
});
178239
});

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,17 @@ import { SectionRefreshHandle } from '../../types';
1616
import { selectWhatsHappeningEnabled } from '../../../../../selectors/featureFlagController/whatsHappening';
1717
import { strings } from '../../../../../../locales/i18n';
1818
import Routes from '../../../../../constants/navigation/Routes';
19-
import { MAX_ITEMS_DISPLAYED } from './constants';
19+
import { MAX_ITEMS_DISPLAYED, WhatsHappeningEntryPoint } from './constants';
2020
import { useWhatsHappening } from './hooks';
2121
import { WhatsHappeningCard, WhatsHappeningCardSkeleton } from './components';
2222
import useHomeViewedEvent, {
2323
HomeSectionNames,
2424
} from '../../hooks/useHomeViewedEvent';
2525
import { useSectionPerformance } from '../../hooks/useSectionPerformance';
2626
import { WalletViewSelectorsIDs } from '../../../Wallet/WalletView.testIds';
27+
import { MetaMetricsEvents } from '../../../../../core/Analytics';
28+
import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
29+
import { getWhatsHappeningEventProps } from './eventProperties';
2730

2831
const CARD_WIDTH = 280;
2932
const GAP = 12;
@@ -56,6 +59,7 @@ const WhatsHappeningSection = forwardRef<
5659
const navigation = useNavigation();
5760
const isEnabled = useSelector(selectWhatsHappeningEnabled);
5861
const title = strings('homepage.sections.whats_happening');
62+
const { trackEvent, createEventBuilder } = useAnalytics();
5963

6064
const { items, isLoading, error, refresh } =
6165
useWhatsHappening(MAX_ITEMS_DISPLAYED);
@@ -98,14 +102,30 @@ const WhatsHappeningSection = forwardRef<
98102
);
99103

100104
const handleViewAll = useCallback(() => {
105+
trackEvent(
106+
createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_OPENED)
107+
.addProperties({ entry_point: WhatsHappeningEntryPoint.ViewAll })
108+
.build(),
109+
);
101110
navigateToDetail(0);
102-
}, [navigateToDetail]);
111+
}, [navigateToDetail, trackEvent, createEventBuilder]);
103112

104113
const handleCardPress = useCallback(
105114
(index: number) => {
115+
const item = items[index];
116+
if (item) {
117+
trackEvent(
118+
createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_OPENED)
119+
.addProperties({
120+
...getWhatsHappeningEventProps(item, index),
121+
entry_point: WhatsHappeningEntryPoint.Card,
122+
})
123+
.build(),
124+
);
125+
}
106126
navigateToDetail(index);
107127
},
108-
[navigateToDetail],
128+
[items, navigateToDetail, trackEvent, createEventBuilder],
109129
);
110130

111131
if (!isEnabled) {
@@ -161,6 +181,7 @@ const WhatsHappeningSection = forwardRef<
161181
<WhatsHappeningCard
162182
key={item.id}
163183
item={item}
184+
cardIndex={index}
164185
onPress={() => handleCardPress(index)}
165186
/>
166187
))}

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

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,30 @@ import { screen, fireEvent } from '@testing-library/react-native';
33
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
44
import WhatsHappeningCard from './WhatsHappeningCard';
55
import type { WhatsHappeningItem } from '../types';
6+
import { MetaMetricsEvents } from '../../../../../../core/Analytics/MetaMetrics.events';
7+
8+
const mockTrackEvent = jest.fn();
9+
const mockCreateEventBuilder = jest.fn((eventName: string) => ({
10+
addProperties: jest.fn((properties: Record<string, unknown>) => ({
11+
build: jest.fn(() => ({ category: eventName, properties })),
12+
})),
13+
build: jest.fn(() => ({ category: eventName })),
14+
}));
15+
16+
let capturedOnVisible: (() => void) | null = null;
17+
jest.mock('../../../../../UI/MarketInsights/hooks/useViewportTracking', () => ({
18+
useViewportTracking: (onVisible: () => void) => {
19+
capturedOnVisible = onVisible;
20+
return { ref: { current: null }, onLayout: jest.fn() };
21+
},
22+
}));
23+
24+
jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({
25+
useAnalytics: () => ({
26+
trackEvent: mockTrackEvent,
27+
createEventBuilder: mockCreateEventBuilder,
28+
}),
29+
}));
630

731
const mockRelatedAsset = {
832
sourceAssetId: 'btc-mainnet',
@@ -23,63 +47,68 @@ const baseItem: WhatsHappeningItem = {
2347
};
2448

2549
describe('WhatsHappeningCard', () => {
50+
beforeEach(() => {
51+
jest.clearAllMocks();
52+
capturedOnVisible = null;
53+
});
54+
2655
it('renders title and description', () => {
27-
renderWithProvider(<WhatsHappeningCard item={baseItem} />);
56+
renderWithProvider(<WhatsHappeningCard item={baseItem} cardIndex={0} />);
2857
expect(screen.getByText(baseItem.title)).toBeOnTheScreen();
2958
expect(screen.getByText(baseItem.description)).toBeOnTheScreen();
3059
});
3160

3261
it('renders category badge when category is provided', () => {
33-
renderWithProvider(<WhatsHappeningCard item={baseItem} />);
62+
renderWithProvider(<WhatsHappeningCard item={baseItem} cardIndex={0} />);
3463
expect(screen.getByText('Macro')).toBeOnTheScreen();
3564
});
3665

3766
it('does not render category badge when category is absent', () => {
3867
const item = { ...baseItem, category: undefined };
39-
renderWithProvider(<WhatsHappeningCard item={item} />);
68+
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
4069
expect(screen.queryByText('Macro')).toBeNull();
4170
});
4271

4372
it('renders Bullish impact badge for positive impact', () => {
4473
const item = { ...baseItem, impact: 'positive' as const };
45-
renderWithProvider(<WhatsHappeningCard item={item} />);
74+
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
4675
expect(screen.getByText('Bullish')).toBeOnTheScreen();
4776
});
4877

4978
it('renders Bearish impact badge for negative impact', () => {
5079
const item = { ...baseItem, impact: 'negative' as const };
51-
renderWithProvider(<WhatsHappeningCard item={item} />);
80+
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
5281
expect(screen.getByText('Bearish')).toBeOnTheScreen();
5382
});
5483

5584
it('renders Neutral impact badge for neutral impact', () => {
5685
const item = { ...baseItem, impact: 'neutral' as const };
57-
renderWithProvider(<WhatsHappeningCard item={item} />);
86+
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
5887
expect(screen.getByText('Neutral')).toBeOnTheScreen();
5988
});
6089

6190
it('does not render impact badge when impact is absent', () => {
6291
const item = { ...baseItem, impact: undefined };
63-
renderWithProvider(<WhatsHappeningCard item={item} />);
92+
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
6493
expect(screen.queryByText('Bullish')).toBeNull();
6594
expect(screen.queryByText('Bearish')).toBeNull();
6695
expect(screen.queryByText('Neutral')).toBeNull();
6796
});
6897

6998
it('renders impact badge alongside category badge', () => {
70-
renderWithProvider(<WhatsHappeningCard item={baseItem} />);
99+
renderWithProvider(<WhatsHappeningCard item={baseItem} cardIndex={0} />);
71100
expect(screen.getByText('Bullish')).toBeOnTheScreen();
72101
expect(screen.getByText('Macro')).toBeOnTheScreen();
73102
});
74103

75104
it('renders related asset symbol pills', () => {
76-
renderWithProvider(<WhatsHappeningCard item={baseItem} />);
105+
renderWithProvider(<WhatsHappeningCard item={baseItem} cardIndex={0} />);
77106
expect(screen.getByText('BTC')).toBeOnTheScreen();
78107
});
79108

80109
it('does not render asset pills when relatedAssets is empty', () => {
81110
const item = { ...baseItem, relatedAssets: [] };
82-
renderWithProvider(<WhatsHappeningCard item={item} />);
111+
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
83112
expect(screen.queryByText('BTC')).toBeNull();
84113
});
85114

@@ -91,36 +120,57 @@ describe('WhatsHappeningCard', () => {
91120
caip19: ['eip155:1/slip44:60'],
92121
};
93122
const item = { ...baseItem, relatedAssets: [mockRelatedAsset, ethAsset] };
94-
renderWithProvider(<WhatsHappeningCard item={item} />);
123+
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
95124
expect(screen.getByText('BTC')).toBeOnTheScreen();
96125
expect(screen.getByText('ETH')).toBeOnTheScreen();
97126
});
98127

99128
it('renders formatted date when date is valid', () => {
100-
renderWithProvider(<WhatsHappeningCard item={baseItem} />);
129+
renderWithProvider(<WhatsHappeningCard item={baseItem} cardIndex={0} />);
101130
expect(screen.getByText('Mar 15, 2026')).toBeOnTheScreen();
102131
});
103132

104133
it('does not render date when date string is invalid', () => {
105134
const item = { ...baseItem, date: 'not-a-date' };
106-
renderWithProvider(<WhatsHappeningCard item={item} />);
135+
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
107136
expect(screen.queryByText('not-a-date')).toBeNull();
108137
});
109138

110139
it('calls onPress with the item when tapped', () => {
111140
const onPress = jest.fn();
112141
renderWithProvider(
113-
<WhatsHappeningCard item={baseItem} onPress={onPress} />,
142+
<WhatsHappeningCard item={baseItem} cardIndex={0} onPress={onPress} />,
114143
);
115144
fireEvent.press(screen.getByText(baseItem.title));
116145
expect(onPress).toHaveBeenCalledTimes(1);
117146
expect(onPress).toHaveBeenCalledWith(baseItem);
118147
});
119148

120149
it('does not throw when onPress is not provided', () => {
121-
renderWithProvider(<WhatsHappeningCard item={baseItem} />);
150+
renderWithProvider(<WhatsHappeningCard item={baseItem} cardIndex={0} />);
122151
expect(() =>
123152
fireEvent.press(screen.getByText(baseItem.title)),
124153
).not.toThrow();
125154
});
155+
156+
it('tracks Whats Happening Card Scrolled to View when card becomes visible', () => {
157+
renderWithProvider(<WhatsHappeningCard item={baseItem} cardIndex={2} />);
158+
expect(capturedOnVisible).not.toBeNull();
159+
capturedOnVisible?.();
160+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
161+
MetaMetricsEvents.WHATS_HAPPENING_CARD_SCROLLED_TO_VIEW,
162+
);
163+
expect(mockTrackEvent).toHaveBeenCalledWith(
164+
expect.objectContaining({
165+
category: MetaMetricsEvents.WHATS_HAPPENING_CARD_SCROLLED_TO_VIEW,
166+
properties: expect.objectContaining({
167+
event_id: 'trend-0',
168+
card_index: 2,
169+
category: 'macro',
170+
impact: 'positive',
171+
asset_symbols: ['BTC'],
172+
}),
173+
}),
174+
);
175+
});
126176
});

0 commit comments

Comments
 (0)