Skip to content

Commit c9bc341

Browse files
authored
feat: implement scrolling to MerklRewards section on navigation (#24982)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** This PR implements a feature to notify users about claimable mUSD bonuses on eligible tokens (specifically mUSD on Linea). When a user has a claimable bonus, a "Claim bonus" CTA is displayed on the token list item. Clicking this CTA navigates to the asset details page and automatically scrolls to the MerklRewards section where the user can claim their bonus. **Key changes:** 1. **TokenListItem**: Added logic to detect claimable Merkl rewards and display "Claim bonus" CTA when applicable 2. **AssetOverview**: Implemented scroll-to-section functionality using a custom hook (`useScrollToMerklRewards`) that handles navigation parameters and scroll timing 3. **Transactions**: Added DeviceEventEmitter listener to handle scroll events from the header component 4. **Testing**: Added comprehensive unit tests for all new functionality **Technical implementation:** - Extracted scroll logic into a reusable hook (`useScrollToMerklRewards`) for better testability - Used `onLayout` to measure MerklRewards position relative to the header (which is the FlatList's ListHeaderComponent) - Implemented retry mechanism to handle cases where layout hasn't been measured yet - Added debouncing in the scroll listener to prevent multiple rapid scrolls - Used DeviceEventEmitter for communication between AssetOverview (header) and Transactions (FlatList) ## **Changelog** CHANGELOG entry: Added "Claim bonus" CTA on token list items for tokens with claimable mUSD bonuses, with automatic scroll to claim section on asset details page ## **Manual testing steps** ```gherkin Feature: Claim bonus notification on token list Scenario: User sees "Claim bonus" CTA for token with claimable bonus Given the user has a token with a claimable bonus And the user is viewing the token list When the token list item is displayed Then the "Claim bonus" CTA should be visible in the secondary balance area And clicking the CTA should navigate to the asset details page And the page should automatically scroll to the MerklRewards section Scenario: User does not see "Claim bonus" CTA for token without claimable bonus Given the user has a token without a claimable bonus And the user is viewing the token list When the token list item is displayed Then the "Claim bonus" CTA should not be visible And the normal secondary balance display (percentage change or mUSD conversion CTA) should be shown instead ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** https://github.com/user-attachments/assets/50dd5eb5-d724-4c69-89e2-2c2049c267e4 <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [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 - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] 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. ## **Pre-merge reviewer checklist** - [ ] 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] > Enables users to quickly claim Merkl rewards by surfacing a CTA in the token list and auto-scrolling to the rewards section on the asset details page. > > - **TokenListItem**: Displays `earn.claim_bonus` CTA when Merkl rewards are claimable (behind `selectMerklCampaignClaimingEnabledFlag`); pressing navigates to `Asset` with `scrollToMerklRewards` param > - **AssetOverview**: Wraps `MerklRewards` in a measured `View`, stores header-relative Y, and uses `useScrollToMerklRewards` to emit a scroll event (retry + delay) > - **Transactions (FlatList host)**: Listens for `scrollToMerklRewards` via `DeviceEventEmitter` and scrolls to the provided offset with debouncing and cleanup > - **Hooks/Utils**: New `useScrollToMerklRewards` and `scrollToMerklRewardsUtils` (padding, retries, delays); `useMerklRewards` now guards undefined assets > - **Tests/i18n**: Comprehensive tests for CTA visibility, navigation/scroll behavior, and hook logic; adds `earn.claim_bonus` locale string > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bdbd8e4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent eba23bb commit c9bc341

12 files changed

Lines changed: 1057 additions & 23 deletions

File tree

app/components/UI/AssetOverview/AssetOverview.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from '../AssetElement/index.constants';
2424
import { SolScope, SolAccountType } from '@metamask/keyring-api';
2525
import { useSendNonEvmAsset } from '../../hooks/useSendNonEvmAsset';
26+
import { useSendNavigation } from '../../Views/confirmations/hooks/useSendNavigation';
2627
import {
2728
ActionButtonType,
2829
ActionLocation,
@@ -222,6 +223,9 @@ jest.mock('../../../core/Engine', () => ({
222223
MultichainNetworkController: {
223224
setActiveNetwork: jest.fn().mockResolvedValue(undefined),
224225
},
226+
SwapsController: {
227+
fetchTokenWithCache: jest.fn().mockResolvedValue(undefined),
228+
},
225229
},
226230
}));
227231

@@ -297,6 +301,18 @@ jest.mock('../Ramp/hooks/useRampTokens', () => ({
297301
useRampTokens: () => mockUseRampTokens(),
298302
}));
299303

304+
// Only mock the new hook added in this branch: useScrollToMerklRewards
305+
// This hook uses useRoute/useNavigation which need proper test setup
306+
jest.mock('./hooks/useScrollToMerklRewards', () => ({
307+
useScrollToMerklRewards: jest.fn(() => ({
308+
hasScrolledRef: { current: false },
309+
})),
310+
}));
311+
312+
jest.mock('../../Views/confirmations/hooks/useSendNavigation', () => ({
313+
useSendNavigation: jest.fn(),
314+
}));
315+
300316
const asset = {
301317
balance: '400',
302318
balanceFiat: '1500',
@@ -378,6 +394,13 @@ describe('AssetOverview', () => {
378394
isLoading: false,
379395
error: null,
380396
});
397+
398+
// Setup useSendNavigation mock to call navigate
399+
(useSendNavigation as jest.Mock).mockReturnValue({
400+
navigateToSendPage: jest.fn((params) => {
401+
mockNavigate('Send', params);
402+
}),
403+
});
381404
});
382405

383406
afterEach(() => {

app/components/UI/AssetOverview/AssetOverview.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
1+
import React, {
2+
useCallback,
3+
useEffect,
4+
useMemo,
5+
useRef,
6+
useState,
7+
} from 'react';
28
import { TouchableOpacity, View } from 'react-native';
39
import { useNavigation } from '@react-navigation/native';
410
import { useDispatch, useSelector } from 'react-redux';
@@ -55,6 +61,7 @@ import Routes from '../../../constants/navigation/Routes';
5561
import TokenDetails from './TokenDetails';
5662
import { RootState } from '../../../reducers';
5763
import { MetaMetricsEvents } from '../../../core/Analytics';
64+
import { useScrollToMerklRewards } from './hooks/useScrollToMerklRewards';
5865
import { getDecimalChainId } from '../../../util/networks';
5966
import { useMetrics } from '../../../components/hooks/useMetrics';
6067
import {
@@ -223,6 +230,11 @@ const AssetOverview: React.FC<AssetOverviewProps> = ({
223230
selectMerklCampaignClaimingEnabledFlag,
224231
);
225232
const { navigateToSendPage } = useSendNavigation();
233+
const merklRewardsRef = useRef<View>(null);
234+
const merklRewardsYInHeaderRef = useRef<number | null>(null);
235+
236+
// Scroll to MerklRewards section when navigating from "Claim bonus" CTA
237+
useScrollToMerklRewards(merklRewardsYInHeaderRef);
226238

227239
const nativeCurrency = useSelector((state: RootState) =>
228240
selectNativeCurrencyByChainId(state, asset.chainId as Hex),
@@ -831,7 +843,20 @@ const AssetOverview: React.FC<AssetOverviewProps> = ({
831843
)
832844
///: END:ONLY_INCLUDE_IF
833845
}
834-
{isMerklCampaignClaimingEnabled && <MerklRewards asset={asset} />}
846+
{isMerklCampaignClaimingEnabled && (
847+
<View
848+
ref={merklRewardsRef}
849+
testID="merkl-rewards-section"
850+
onLayout={(event) => {
851+
// Store Y position relative to header (which is the scroll offset)
852+
// This is more reliable than measureInWindow for FlatList scrolling
853+
const { y } = event.nativeEvent.layout;
854+
merklRewardsYInHeaderRef.current = y;
855+
}}
856+
>
857+
<MerklRewards asset={asset} />
858+
</View>
859+
)}
835860
{isPerpsEnabled && hasPerpsMarket && marketData && (
836861
<>
837862
<View style={styles.perpsPositionHeader}>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { DeviceEventEmitter } from 'react-native';
2+
3+
export const SCROLL_PADDING = 350;
4+
export const MAX_RETRIES = 10;
5+
export const RETRY_DELAY_MS = 100;
6+
export const SCROLL_DELAY_MS = 150;
7+
8+
/**
9+
* Emits the scroll event to scroll to MerklRewards section
10+
*/
11+
export const emitScrollToMerklRewards = (scrollY: number) => {
12+
DeviceEventEmitter.emit('scrollToMerklRewards', { y: scrollY });
13+
};
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import { renderHook, act, waitFor } from '@testing-library/react-native';
2+
3+
// Mock the emit function from the utility module
4+
const mockEmitScrollToMerklRewards = jest.fn();
5+
6+
jest.mock('./scrollToMerklRewardsUtils', () => ({
7+
SCROLL_PADDING: 350,
8+
MAX_RETRIES: 10,
9+
RETRY_DELAY_MS: 100,
10+
SCROLL_DELAY_MS: 150,
11+
emitScrollToMerklRewards: (...args: unknown[]) =>
12+
mockEmitScrollToMerklRewards(...args),
13+
}));
14+
15+
// Mock React Navigation
16+
const mockSetParams = jest.fn();
17+
const mockRoute: { params: Record<string, unknown> } = {
18+
params: {},
19+
};
20+
21+
jest.mock('@react-navigation/native', () => {
22+
const actual = jest.requireActual('@react-navigation/native');
23+
const ReactActual = jest.requireActual('react');
24+
return {
25+
...actual,
26+
useRoute: () => mockRoute,
27+
useNavigation: () => ({
28+
setParams: (...args: unknown[]) => mockSetParams(...args),
29+
}),
30+
// Run focus effects as a normal effect during tests
31+
useFocusEffect: (effect: () => void | (() => void)) => {
32+
ReactActual.useEffect(() => {
33+
const cleanup = effect();
34+
return cleanup;
35+
}, []);
36+
},
37+
};
38+
});
39+
40+
// Import after mocks
41+
import { useScrollToMerklRewards } from './useScrollToMerklRewards';
42+
43+
describe('useScrollToMerklRewards', () => {
44+
beforeEach(() => {
45+
jest.useFakeTimers();
46+
mockEmitScrollToMerklRewards.mockClear();
47+
mockSetParams.mockClear();
48+
mockRoute.params = {};
49+
});
50+
51+
afterEach(() => {
52+
jest.runOnlyPendingTimers();
53+
jest.useRealTimers();
54+
});
55+
56+
it('does not scroll when scrollToMerklRewards param is not present', () => {
57+
const merklRewardsYInHeaderRef = { current: 500 };
58+
renderHook(() => useScrollToMerklRewards(merklRewardsYInHeaderRef));
59+
60+
// Run all timers to ensure any scheduled callbacks would execute
61+
act(() => {
62+
jest.runAllTimers();
63+
});
64+
65+
expect(mockEmitScrollToMerklRewards).not.toHaveBeenCalled();
66+
expect(mockSetParams).not.toHaveBeenCalled();
67+
});
68+
69+
it('scrolls when scrollToMerklRewards param is true and Y position is available', async () => {
70+
mockRoute.params = { scrollToMerklRewards: true };
71+
const merklRewardsYInHeaderRef = { current: 500 };
72+
73+
renderHook(() => useScrollToMerklRewards(merklRewardsYInHeaderRef));
74+
75+
// Wait for effect to run and setParams to be called
76+
await waitFor(() => {
77+
expect(mockSetParams).toHaveBeenCalledWith({
78+
scrollToMerklRewards: undefined,
79+
});
80+
});
81+
82+
// Advance timers to execute the setTimeout(150)
83+
act(() => {
84+
jest.advanceTimersByTime(150);
85+
});
86+
87+
expect(mockEmitScrollToMerklRewards).toHaveBeenCalledWith(150); // 500 - 350
88+
});
89+
90+
it('retries scrolling when Y position is not available initially', async () => {
91+
mockRoute.params = { scrollToMerklRewards: true };
92+
const merklRewardsYInHeaderRef: React.MutableRefObject<number | null> = {
93+
current: null,
94+
};
95+
96+
renderHook(() => useScrollToMerklRewards(merklRewardsYInHeaderRef));
97+
98+
// Wait for effect to run
99+
await waitFor(() => {
100+
expect(mockSetParams).toHaveBeenCalled();
101+
});
102+
103+
// First retry - still null
104+
act(() => {
105+
jest.advanceTimersByTime(100);
106+
});
107+
108+
expect(mockEmitScrollToMerklRewards).not.toHaveBeenCalled();
109+
110+
// Set Y position after first retry
111+
merklRewardsYInHeaderRef.current = 600;
112+
113+
// Second retry + scroll timeout
114+
act(() => {
115+
jest.advanceTimersByTime(100 + 150);
116+
});
117+
118+
expect(mockEmitScrollToMerklRewards).toHaveBeenCalledWith(250); // 600 - 350
119+
});
120+
121+
it('stops retrying after max retries', () => {
122+
mockRoute.params = { scrollToMerklRewards: true };
123+
const merklRewardsYInHeaderRef = { current: null };
124+
125+
renderHook(() => useScrollToMerklRewards(merklRewardsYInHeaderRef));
126+
127+
// Fast-forward through all retries (10 retries * 100ms = 1000ms)
128+
act(() => {
129+
jest.advanceTimersByTime(1100);
130+
});
131+
132+
// Should not have emitted after max retries
133+
expect(mockEmitScrollToMerklRewards).not.toHaveBeenCalled();
134+
});
135+
136+
it('subtracts padding from Y position when calculating scroll offset', async () => {
137+
mockRoute.params = { scrollToMerklRewards: true };
138+
const merklRewardsYInHeaderRef = { current: 1000 };
139+
140+
renderHook(() => useScrollToMerklRewards(merklRewardsYInHeaderRef));
141+
142+
// Wait for effect to run
143+
await waitFor(() => {
144+
expect(mockSetParams).toHaveBeenCalled();
145+
});
146+
147+
// Advance timers to execute the scroll setTimeout(150)
148+
act(() => {
149+
jest.advanceTimersByTime(150);
150+
});
151+
152+
expect(mockEmitScrollToMerklRewards).toHaveBeenCalledWith(650); // 1000 - 350
153+
});
154+
155+
it('does not allow negative scroll offset', async () => {
156+
mockRoute.params = { scrollToMerklRewards: true };
157+
const merklRewardsYInHeaderRef = { current: 200 }; // Less than padding
158+
159+
renderHook(() => useScrollToMerklRewards(merklRewardsYInHeaderRef));
160+
161+
// Wait for effect to run
162+
await waitFor(() => {
163+
expect(mockSetParams).toHaveBeenCalled();
164+
});
165+
166+
// Advance timers to execute the scroll setTimeout(150)
167+
act(() => {
168+
jest.advanceTimersByTime(150);
169+
});
170+
171+
expect(mockEmitScrollToMerklRewards).toHaveBeenCalledWith(0); // Math.max(0, 200 - 350) = 0
172+
});
173+
174+
it('scrolls only once per navigation', async () => {
175+
mockRoute.params = { scrollToMerklRewards: true };
176+
const merklRewardsYInHeaderRef = { current: 500 };
177+
178+
const { rerender } = renderHook(() =>
179+
useScrollToMerklRewards(merklRewardsYInHeaderRef),
180+
);
181+
182+
// Wait for effect to run
183+
await waitFor(() => {
184+
expect(mockSetParams).toHaveBeenCalled();
185+
});
186+
187+
// Advance timers to execute the scroll setTimeout(150)
188+
act(() => {
189+
jest.advanceTimersByTime(150);
190+
});
191+
192+
expect(mockEmitScrollToMerklRewards).toHaveBeenCalledTimes(1);
193+
194+
// Clear the mock
195+
mockEmitScrollToMerklRewards.mockClear();
196+
197+
// Rerender with same params - should not scroll again
198+
rerender(merklRewardsYInHeaderRef);
199+
200+
act(() => {
201+
jest.advanceTimersByTime(200);
202+
});
203+
204+
// Should not emit again
205+
expect(mockEmitScrollToMerklRewards).not.toHaveBeenCalled();
206+
});
207+
208+
it('cancels pending scroll timeout on unmount', async () => {
209+
mockRoute.params = { scrollToMerklRewards: true };
210+
const merklRewardsYInHeaderRef = { current: 500 };
211+
212+
const { unmount } = renderHook(() =>
213+
useScrollToMerklRewards(merklRewardsYInHeaderRef),
214+
);
215+
216+
// Wait for effect to run
217+
await waitFor(() => {
218+
expect(mockSetParams).toHaveBeenCalled();
219+
});
220+
221+
// Unmount before scroll timeout fires (before 150ms)
222+
unmount();
223+
224+
// Advance timers past the scroll delay
225+
act(() => {
226+
jest.advanceTimersByTime(200);
227+
});
228+
229+
// Should NOT have emitted because timeout was cancelled on unmount
230+
expect(mockEmitScrollToMerklRewards).not.toHaveBeenCalled();
231+
});
232+
233+
it('cancels pending retry timeouts on unmount', async () => {
234+
mockRoute.params = { scrollToMerklRewards: true };
235+
const merklRewardsYInHeaderRef: React.MutableRefObject<number | null> = {
236+
current: null,
237+
};
238+
239+
const { unmount } = renderHook(() =>
240+
useScrollToMerklRewards(merklRewardsYInHeaderRef),
241+
);
242+
243+
// Wait for effect to run
244+
await waitFor(() => {
245+
expect(mockSetParams).toHaveBeenCalled();
246+
});
247+
248+
// Advance to first retry
249+
act(() => {
250+
jest.advanceTimersByTime(50);
251+
});
252+
253+
// Unmount during retry phase
254+
unmount();
255+
256+
// Set Y position that would normally trigger scroll
257+
merklRewardsYInHeaderRef.current = 600;
258+
259+
// Advance timers through all remaining retry attempts
260+
act(() => {
261+
jest.advanceTimersByTime(2000);
262+
});
263+
264+
// Should NOT have emitted because retry timeouts were cancelled
265+
expect(mockEmitScrollToMerklRewards).not.toHaveBeenCalled();
266+
});
267+
});

0 commit comments

Comments
 (0)