Skip to content

Commit 88a6815

Browse files
authored
chore: trending tokens section (#22400)
## **Description** PR to add trending tokens card. This should be under a feature flag( selector `selectAssetsTrendingTokensEnabled`) ## **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: No functional changes, this is still under a feature flag. ## **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] --> <img width="425" height="858" alt="Screenshot 2025-11-11 at 18 09 58" src="https://github.com/user-attachments/assets/ff394cf2-fbc8-4965-a2d1-e048eb1990ad" /> ## **Pre-merge author checklist** - [ ] 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. ## **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] > Introduces a Trending Tokens UI section integrated into TrendingView with a shared token logo hook, updates the trending fetch hook to default to popular networks, and adds comprehensive tests and i18n. > > - **UI (Trending)**: > - **Trending Tokens Section**: New `TrendingTokensSection` with `TrendingTokensList`, `TrendingTokenRowItem`, `TrendingTokenLogo`, and `TrendingTokensSkeleton`, integrated into `TrendingView` (header tweak, scroll container). > - **Hooks**: > - **New**: `useTokenLogo` for shared image loading/error/background logic; refactors `PerpsTokenLogo` to use it. > - **Updated**: `useTrendingRequest` now accepts optional `chainIds`, defaults to popular networks via `useNetworksByNamespace`/`useNetworksToUse`, sets initial loading to true, and fixes debounce dependencies. > - **Utils**: > - Add formatting helpers `formatCompactUSD` and `formatMarketStats` for market cap/volume. > - **Tests**: > - Add extensive unit tests and snapshots for new components/hooks and updated trending request behavior. > - **Localization**: > - Update `en.json` with `trending.title`, `trending.view_all`, and `trending.tokens` strings. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 49ddd1a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 50ca0df commit 88a6815

File tree

25 files changed

+2752
-138
lines changed

25 files changed

+2752
-138
lines changed

app/components/UI/Assets/hooks/useTrendingRequest/index.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,20 @@ import {
66
SortTrendingBy,
77
} from '@metamask/assets-controllers';
88
import { useStableArray } from '../../../Perps/hooks/useStableArray';
9+
import {
10+
NetworkType,
11+
useNetworksByNamespace,
12+
ProcessedNetwork,
13+
} from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace';
14+
import { useNetworksToUse } from '../../../../hooks/useNetworksToUse/useNetworksToUse';
915
export const DEBOUNCE_WAIT = 500;
1016

1117
/**
1218
* Hook for handling trending tokens request
1319
* @returns {Object} An object containing the trending tokens results, loading state, error, and a function to trigger fetch
1420
*/
1521
export const useTrendingRequest = (options: {
16-
chainIds: CaipChainId[];
22+
chainIds?: CaipChainId[];
1723
sortBy?: SortTrendingBy;
1824
minLiquidity?: number;
1925
minVolume24hUsd?: number;
@@ -22,7 +28,7 @@ export const useTrendingRequest = (options: {
2228
maxMarketCap?: number;
2329
}) => {
2430
const {
25-
chainIds,
31+
chainIds: providedChainIds = [],
2632
sortBy,
2733
minLiquidity,
2834
minVolume24hUsd,
@@ -31,10 +37,30 @@ export const useTrendingRequest = (options: {
3137
maxMarketCap,
3238
} = options;
3339

40+
// Get default networks when chainIds is empty
41+
const { networks } = useNetworksByNamespace({
42+
networkType: NetworkType.Popular,
43+
});
44+
45+
const { networksToUse } = useNetworksToUse({
46+
networks,
47+
networkType: NetworkType.Popular,
48+
});
49+
50+
// Use provided chainIds or default to popular networks
51+
const chainIds = useMemo((): CaipChainId[] => {
52+
if (providedChainIds.length > 0) {
53+
return providedChainIds;
54+
}
55+
return networksToUse.map(
56+
(network: ProcessedNetwork) => network.caipChainId,
57+
);
58+
}, [providedChainIds, networksToUse]);
59+
3460
const [results, setResults] = useState<Awaited<
3561
ReturnType<typeof getTrendingTokens>
3662
> | null>(null);
37-
const [isLoading, setIsLoading] = useState(false);
63+
const [isLoading, setIsLoading] = useState(true);
3864
const [error, setError] = useState<Error | null>(null);
3965

4066
// Track the current request ID to prevent stale results from overwriting current ones
@@ -111,7 +137,7 @@ export const useTrendingRequest = (options: {
111137
debouncedFetchTrendingTokens.cancel();
112138

113139
// If chainIds is empty, don't trigger fetch
114-
if (!memoizedOptions.chainIds.length) {
140+
if (!stableChainIds.length) {
115141
return;
116142
}
117143

@@ -122,7 +148,7 @@ export const useTrendingRequest = (options: {
122148
return () => {
123149
debouncedFetchTrendingTokens.cancel();
124150
};
125-
}, [debouncedFetchTrendingTokens, memoizedOptions.chainIds.length]);
151+
}, [debouncedFetchTrendingTokens, stableChainIds]);
126152

127153
return {
128154
results: results || [],

app/components/UI/Assets/hooks/useTrendingRequest/useTrendingRequest.test.ts

Lines changed: 145 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,76 @@ import { renderHookWithProvider } from '../../../../../util/test/renderWithProvi
33
import { act } from '@testing-library/react-native';
44
// eslint-disable-next-line import/no-namespace
55
import * as assetsControllers from '@metamask/assets-controllers';
6+
import {
7+
ProcessedNetwork,
8+
useNetworksByNamespace,
9+
} from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace';
10+
import { useNetworksToUse } from '../../../../hooks/useNetworksToUse/useNetworksToUse';
11+
12+
// Mock the network hooks
13+
jest.mock(
14+
'../../../../hooks/useNetworksByNamespace/useNetworksByNamespace',
15+
() => ({
16+
useNetworksByNamespace: jest.fn(),
17+
NetworkType: {
18+
Popular: 'popular',
19+
Custom: 'custom',
20+
},
21+
}),
22+
);
23+
24+
jest.mock('../../../../hooks/useNetworksToUse/useNetworksToUse', () => ({
25+
useNetworksToUse: jest.fn(),
26+
}));
27+
28+
const mockUseNetworksByNamespace =
29+
useNetworksByNamespace as jest.MockedFunction<typeof useNetworksByNamespace>;
30+
const mockUseNetworksToUse = useNetworksToUse as jest.MockedFunction<
31+
typeof useNetworksToUse
32+
>;
33+
34+
// Default mock networks
35+
const mockDefaultNetworks: ProcessedNetwork[] = [
36+
{
37+
id: '1',
38+
name: 'Ethereum Mainnet',
39+
caipChainId: 'eip155:1' as const,
40+
isSelected: true,
41+
imageSource: { uri: 'ethereum' },
42+
},
43+
{
44+
id: '137',
45+
name: 'Polygon',
46+
caipChainId: 'eip155:137' as const,
47+
isSelected: true,
48+
imageSource: { uri: 'polygon' },
49+
},
50+
];
651

752
describe('useTrendingRequest', () => {
853
beforeEach(() => {
954
jest.clearAllMocks();
1055
jest.useFakeTimers();
56+
// Set up default mocks for network hooks
57+
mockUseNetworksByNamespace.mockReturnValue({
58+
networks: mockDefaultNetworks,
59+
selectedNetworks: mockDefaultNetworks,
60+
areAllNetworksSelected: true,
61+
areAnyNetworksSelected: true,
62+
networkCount: mockDefaultNetworks.length,
63+
selectedCount: mockDefaultNetworks.length,
64+
});
65+
mockUseNetworksToUse.mockReturnValue({
66+
networksToUse: mockDefaultNetworks,
67+
evmNetworks: mockDefaultNetworks,
68+
solanaNetworks: [],
69+
selectedEvmAccount: null,
70+
selectedSolanaAccount: null,
71+
isMultichainAccountsState2Enabled: false,
72+
areAllNetworksSelectedCombined: true,
73+
areAllEvmNetworksSelected: true,
74+
areAllSolanaNetworksSelected: false,
75+
} as unknown as ReturnType<typeof useNetworksToUse>);
1176
});
1277

1378
it('returns an object with results, isLoading, error, and fetch function', () => {
@@ -195,12 +260,23 @@ describe('useTrendingRequest', () => {
195260
unmount();
196261
});
197262

198-
it('skips fetch when chain ids are empty', async () => {
263+
it('uses default popular networks when chainIds is empty', async () => {
199264
const spyGetTrendingTokens = jest.spyOn(
200265
assetsControllers,
201266
'getTrendingTokens',
202267
);
203-
spyGetTrendingTokens.mockResolvedValue([]);
268+
const mockResults: assetsControllers.TrendingAsset[] = [
269+
{
270+
assetId: 'eip155:1/erc20:0x123',
271+
symbol: 'TOKEN1',
272+
name: 'Token 1',
273+
decimals: 18,
274+
price: '1',
275+
aggregatedUsdVolume: 1,
276+
marketCap: 1,
277+
},
278+
];
279+
spyGetTrendingTokens.mockResolvedValue(mockResults as never);
204280

205281
const { result, unmount } = renderHookWithProvider(() =>
206282
useTrendingRequest({
@@ -213,20 +289,82 @@ describe('useTrendingRequest', () => {
213289
await Promise.resolve();
214290
});
215291

216-
expect(spyGetTrendingTokens).not.toHaveBeenCalled();
217-
expect(result.current.results).toEqual([]);
292+
expect(mockUseNetworksByNamespace).toHaveBeenCalledWith({
293+
networkType: 'popular',
294+
});
295+
expect(mockUseNetworksToUse).toHaveBeenCalledWith({
296+
networks: mockDefaultNetworks,
297+
networkType: 'popular',
298+
});
299+
expect(spyGetTrendingTokens).toHaveBeenCalledWith(
300+
expect.objectContaining({
301+
chainIds: ['eip155:1', 'eip155:137'],
302+
}),
303+
);
304+
expect(result.current.results).toEqual(mockResults);
218305
expect(result.current.isLoading).toBe(false);
219306

307+
spyGetTrendingTokens.mockRestore();
308+
unmount();
309+
});
310+
311+
it('uses default popular networks when chainIds is not provided', async () => {
312+
const spyGetTrendingTokens = jest.spyOn(
313+
assetsControllers,
314+
'getTrendingTokens',
315+
);
316+
const mockResults: assetsControllers.TrendingAsset[] = [];
317+
spyGetTrendingTokens.mockResolvedValue(mockResults as never);
318+
319+
renderHookWithProvider(() => useTrendingRequest({}));
320+
220321
await act(async () => {
221-
await result.current.fetch();
222322
jest.advanceTimersByTime(DEBOUNCE_WAIT);
223323
await Promise.resolve();
224324
});
225325

226-
expect(spyGetTrendingTokens).not.toHaveBeenCalled();
326+
expect(mockUseNetworksByNamespace).toHaveBeenCalledWith({
327+
networkType: 'popular',
328+
});
329+
expect(spyGetTrendingTokens).toHaveBeenCalledWith(
330+
expect.objectContaining({
331+
chainIds: ['eip155:1', 'eip155:137'],
332+
}),
333+
);
334+
335+
spyGetTrendingTokens.mockRestore();
336+
});
337+
338+
it('uses provided chainIds when available instead of default networks', async () => {
339+
const spyGetTrendingTokens = jest.spyOn(
340+
assetsControllers,
341+
'getTrendingTokens',
342+
);
343+
const mockResults: assetsControllers.TrendingAsset[] = [];
344+
spyGetTrendingTokens.mockResolvedValue(mockResults as never);
345+
346+
const customChainIds: `${string}:${string}`[] = [
347+
'eip155:56',
348+
'eip155:42161',
349+
];
350+
renderHookWithProvider(() =>
351+
useTrendingRequest({
352+
chainIds: customChainIds,
353+
}),
354+
);
355+
356+
await act(async () => {
357+
jest.advanceTimersByTime(DEBOUNCE_WAIT);
358+
await Promise.resolve();
359+
});
360+
361+
expect(spyGetTrendingTokens).toHaveBeenCalledWith(
362+
expect.objectContaining({
363+
chainIds: customChainIds,
364+
}),
365+
);
227366

228367
spyGetTrendingTokens.mockRestore();
229-
unmount();
230368
});
231369

232370
it('coalesces multiple rapid calls into a single fetch', async () => {

0 commit comments

Comments
 (0)