Skip to content

Commit 0817ea2

Browse files
authored
feat: add Quick Trade button to each crypto token row (#31805)
Each token row in the Explore search results (both "All" and "Crypto" tabs) now shows a circular flash-icon button that opens the existing QuickBuy sheet pre-loaded for that token. Key changes: - Add optional onQuickTrade prop to TrendingTokenRowItem, rendered as a 36 px dark circle with a flash icon (DS Icon, theme-aware bg) - Thread onQuickTrade through TokenSearchRowItem -> SearchFeedRow (tokens feed only, stocks/perps/etc. are unaffected) - ExploreSearchResults: replace empty BottomSheet with TrendingQuickBuy adapter; FullFeedList in ExploreSearchScreen wired up identically - New TrendingQuickBuy UI-layer adapter maps TrendingAsset to QuickBuyTarget and renders QuickBuy.Root, keeping TrendingView isolated from SocialLeaderboard - Analytics: add 'explore_search' to SocialLeaderboardSource and QuickBuySheetSource; fire SOCIAL_QUICK_BUY_SHEET_VIEWED on open with source, caip19, and marketCap; forward analyticsContext to QuickBuy.Root so all downstream events are attributed correctly - Tests: TrendingTokenRowItem button render/press/navigation guards; TrendingQuickBuy target mapping and event firing lifecycle; SearchFeedRow onQuickTrade forwarding and isolation - Locale: add trending.quick_trade = "Quick Buy & Sell" <!-- 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 Quick Trade button to each crypto token row on explore search ## **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/1c39fc2d-a52e-4a39-9fc9-9f626ad44afc ## **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] > **Medium Risk** > Reuses the existing Quick Buy trade path from many Explore entry points; mistakes in asset mapping or analytics gating could mis-attribute trades or affect experiment readouts, though UI is flag-gated. > > **Overview** > Adds an **Explore Quick Buy A/B test** (`assetsASSETS3380AbtestExploreQuickBuy`) so the **treatment** arm can show a **flash Quick Trade control** on eligible **token and stock** rows across Explore (search, Crypto/RWAs tabs, trending/stocks full lists). > > **`TrendingQuickBuy`** is a new adapter that maps `TrendingAsset` → `QuickBuy.Root`, fires **`SOCIAL_QUICK_BUY_SHEET_VIEWED`** when a sheet opens, and passes per-surface **`source`** values (`explore_search`, `explore_crypto`, `explore_trending`, etc.). **`onQuickTrade`** is threaded through list layouts and **`TrendingTokenRowItem`** (press isolated from row navigation). > > Analytics wiring adds **Explore-specific quick-buy sources**, registers the new experiment mapping, extends A/B enrichment with **array-based inject gates** and **`excludeWhenPropertiesMatch`** so **Social AI Quick Buy** attribution does not apply on Explore surfaces. Tests cover row behavior, adapter mapping/events, search row forwarding, and AB config structure. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f520a31. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 11e5ce9 commit 0817ea2

24 files changed

Lines changed: 926 additions & 78 deletions

File tree

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import React, { useCallback } from 'react';
1+
import React, { useCallback, useState } from 'react';
2+
import type { TrendingAsset } from '@metamask/assets-controllers';
3+
import TrendingQuickBuy from '../../components/TrendingQuickBuy/TrendingQuickBuy';
24
import { strings } from '../../../../../../locales/i18n';
35
import {
46
PriceChangeOption,
@@ -8,8 +10,22 @@ import { useRwaTokens } from '../../hooks/useRwaTokens/useRwaTokens';
810
import { useTokenListFilters } from '../../hooks/useTokenListFilters/useTokenListFilters';
911
import TokenListPageLayout from '../../components/TokenListPageLayout/TokenListPageLayout';
1012
import { RWA_NETWORKS_LIST } from '../../utils/trendingNetworksList';
13+
import { useABTest } from '../../../../../hooks/useABTest';
14+
import {
15+
EXPLORE_QUICK_BUY_AB_KEY,
16+
EXPLORE_QUICK_BUY_VARIANTS,
17+
EXPLORE_QUICK_BUY_EXPOSURE_METADATA,
18+
} from '../../../../Views/TrendingView/search/abTestConfig';
1119

1220
const RWATokensFullView = () => {
21+
const [quickTradeToken, setQuickTradeToken] = useState<TrendingAsset | null>(
22+
null,
23+
);
24+
const { variant: quickBuyVariant } = useABTest(
25+
EXPLORE_QUICK_BUY_AB_KEY,
26+
EXPLORE_QUICK_BUY_VARIANTS,
27+
EXPLORE_QUICK_BUY_EXPOSURE_METADATA,
28+
);
1329
const filters = useTokenListFilters({
1430
timeOption: TimeOption.TwentyFourHours,
1531
});
@@ -53,6 +69,16 @@ const RWATokensFullView = () => {
5369
allowedNetworks={RWA_NETWORKS_LIST}
5470
onLoadMore={loadMore}
5571
isLoadingMore={isLoadingMore}
72+
onQuickTrade={
73+
quickBuyVariant.showQuickTradeButton ? setQuickTradeToken : undefined
74+
}
75+
quickBuyNode={
76+
<TrendingQuickBuy
77+
token={quickTradeToken}
78+
onClose={() => setQuickTradeToken(null)}
79+
source="explore_stocks"
80+
/>
81+
}
5682
/>
5783
);
5884
};

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useCallback, useMemo, useState } from 'react';
2+
import TrendingQuickBuy from '../../components/TrendingQuickBuy/TrendingQuickBuy';
23
import { View, RefreshControl } from 'react-native';
34
import { useRoute, type RouteProp } from '@react-navigation/native';
45
import { useTailwind } from '@metamask/design-system-twrnc-preset';
@@ -28,6 +29,12 @@ import { FilterButton } from '../../components/FilterBar/FilterBar';
2829
import TokenListPageLayout from '../../components/TokenListPageLayout/TokenListPageLayout';
2930
import { TRENDING_NETWORKS_LIST } from '../../utils/trendingNetworksList';
3031
import type { Theme } from '../../../../../util/theme/models';
32+
import { useABTest } from '../../../../../hooks/useABTest';
33+
import {
34+
EXPLORE_QUICK_BUY_AB_KEY,
35+
EXPLORE_QUICK_BUY_VARIANTS,
36+
EXPLORE_QUICK_BUY_EXPOSURE_METADATA,
37+
} from '../../../../Views/TrendingView/search/abTestConfig';
3138

3239
export interface TrendingTokensFullViewParams {
3340
initialTimeOption?: TimeOption;
@@ -43,6 +50,7 @@ export interface TrendingTokensDataProps {
4350
theme: Theme;
4451
onLoadMore?: () => void;
4552
isLoadingMore?: boolean;
53+
onQuickTrade?: (token: TrendingAsset) => void;
4654

4755
search: {
4856
searchResults: TrendingAsset[];
@@ -62,6 +70,7 @@ export const TrendingTokensData = (props: TrendingTokensDataProps) => {
6270
theme,
6371
onLoadMore,
6472
isLoadingMore,
73+
onQuickTrade,
6574
} = props;
6675

6776
const tw = useTailwind();
@@ -95,6 +104,7 @@ export const TrendingTokensData = (props: TrendingTokensDataProps) => {
95104
filterContext={filterContext}
96105
onLoadMore={onLoadMore}
97106
isLoadingMore={isLoadingMore}
107+
onQuickTrade={onQuickTrade}
98108
refreshControl={
99109
<RefreshControl
100110
colors={[theme.colors.primary.default]}
@@ -110,6 +120,14 @@ export const TrendingTokensData = (props: TrendingTokensDataProps) => {
110120

111121
const TrendingTokensFullView = () => {
112122
const sessionManager = TrendingFeedSessionManager.getInstance();
123+
const [quickTradeToken, setQuickTradeToken] = useState<TrendingAsset | null>(
124+
null,
125+
);
126+
const { variant: quickBuyVariant } = useABTest(
127+
EXPLORE_QUICK_BUY_AB_KEY,
128+
EXPLORE_QUICK_BUY_VARIANTS,
129+
EXPLORE_QUICK_BUY_EXPOSURE_METADATA,
130+
);
113131
const { params } =
114132
useRoute<
115133
RouteProp<{ TrendingTokensFullView: TrendingTokensFullViewParams }>
@@ -259,6 +277,16 @@ const TrendingTokensFullView = () => {
259277
selectedTime={filters.selectedTimeOption}
260278
/>
261279
}
280+
onQuickTrade={
281+
quickBuyVariant.showQuickTradeButton ? setQuickTradeToken : undefined
282+
}
283+
quickBuyNode={
284+
<TrendingQuickBuy
285+
token={quickTradeToken}
286+
onClose={() => setQuickTradeToken(null)}
287+
source="explore_trending"
288+
/>
289+
}
262290
/>
263291
);
264292
};

app/components/UI/Trending/components/TokenListPageLayout/TokenListPageLayout.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export interface TokenListPageLayoutProps {
4242
onLoadMore?: () => void;
4343
/** Whether a pagination request is in flight. */
4444
isLoadingMore?: boolean;
45+
/** When provided, shows a Quick Trade flash button on each token row. */
46+
onQuickTrade?: (token: TrendingAsset) => void;
47+
/** Overlay node (e.g. TrendingQuickBuy sheet) rendered outside the scroll area. */
48+
quickBuyNode?: React.ReactNode;
4549
}
4650

4751
/**
@@ -65,6 +69,8 @@ const TokenListPageLayout: React.FC<TokenListPageLayoutProps> = ({
6569
extraBottomSheets,
6670
onLoadMore,
6771
isLoadingMore,
72+
onQuickTrade,
73+
quickBuyNode,
6874
}) => {
6975
const tw = useTailwind();
7076
const theme = useAppThemeFromContext();
@@ -112,6 +118,7 @@ const TokenListPageLayout: React.FC<TokenListPageLayoutProps> = ({
112118
theme={theme}
113119
onLoadMore={onLoadMore}
114120
isLoadingMore={isLoadingMore}
121+
onQuickTrade={onQuickTrade}
115122
/>
116123

117124
<TrendingTokenNetworkBottomSheet
@@ -129,6 +136,7 @@ const TokenListPageLayout: React.FC<TokenListPageLayoutProps> = ({
129136
sortDirection={filters.priceChangeSortDirection}
130137
/>
131138
{extraBottomSheets}
139+
{quickBuyNode}
132140
</SafeAreaView>
133141
);
134142
};
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import React from 'react';
2+
import { render } from '@testing-library/react-native';
3+
import type { TrendingAsset } from '@metamask/assets-controllers';
4+
import TrendingQuickBuy from './TrendingQuickBuy';
5+
import { MetaMetricsEvents } from '../../../../../core/Analytics';
6+
import { SocialLeaderboardEventProperties } from '../../../../Views/SocialLeaderboard/analytics';
7+
8+
const mockTrack = jest.fn();
9+
jest.mock(
10+
'../../../../Views/SocialLeaderboard/analytics/useSocialLeaderboardAnalytics',
11+
() => ({
12+
useSocialLeaderboardAnalytics: () => ({ track: mockTrack }),
13+
}),
14+
);
15+
16+
const mockQuickBuyRoot = jest.fn();
17+
jest.mock(
18+
'../../../../Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuy',
19+
() => ({
20+
QuickBuy: {
21+
Root: (props: Record<string, unknown>) => {
22+
mockQuickBuyRoot(props);
23+
return null;
24+
},
25+
},
26+
}),
27+
);
28+
29+
jest.mock(
30+
'../../../../Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/features',
31+
() => ({
32+
TOP_TRADERS_QUICK_BUY_FEATURES: { tradeModes: ['buy', 'sell'] },
33+
}),
34+
);
35+
36+
// ─── Helpers ─────────────────────────────────────────────────────────────────
37+
38+
const makeToken = (overrides: Partial<TrendingAsset> = {}): TrendingAsset => ({
39+
assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
40+
name: 'USD Coin',
41+
symbol: 'USDC',
42+
decimals: 6,
43+
price: '1.00',
44+
marketCap: 75_000_000_000,
45+
aggregatedUsdVolume: 900_000_000,
46+
...overrides,
47+
});
48+
49+
describe('TrendingQuickBuy', () => {
50+
beforeEach(() => {
51+
jest.clearAllMocks();
52+
});
53+
54+
it('renders QuickBuy.Root with isVisible=false when token is null', () => {
55+
render(<TrendingQuickBuy token={null} onClose={jest.fn()} />);
56+
57+
expect(mockQuickBuyRoot).toHaveBeenCalledWith(
58+
expect.objectContaining({ isVisible: false }),
59+
);
60+
});
61+
62+
it('renders QuickBuy.Root with isVisible=true when token is provided', () => {
63+
render(<TrendingQuickBuy token={makeToken()} onClose={jest.fn()} />);
64+
65+
expect(mockQuickBuyRoot).toHaveBeenCalledWith(
66+
expect.objectContaining({ isVisible: true }),
67+
);
68+
});
69+
70+
it('maps an ERC-20 TrendingAsset to the correct QuickBuyTarget', () => {
71+
const token = makeToken({
72+
assetId: 'eip155:1/erc20:0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f',
73+
symbol: 'SNX',
74+
name: 'Synthetix',
75+
});
76+
77+
render(<TrendingQuickBuy token={token} onClose={jest.fn()} />);
78+
79+
expect(mockQuickBuyRoot).toHaveBeenCalledWith(
80+
expect.objectContaining({
81+
target: {
82+
chain: 'eip155:1',
83+
tokenAddress: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f',
84+
tokenSymbol: 'SNX',
85+
tokenName: 'Synthetix',
86+
},
87+
}),
88+
);
89+
});
90+
91+
it('maps a native (slip44) TrendingAsset to the zero address', () => {
92+
const token = makeToken({
93+
assetId: 'eip155:1/slip44:60',
94+
symbol: 'ETH',
95+
name: 'Ethereum',
96+
});
97+
98+
render(<TrendingQuickBuy token={token} onClose={jest.fn()} />);
99+
100+
expect(mockQuickBuyRoot).toHaveBeenCalledWith(
101+
expect.objectContaining({
102+
target: expect.objectContaining({
103+
tokenAddress: '0x0000000000000000000000000000000000000000',
104+
tokenSymbol: 'ETH',
105+
}),
106+
}),
107+
);
108+
});
109+
110+
it('passes analyticsContext with source=explore_search to QuickBuy.Root', () => {
111+
render(<TrendingQuickBuy token={makeToken()} onClose={jest.fn()} />);
112+
113+
expect(mockQuickBuyRoot).toHaveBeenCalledWith(
114+
expect.objectContaining({
115+
analyticsContext: expect.objectContaining({ source: 'explore_search' }),
116+
}),
117+
);
118+
});
119+
120+
it('fires SOCIAL_QUICK_BUY_SHEET_VIEWED when token transitions from null to non-null', () => {
121+
const token = makeToken();
122+
render(<TrendingQuickBuy token={token} onClose={jest.fn()} />);
123+
124+
expect(mockTrack).toHaveBeenCalledTimes(1);
125+
expect(mockTrack).toHaveBeenCalledWith(
126+
MetaMetricsEvents.SOCIAL_QUICK_BUY_SHEET_VIEWED,
127+
expect.objectContaining({
128+
[SocialLeaderboardEventProperties.SOURCE]: 'explore_search',
129+
[SocialLeaderboardEventProperties.CAIP19]: token.assetId,
130+
[SocialLeaderboardEventProperties.MARKET_CAP]: token.marketCap,
131+
}),
132+
);
133+
});
134+
135+
it('does not fire SOCIAL_QUICK_BUY_SHEET_VIEWED when token is null', () => {
136+
render(<TrendingQuickBuy token={null} onClose={jest.fn()} />);
137+
expect(mockTrack).not.toHaveBeenCalled();
138+
});
139+
140+
it('does not re-fire the event while the same token stays visible', () => {
141+
const token = makeToken();
142+
const { rerender } = render(
143+
<TrendingQuickBuy token={token} onClose={jest.fn()} />,
144+
);
145+
146+
// Simulate a re-render with the same token (e.g. parent re-renders)
147+
rerender(<TrendingQuickBuy token={token} onClose={jest.fn()} />);
148+
149+
expect(mockTrack).toHaveBeenCalledTimes(1);
150+
});
151+
152+
it('fires the event again when a new token is opened after closing', () => {
153+
const tokenA = makeToken({
154+
assetId: 'eip155:1/erc20:0xaaa',
155+
symbol: 'AAA',
156+
name: 'Token A',
157+
});
158+
const tokenB = makeToken({
159+
assetId: 'eip155:1/erc20:0xbbb',
160+
symbol: 'BBB',
161+
name: 'Token B',
162+
});
163+
164+
const { rerender } = render(
165+
<TrendingQuickBuy token={tokenA} onClose={jest.fn()} />,
166+
);
167+
// Close sheet
168+
rerender(<TrendingQuickBuy token={null} onClose={jest.fn()} />);
169+
// Open with a new token
170+
rerender(<TrendingQuickBuy token={tokenB} onClose={jest.fn()} />);
171+
172+
expect(mockTrack).toHaveBeenCalledTimes(2);
173+
expect(mockTrack).toHaveBeenNthCalledWith(
174+
2,
175+
MetaMetricsEvents.SOCIAL_QUICK_BUY_SHEET_VIEWED,
176+
expect.objectContaining({
177+
[SocialLeaderboardEventProperties.CAIP19]: tokenB.assetId,
178+
}),
179+
);
180+
});
181+
182+
it('forwards onClose to QuickBuy.Root', () => {
183+
const onClose = jest.fn();
184+
render(<TrendingQuickBuy token={makeToken()} onClose={onClose} />);
185+
186+
expect(mockQuickBuyRoot).toHaveBeenCalledWith(
187+
expect.objectContaining({ onClose }),
188+
);
189+
});
190+
});

0 commit comments

Comments
 (0)