Skip to content

Commit 067dcbd

Browse files
authored
chore: improve crypto movers section in explore (MetaMask#30809)
<!-- 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** Improve crypto movers section in explore: - Show 3 rows of pills instead of 2 - Based on 1h price change instead of 24h <!-- 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: improve crypto movers section in explore ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3297 ## **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] > **Low Risk** > UI and trending feed/sort wiring only; no auth, payments, or security-sensitive paths. > > **Overview** > **Explore Crypto Movers** now uses **1h** price change (not 24h), shows pills in **three rows** with up to **18** items, and **View all** opens trending tokens full view with `initialTimeOption: 1h` so filters and API sort (`h1_trending`) match the section. > > **PillScrollList** and **SectionPillsSkeleton** support a configurable **`rowCount`** (default still 2). **`useTokensFeed`** / **`useTrendingSearch`** accept **`timeOption`** for trending sort and local price-change sorting; **`CryptoMoversPillItem`** reads percent change for the selected interval. Navigation types add **`TrendingTokensFullViewParams`**. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 0dc4baf. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent c6cc0b9 commit 067dcbd

14 files changed

Lines changed: 330 additions & 39 deletions

File tree

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider';
1111
import TrendingTokensFullView, {
1212
TrendingTokensData,
1313
TrendingTokensDataProps,
14+
TrendingTokensFullViewParams,
1415
} from './TrendingTokensFullView';
1516
import type { TrendingAsset } from '@metamask/assets-controllers';
1617
import { useTrendingSearch } from '../../hooks/useTrendingSearch/useTrendingSearch';
@@ -40,12 +41,17 @@ const initialMetrics: Metrics = {
4041

4142
const mockNavigate = jest.fn();
4243
const mockGoBack = jest.fn();
44+
const mockUseRoute = jest.fn<
45+
{ params: TrendingTokensFullViewParams | undefined },
46+
[]
47+
>(() => ({ params: undefined }));
4348

4449
jest.mock('@react-navigation/native', () => ({
4550
useNavigation: () => ({
4651
navigate: mockNavigate,
4752
goBack: mockGoBack,
4853
}),
54+
useRoute: () => mockUseRoute(),
4955
createNavigatorFactory: () => ({}),
5056
}));
5157

@@ -244,6 +250,7 @@ describe('TrendingTokensFullView', () => {
244250

245251
beforeEach(() => {
246252
jest.clearAllMocks();
253+
mockUseRoute.mockReturnValue({ params: undefined });
247254
const mocks = arrangeMocks();
248255
mocks.setTrendingRequestMock({ results: [createMockToken()] });
249256
mocks.setTrendingSearchMock({ data: [createMockToken()] });
@@ -333,6 +340,21 @@ describe('TrendingTokensFullView', () => {
333340
});
334341
});
335342

343+
it('applies initial time option from route params', () => {
344+
mockUseRoute.mockReturnValue({
345+
params: { initialTimeOption: TimeOption.OneHour },
346+
});
347+
348+
const { getByTestId } = renderTrendingFullView();
349+
350+
expect(getByTestId('24h-button')).toHaveTextContent('1h');
351+
expect(mockUseTrendingSearch).toHaveBeenCalledWith({
352+
sortBy: 'h1_trending',
353+
chainIds: null,
354+
searchQuery: undefined,
355+
});
356+
});
357+
336358
it('calls refetch when pull-to-refresh is triggered', () => {
337359
const mockTokens = [
338360
createMockToken({ name: 'Token 1', assetId: 'eip155:1/erc20:0x123' }),

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useCallback, useMemo, useState } from 'react';
22
import { View, TouchableOpacity, RefreshControl } from 'react-native';
3+
import { useRoute, type RouteProp } from '@react-navigation/native';
34
import { useTailwind } from '@metamask/design-system-twrnc-preset';
45
import { strings } from '../../../../../../locales/i18n';
56
import TrendingTokensList, {
@@ -21,6 +22,7 @@ import {
2122
} from '@metamask/design-system-react-native';
2223
import {
2324
TrendingTokenTimeBottomSheet,
25+
mapTimeOptionToSortBy,
2426
PriceChangeOption,
2527
TimeOption,
2628
} from '../../components/TrendingTokensBottomSheet';
@@ -35,6 +37,10 @@ import TokenListPageLayout from '../../components/TokenListPageLayout/TokenListP
3537
import { TRENDING_NETWORKS_LIST } from '../../utils/trendingNetworksList';
3638
import type { Theme } from '../../../../../util/theme/models';
3739

40+
export interface TrendingTokensFullViewParams {
41+
initialTimeOption?: TimeOption;
42+
}
43+
3844
export interface TrendingTokensDataProps {
3945
isLoading: boolean;
4046
refreshing: boolean;
@@ -113,9 +119,16 @@ export const TrendingTokensData = (props: TrendingTokensDataProps) => {
113119
const TrendingTokensFullView = () => {
114120
const tw = useTailwind();
115121
const sessionManager = TrendingFeedSessionManager.getInstance();
116-
const filters = useTokenListFilters();
122+
const { params } =
123+
useRoute<
124+
RouteProp<{ TrendingTokensFullView: TrendingTokensFullViewParams }>
125+
>();
126+
const initialTimeOption = params?.initialTimeOption;
127+
const filters = useTokenListFilters({ timeOption: initialTimeOption });
117128

118-
const [sortBy, setSortBy] = useState<SortTrendingBy | undefined>(undefined);
129+
const [sortBy, setSortBy] = useState<SortTrendingBy | undefined>(
130+
initialTimeOption ? mapTimeOptionToSortBy(initialTimeOption) : undefined,
131+
);
119132
const [showTimeBottomSheet, setShowTimeBottomSheet] = useState(false);
120133

121134
const {

app/components/UI/Trending/components/PillScrollList/PillScrollList.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,26 @@ describe('PillScrollList', () => {
6060
expect(renderItem).toHaveBeenCalledTimes(3);
6161
});
6262

63+
it('supports a custom row count', () => {
64+
const { getByTestId } = render(
65+
<PillScrollList
66+
data={[{ id: 'a' }, { id: 'b' }, { id: 'c' }]}
67+
isLoading={false}
68+
rowCount={3}
69+
renderItem={(item: { id: string }, index: number) => (
70+
<Text testID={`pill-${item.id}`}>{String(index)}</Text>
71+
)}
72+
keyExtractor={(item: { id: string }) => item.id}
73+
Skeleton={Skeleton}
74+
listTestId="pills-list"
75+
/>,
76+
);
77+
78+
expect(getByTestId('pills-list-row-0')).toBeTruthy();
79+
expect(getByTestId('pills-list-row-1')).toBeTruthy();
80+
expect(getByTestId('pills-list-row-2')).toBeTruthy();
81+
});
82+
6383
it('respects maxPills when slicing data before splitting', () => {
6484
const { getByTestId, queryByTestId } = render(
6585
<PillScrollList

app/components/UI/Trending/components/PillScrollList/PillScrollList.tsx

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,48 @@ import {
88
import { useTailwind } from '@metamask/design-system-twrnc-preset';
99

1010
const DEFAULT_MAX_PILLS = 12;
11+
const DEFAULT_ROW_COUNT = 2;
1112

12-
function splitIntoTwoRows<T>(items: T[]): [T[], T[]] {
13-
if (items.length === 0) return [[], []];
14-
const mid = Math.ceil(items.length / 2);
15-
return [items.slice(0, mid), items.slice(mid)];
13+
interface PillRow<T> {
14+
items: T[];
15+
startIndex: number;
16+
}
17+
18+
const normalizeRowCount = (rowCount: number) =>
19+
Math.max(1, Math.floor(rowCount));
20+
21+
function splitIntoRows<T>(items: T[], rowCount: number): PillRow<T>[] {
22+
if (items.length === 0) return [];
23+
24+
const rows: PillRow<T>[] = [];
25+
let start = 0;
26+
27+
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
28+
const remainingItems = items.length - start;
29+
const remainingRows = rowCount - rowIndex;
30+
const rowSize = Math.ceil(remainingItems / remainingRows);
31+
const row = items.slice(start, start + rowSize);
32+
33+
if (row.length > 0) {
34+
rows.push({ items: row, startIndex: start });
35+
}
36+
37+
start += rowSize;
38+
}
39+
40+
return rows;
1641
}
1742

1843
export interface PillScrollListProps<T> {
1944
data: T[];
2045
isLoading: boolean;
2146
renderItem: (item: T, index: number) => React.ReactNode;
2247
keyExtractor: (item: T) => string;
23-
Skeleton: React.ComponentType;
48+
Skeleton: React.ComponentType<{ rowCount?: number }>;
2449
/** @default 12 */
2550
maxPills?: number;
51+
/** @default 2 */
52+
rowCount?: number;
2653
listTestId?: string;
2754
/**
2855
* Outer wrapper Tailwind classes. Defaults to Explore spacing (`mt-3 mb-9`).
@@ -33,8 +60,8 @@ export interface PillScrollListProps<T> {
3360
}
3461

3562
/**
36-
* Two-row horizontal scroll of pill-shaped items. Used for "crypto movers".
37-
* Splits incoming data evenly between the two rows.
63+
* Multi-row horizontal scroll of pill-shaped items. Used for compact movers sections.
64+
* Splits incoming data evenly between rows.
3865
*/
3966
const DEFAULT_WRAPPER_TW = '-mx-4 bg-transparent mt-3 mb-9' as const;
4067

@@ -45,14 +72,16 @@ function PillScrollList<T>({
4572
keyExtractor,
4673
Skeleton,
4774
maxPills = DEFAULT_MAX_PILLS,
75+
rowCount = DEFAULT_ROW_COUNT,
4876
listTestId,
4977
wrapperTwClassName = DEFAULT_WRAPPER_TW,
5078
}: PillScrollListProps<T>) {
5179
const tw = useTailwind();
5280
const displayData = useMemo(() => data.slice(0, maxPills), [data, maxPills]);
53-
const [row1, row2] = useMemo(
54-
() => splitIntoTwoRows(displayData),
55-
[displayData],
81+
const normalizedRowCount = normalizeRowCount(rowCount);
82+
const rows = useMemo(
83+
() => splitIntoRows(displayData, normalizedRowCount),
84+
[displayData, normalizedRowCount],
5685
);
5786

5887
const renderRow = (items: T[], startIndex: number) =>
@@ -66,10 +95,10 @@ function PillScrollList<T>({
6695
<Box twClassName={wrapperTwClassName}>
6796
{isLoading && (
6897
<Box twClassName="px-4">
69-
<Skeleton />
98+
<Skeleton rowCount={normalizedRowCount} />
7099
</Box>
71100
)}
72-
{!isLoading && (row1.length > 0 || row2.length > 0) && (
101+
{!isLoading && rows.length > 0 && (
73102
<ScrollView
74103
horizontal
75104
showsHorizontalScrollIndicator={false}
@@ -79,24 +108,19 @@ function PillScrollList<T>({
79108
contentContainerStyle={tw.style('flex-col px-4')}
80109
>
81110
<Box flexDirection={BoxFlexDirection.Column} twClassName="gap-2">
82-
{row1.length > 0 ? (
83-
<Box
84-
flexDirection={BoxFlexDirection.Row}
85-
alignItems={BoxAlignItems.Center}
86-
twClassName="flex-nowrap gap-2"
87-
>
88-
{renderRow(row1, 0)}
89-
</Box>
90-
) : null}
91-
{row2.length > 0 ? (
111+
{rows.map((row, rowIndex) => (
92112
<Box
113+
key={rowIndex}
93114
flexDirection={BoxFlexDirection.Row}
94115
alignItems={BoxAlignItems.Center}
95116
twClassName="flex-nowrap gap-2"
117+
testID={
118+
listTestId ? `${listTestId}-row-${rowIndex}` : undefined
119+
}
96120
>
97-
{renderRow(row2, row1.length)}
121+
{renderRow(row.items, row.startIndex)}
98122
</Box>
99-
) : null}
123+
))}
100124
</Box>
101125
</ScrollView>
102126
)}

app/components/UI/Trending/components/SectionPillsSkeleton/SectionPillsSkeleton.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,15 @@ const SkeletonRow: React.FC<{ prefix: string }> = ({ prefix }) => (
3838
</Box>
3939
);
4040

41-
const SectionPillsSkeleton: React.FC = () => {
41+
interface SectionPillsSkeletonProps {
42+
rowCount?: number;
43+
}
44+
45+
const SectionPillsSkeleton: React.FC<SectionPillsSkeletonProps> = ({
46+
rowCount = 2,
47+
}) => {
4248
const tw = useTailwind();
49+
const normalizedRowCount = Math.max(1, Math.floor(rowCount));
4350

4451
return (
4552
<Box marginBottom={5} twClassName="bg-transparent">
@@ -50,8 +57,9 @@ const SectionPillsSkeleton: React.FC = () => {
5057
contentContainerStyle={tw.style('flex-col gap-2 pr-0')}
5158
>
5259
<Box flexDirection={BoxFlexDirection.Column} twClassName="gap-2">
53-
<SkeletonRow prefix="r1" />
54-
<SkeletonRow prefix="r2" />
60+
{Array.from({ length: normalizedRowCount }).map((_, rowIndex) => (
61+
<SkeletonRow key={rowIndex} prefix={`r${rowIndex}`} />
62+
))}
5563
</Box>
5664
</ScrollView>
5765
</Box>

app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { sortTrendingTokens } from '../../utils/sortTrendingTokens';
99
import {
1010
PriceChangeOption,
1111
SortDirection,
12+
TimeOption,
1213
} from '../../components/TrendingTokensBottomSheet';
1314

1415
// Mock dependencies
@@ -108,6 +109,7 @@ describe('useTrendingSearch', () => {
108109
mockTrendingResults,
109110
PriceChangeOption.PriceChange,
110111
SortDirection.Descending,
112+
undefined,
111113
);
112114
expect(result.current.isLoading).toBe(false);
113115
});
@@ -133,6 +135,33 @@ describe('useTrendingSearch', () => {
133135
mockTrendingResults,
134136
PriceChangeOption.MarketCap,
135137
SortDirection.Ascending,
138+
undefined,
139+
);
140+
});
141+
142+
it('passes custom time option to sortTrendingTokens', async () => {
143+
const sortedResults = [mockTrendingResults[1], mockTrendingResults[0]];
144+
mockSortTrendingTokens.mockReturnValue(sortedResults);
145+
146+
const { result } = renderHookWithProvider(() =>
147+
useTrendingSearch({
148+
sortTrendingTokensOptions: {
149+
option: PriceChangeOption.PriceChange,
150+
direction: SortDirection.Descending,
151+
timeOption: TimeOption.OneHour,
152+
},
153+
}),
154+
);
155+
156+
await waitFor(() => {
157+
expect(result.current.data).toEqual(sortedResults);
158+
});
159+
160+
expect(mockSortTrendingTokens).toHaveBeenCalledWith(
161+
mockTrendingResults,
162+
PriceChangeOption.PriceChange,
163+
SortDirection.Descending,
164+
TimeOption.OneHour,
136165
);
137166
});
138167

app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { sortTrendingTokens } from '../../utils/sortTrendingTokens';
77
import {
88
PriceChangeOption,
99
SortDirection,
10+
TimeOption,
1011
} from '../../components/TrendingTokensBottomSheet';
1112
import { isEqual } from 'lodash';
1213

@@ -42,6 +43,7 @@ export const useTrendingSearch = (opts?: {
4243
sortTrendingTokensOptions?: {
4344
option: PriceChangeOption;
4445
direction: SortDirection;
46+
timeOption?: TimeOption;
4547
};
4648
}) => {
4749
const {
@@ -99,6 +101,7 @@ export const useTrendingSearch = (opts?: {
99101
trendingResults,
100102
sortTrendingTokensOptions.option,
101103
sortTrendingTokensOptions.direction,
104+
sortTrendingTokensOptions.timeOption,
102105
);
103106
}
104107

0 commit comments

Comments
 (0)