Skip to content

Commit f146f58

Browse files
authored
feat: add Crypto Movers Quick Buy and search keyboard coordination (#32273)
Wire Quick Buy on Crypto Movers full view with explore_now analytics, dismiss keyboard when Quick Buy opens on search surfaces, and map explore sources to QUICK_BUY_EXPLORE for feature tracking. <!-- 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. --> <!-- mms-check directive vocabulary — read by .github/scripts/shared/pr-template-checks.ts at module load to build the validation plan. Directives are invisible in rendered markdown and must NOT be removed or edited without updating the validator registry. type=text Section must contain non-placeholder prose. type=changelog Section must have a valid CHANGELOG entry: line. type=issue-link Section must have a Fixes:/Closes:/Refs: line with a value. type=manual-testing Section must have real testing steps or an explicit N/A. type=screenshot Section must have evidence (image/URL) or an explicit N/A. type=checklist Section must have all checkboxes consciously checked. required=true|false Whether a missing/invalid section runs the validator at all. blocking=true|false Whether a failure of this check fails the CI workflow. Default: false — failures are shown as warnings in the sticky comment but do not block the PR. Sections without a directive are checked for structural presence only. --> ## **Description** <!-- mms-check: type=text required=true --> <!-- 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** <!-- mms-check: type=changelog required=true blocking=true --> <!-- 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: add Crypto Movers Quick Buy and search keyboard coordination ## **Related issues** <!-- mms-check: type=issue-link required=true --> Fixes: ## **Manual testing steps** <!-- mms-check: type=manual-testing required=true --> ```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** <!-- mms-check: type=screenshot required=true --> <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/72e5032d-1d19-4cf3-bbb4-9219e4c2c8cf ## **Pre-merge author checklist** <!-- mms-check: type=checklist required=true --> <!-- 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] > **Low Risk** > UI coordination and navigation/analytics params only; no auth, payments, or data-layer changes. Covered by new hook unit tests and an entry-point title test. > > **Overview** > Adds **`useQuickBuySearchKeyboard`** so Explore screens that mix inline search and Quick Buy keep the keyboard and bottom sheet from competing: opening Quick Buy dismisses the keyboard, and bringing the search keyboard back closes an open Quick Buy sheet. The hook is wired into **trending/stocks full views**, **explore search** (`ExploreSearchScreen`, `ExploreSearchResults`), with a shared **`closeQuickBuy`** callback. > > **Crypto Movers “View all”** from the Now tab now navigates to the trending tokens full view with **`entryPoint: 'crypto_movers'`** and **`quickBuySource: 'explore_now'`**, which drives the **Crypto Movers** header title and Quick Buy analytics source (default remains **`explore_trending`** for other entry paths). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9d2f1fc. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 4aafd62 commit f146f58

9 files changed

Lines changed: 195 additions & 13 deletions

File tree

app/components/UI/Trending/Views/RWATokensFullView/RWATokensFullView.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
EXPLORE_QUICK_BUY_VARIANTS,
1717
EXPLORE_QUICK_BUY_EXPOSURE_METADATA,
1818
} from '../../../../Views/TrendingView/search/abTestConfig';
19+
import { useQuickBuySearchKeyboard } from '../../hooks/useQuickBuySearchKeyboard/useQuickBuySearchKeyboard';
1920

2021
const RWATokensFullView = () => {
2122
const [quickTradeToken, setQuickTradeToken] = useState<TrendingAsset | null>(
@@ -57,6 +58,15 @@ const RWATokensFullView = () => {
5758
}
5859
}, [refetchStocks, filters]);
5960

61+
const closeQuickBuy = useCallback(() => {
62+
setQuickTradeToken(null);
63+
}, []);
64+
65+
useQuickBuySearchKeyboard(
66+
quickBuyVariant.showQuickTradeButton ? quickTradeToken : null,
67+
closeQuickBuy,
68+
);
69+
6070
return (
6171
<TokenListPageLayout
6272
title={strings('trending.stocks')}
@@ -75,7 +85,7 @@ const RWATokensFullView = () => {
7585
quickBuyNode={
7686
<TrendingQuickBuy
7787
token={quickTradeToken}
78-
onClose={() => setQuickTradeToken(null)}
88+
onClose={closeQuickBuy}
7989
source="explore_stocks"
8090
/>
8191
}

app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,19 @@ describe('TrendingTokensFullView', () => {
263263
expect(getByTestId('trending-tokens-header-back-button')).toBeOnTheScreen();
264264
});
265265

266+
it('shows Crypto movers title when opened from the Now tab entry point', () => {
267+
mockUseRoute.mockReturnValue({
268+
params: {
269+
entryPoint: 'crypto_movers',
270+
quickBuySource: 'explore_now',
271+
},
272+
});
273+
274+
const { getByText } = renderTrendingFullView();
275+
276+
expect(getByText(strings('trending.crypto_movers'))).toBeOnTheScreen();
277+
});
278+
266279
it('navigates back when back button is pressed', async () => {
267280
const mocks = arrangeMocks();
268281
const { getByTestId } = renderTrendingFullView();

app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,19 @@ import {
3535
EXPLORE_QUICK_BUY_VARIANTS,
3636
EXPLORE_QUICK_BUY_EXPOSURE_METADATA,
3737
} from '../../../../Views/TrendingView/search/abTestConfig';
38+
import type { QuickBuySheetSource } from '../../../../Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/analytics';
39+
import { useQuickBuySearchKeyboard } from '../../hooks/useQuickBuySearchKeyboard/useQuickBuySearchKeyboard';
40+
41+
export type TrendingTokensFullViewEntryPoint =
42+
| 'crypto_movers'
43+
| 'trending_tokens';
3844

3945
export interface TrendingTokensFullViewParams {
4046
initialTimeOption?: TimeOption;
47+
/** Quick Buy analytics source. Defaults to `explore_trending`. */
48+
quickBuySource?: QuickBuySheetSource;
49+
/** Entry surface for title and analytics context. */
50+
entryPoint?: TrendingTokensFullViewEntryPoint;
4151
}
4252

4353
export interface TrendingTokensDataProps {
@@ -133,6 +143,11 @@ const TrendingTokensFullView = () => {
133143
RouteProp<{ TrendingTokensFullView: TrendingTokensFullViewParams }>
134144
>();
135145
const initialTimeOption = params?.initialTimeOption;
146+
const quickBuySource = params?.quickBuySource ?? 'explore_trending';
147+
const pageTitle =
148+
params?.entryPoint === 'crypto_movers'
149+
? strings('trending.crypto_movers')
150+
: strings('trending.trending_tokens');
136151
const filters = useTokenListFilters({ timeOption: initialTimeOption });
137152

138153
const [sortBy, setSortBy] = useState<SortTrendingBy | undefined>(
@@ -247,6 +262,15 @@ const TrendingTokensFullView = () => {
247262
}
248263
}, [refetchTokensSection, setRefreshing]);
249264

265+
const closeQuickBuy = useCallback(() => {
266+
setQuickTradeToken(null);
267+
}, []);
268+
269+
useQuickBuySearchKeyboard(
270+
quickBuyVariant.showQuickTradeButton ? quickTradeToken : null,
271+
closeQuickBuy,
272+
);
273+
250274
const timeFilterButton = (
251275
<FilterButton
252276
testID="24h-button"
@@ -258,7 +282,7 @@ const TrendingTokensFullView = () => {
258282

259283
return (
260284
<TokenListPageLayout
261-
title={strings('trending.trending_tokens')}
285+
title={pageTitle}
262286
testID="trending-tokens-header"
263287
filters={filters}
264288
tokens={trendingTokens}
@@ -283,8 +307,8 @@ const TrendingTokensFullView = () => {
283307
quickBuyNode={
284308
<TrendingQuickBuy
285309
token={quickTradeToken}
286-
onClose={() => setQuickTradeToken(null)}
287-
source="explore_trending"
310+
onClose={closeQuickBuy}
311+
source={quickBuySource}
288312
/>
289313
}
290314
/>
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { renderHook, act } from '@testing-library/react-native';
2+
import type { TrendingAsset } from '@metamask/assets-controllers';
3+
import { Keyboard, Platform } from 'react-native';
4+
import { useQuickBuySearchKeyboard } from './useQuickBuySearchKeyboard';
5+
6+
const mockToken: TrendingAsset = {
7+
assetId: 'eip155:1/slip44:60',
8+
symbol: 'ETH',
9+
name: 'Ethereum',
10+
decimals: 18,
11+
price: '2000',
12+
aggregatedUsdVolume: 1_000_000,
13+
marketCap: 100_000_000,
14+
};
15+
16+
describe('useQuickBuySearchKeyboard', () => {
17+
let keyboardShowCallback: (() => void) | undefined;
18+
19+
beforeEach(() => {
20+
keyboardShowCallback = undefined;
21+
jest.spyOn(Keyboard, 'dismiss').mockImplementation(() => undefined);
22+
jest
23+
.spyOn(Keyboard, 'addListener')
24+
.mockImplementation(
25+
(event, callback: Parameters<typeof Keyboard.addListener>[1]) => {
26+
if (
27+
event ===
28+
(Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow')
29+
) {
30+
keyboardShowCallback = callback as () => void;
31+
}
32+
return { remove: jest.fn() } as unknown as ReturnType<
33+
typeof Keyboard.addListener
34+
>;
35+
},
36+
);
37+
});
38+
39+
afterEach(() => {
40+
jest.restoreAllMocks();
41+
});
42+
43+
it('dismisses the keyboard when Quick Buy opens', () => {
44+
const closeQuickBuy = jest.fn();
45+
46+
const { rerender } = renderHook(
47+
(props: { token: TrendingAsset | null }) =>
48+
useQuickBuySearchKeyboard(props.token, closeQuickBuy),
49+
{ initialProps: { token: null as TrendingAsset | null } },
50+
);
51+
52+
expect(Keyboard.dismiss).not.toHaveBeenCalled();
53+
54+
rerender({ token: mockToken });
55+
56+
expect(Keyboard.dismiss).toHaveBeenCalledTimes(1);
57+
});
58+
59+
it('closes Quick Buy when the search keyboard appears', () => {
60+
const closeQuickBuy = jest.fn();
61+
62+
renderHook(() => useQuickBuySearchKeyboard(mockToken, closeQuickBuy));
63+
64+
act(() => {
65+
keyboardShowCallback?.();
66+
});
67+
68+
expect(closeQuickBuy).toHaveBeenCalledTimes(1);
69+
});
70+
71+
it('does not close Quick Buy when the keyboard appears and the sheet is closed', () => {
72+
const closeQuickBuy = jest.fn();
73+
74+
renderHook(() => useQuickBuySearchKeyboard(null, closeQuickBuy));
75+
76+
act(() => {
77+
keyboardShowCallback?.();
78+
});
79+
80+
expect(closeQuickBuy).not.toHaveBeenCalled();
81+
});
82+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { TrendingAsset } from '@metamask/assets-controllers';
2+
import { useEffect, useRef } from 'react';
3+
import { Keyboard, Platform } from 'react-native';
4+
5+
/**
6+
* Explore surfaces that combine inline search with the Quick Buy sheet —
7+
* keeps the search keyboard and bottom sheet mutually exclusive:
8+
* - opening Quick Buy dismisses the search keyboard
9+
* - showing the search keyboard closes Quick Buy
10+
*/
11+
export function useQuickBuySearchKeyboard(
12+
quickTradeToken: TrendingAsset | null,
13+
closeQuickBuy: () => void,
14+
): void {
15+
const quickTradeTokenRef = useRef(quickTradeToken);
16+
quickTradeTokenRef.current = quickTradeToken;
17+
18+
const closeQuickBuyRef = useRef(closeQuickBuy);
19+
closeQuickBuyRef.current = closeQuickBuy;
20+
21+
useEffect(() => {
22+
if (quickTradeToken) {
23+
Keyboard.dismiss();
24+
}
25+
}, [quickTradeToken]);
26+
27+
useEffect(() => {
28+
const showEvent =
29+
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
30+
31+
const subscription = Keyboard.addListener(showEvent, () => {
32+
if (quickTradeTokenRef.current) {
33+
closeQuickBuyRef.current();
34+
}
35+
});
36+
37+
return () => subscription.remove();
38+
}, []);
39+
}

app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
EXPLORE_QUICK_BUY_VARIANTS,
1515
EXPLORE_QUICK_BUY_EXPOSURE_METADATA,
1616
} from '../../search/abTestConfig';
17+
import { useQuickBuySearchKeyboard } from '../../../../UI/Trending/hooks/useQuickBuySearchKeyboard/useQuickBuySearchKeyboard';
1718
import { useSafeAreaInsets } from 'react-native-safe-area-context';
1819
import { useNavigation } from '@react-navigation/native';
1920
import { useTailwind } from '@metamask/design-system-twrnc-preset';
@@ -82,6 +83,12 @@ const FullFeedList: React.FC<FullFeedListProps> = ({
8283
EXPLORE_QUICK_BUY_EXPOSURE_METADATA,
8384
);
8485

86+
const closeQuickBuy = useCallback(() => {
87+
setQuickTradeToken(null);
88+
}, []);
89+
90+
useQuickBuySearchKeyboard(quickTradeToken, closeQuickBuy);
91+
8592
useEffect(() => {
8693
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
8794
}, [searchQuery]);
@@ -173,10 +180,7 @@ const FullFeedList: React.FC<FullFeedListProps> = ({
173180
onEndReachedThreshold={0.3}
174181
ListFooterComponent={footer}
175182
/>
176-
<TrendingQuickBuy
177-
token={quickTradeToken}
178-
onClose={() => setQuickTradeToken(null)}
179-
/>
183+
<TrendingQuickBuy token={quickTradeToken} onClose={closeQuickBuy} />
180184
</>
181185
);
182186
};

app/components/Views/TrendingView/search/ExploreSearchResults.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
EXPLORE_QUICK_BUY_VARIANTS,
5050
EXPLORE_QUICK_BUY_EXPOSURE_METADATA,
5151
} from './abTestConfig';
52+
import { useQuickBuySearchKeyboard } from '../../../UI/Trending/hooks/useQuickBuySearchKeyboard/useQuickBuySearchKeyboard';
5253

5354
const POPULAR_ASSETS: TrendingAsset[] = [
5455
{
@@ -119,6 +120,12 @@ const ExploreSearchResults: React.FC<ExploreSearchResultsProps> = ({
119120
EXPLORE_QUICK_BUY_EXPOSURE_METADATA,
120121
);
121122

123+
const closeQuickBuy = useCallback(() => {
124+
setQuickTradeToken(null);
125+
}, []);
126+
127+
useQuickBuySearchKeyboard(quickTradeToken, closeQuickBuy);
128+
122129
const { onScrollBeginDrag, resetScrollTracking } = useScrollTracking(
123130
'scrolled',
124131
searchQuery,
@@ -383,10 +390,7 @@ const ExploreSearchResults: React.FC<ExploreSearchResultsProps> = ({
383390
ListFooterComponent={renderFooter}
384391
onScrollBeginDrag={onScrollBeginDrag}
385392
/>
386-
<TrendingQuickBuy
387-
token={quickTradeToken}
388-
onClose={() => setQuickTradeToken(null)}
389-
/>
393+
<TrendingQuickBuy token={quickTradeToken} onClose={closeQuickBuy} />
390394
</Box>
391395
);
392396
};

app/components/Views/TrendingView/tabs/NowTab.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -714,7 +714,11 @@ describe('NowTab — Crypto Movers', () => {
714714

715715
expect(mockNavigate).toHaveBeenCalledWith(
716716
Routes.WALLET.TRENDING_TOKENS_FULL_VIEW,
717-
{ initialTimeOption: '1h' },
717+
{
718+
initialTimeOption: '1h',
719+
entryPoint: 'crypto_movers',
720+
quickBuySource: 'explore_now',
721+
},
718722
);
719723
});
720724

app/components/Views/TrendingView/tabs/NowTab.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,8 @@ const NowTabContent: React.FC<TabProps> = ({
316316
onViewAll={() =>
317317
navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW, {
318318
initialTimeOption: CRYPTO_MOVERS_TIME_OPTION,
319+
entryPoint: 'crypto_movers',
320+
quickBuySource: 'explore_now',
319321
})
320322
}
321323
testID="section-header-view-all-crypto_movers"

0 commit comments

Comments
 (0)