Skip to content

Commit eacbd59

Browse files
authored
chore: add gainers and losers section in Explore/Now (MetaMask#30837)
<!-- 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** Add gainers and losers section in Explore/Now <!-- 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: add gainers and losers section in Explore/Now ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3298 ## **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 navigation param changes in Explore/Perps with no auth, payments, or data-persistence logic beyond existing sort preferences. > > **Overview** > **Explore → Now** adds **Gainers / Losers** pills on **Perps movers**, filters markets by sign of 24h price change, and sorts gainers descending / losers ascending via new `filterAndSortByPriceChangeDirection` in `usePerpsFeed`. > > **View all** now opens the perps market list with matching sort: `navigateToPerpsMarketList` takes an options object (`sortDirection`, optional `source`) and passes `defaultSortDirection` through navigation into `usePerpsMarketListView`, so list ordering aligns with the selected pill (not only saved user prefs). Copy added for the new pill labels in `en.json`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 61a03c3. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent fb5a689 commit eacbd59

12 files changed

Lines changed: 358 additions & 29 deletions

File tree

app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,11 @@ describe('PerpsMarketListView', () => {
498498
>;
499499
const { useRoute } = jest.requireMock('@react-navigation/native');
500500
const mockUseRoute = useRoute as jest.MockedFunction<typeof useRoute>;
501+
const { usePerpsMarketListView } = jest.requireMock('../../hooks');
502+
const mockUsePerpsMarketListView =
503+
usePerpsMarketListView as jest.MockedFunction<
504+
typeof usePerpsMarketListView
505+
>;
501506

502507
const mockMarketData: PerpsMarketData[] = [
503508
{
@@ -623,6 +628,26 @@ describe('PerpsMarketListView', () => {
623628
});
624629
});
625630

631+
it('passes default sort params from route to market list hook', () => {
632+
mockUseRoute.mockReturnValue({
633+
key: 'PerpsMarketListView-123',
634+
name: 'PerpsMarketListView',
635+
params: {
636+
defaultSortOptionId: 'priceChange',
637+
defaultSortDirection: 'asc',
638+
},
639+
});
640+
641+
renderWithProvider(<PerpsMarketListView />, { state: mockState });
642+
643+
expect(mockUsePerpsMarketListView).toHaveBeenCalledWith(
644+
expect.objectContaining({
645+
defaultSortOptionId: 'priceChange',
646+
defaultSortDirection: 'asc',
647+
}),
648+
);
649+
});
650+
626651
it('renders interactive elements', async () => {
627652
renderWithProvider(<PerpsMarketListView />, { state: mockState });
628653

@@ -849,12 +874,10 @@ describe('PerpsMarketListView', () => {
849874

850875
describe('Edge Cases', () => {
851876
it('filters markets with whitespace-only query', async () => {
852-
const { usePerpsMarketListView } = jest.requireMock('../../hooks');
853-
854877
mockSearchQuery = ' ';
855878

856879
// Mock to return empty results when search query is whitespace
857-
usePerpsMarketListView.mockReturnValue({
880+
mockUsePerpsMarketListView.mockReturnValue({
858881
markets: mockMarketData, // Whitespace is trimmed, so all markets show
859882
searchState: {
860883
searchQuery: ' ',

app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ const PerpsMarketListView = ({
6868
const defaultMarketTypeFilter =
6969
route.params?.defaultMarketTypeFilter ?? 'all';
7070
const defaultSortOptionId = route.params?.defaultSortOptionId;
71+
const defaultSortDirection = route.params?.defaultSortDirection;
7172
const transactionActiveAbTests = route.params?.transactionActiveAbTests;
7273

7374
const fadeAnimation = useRef(new Animated.Value(0)).current;
@@ -87,6 +88,7 @@ const PerpsMarketListView = ({
8788
showWatchlistOnly,
8889
defaultMarketTypeFilter,
8990
defaultSortOptionId,
91+
defaultSortDirection,
9092
showZeroVolume: __DEV__,
9193
});
9294

app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,29 @@ describe('usePerpsMarketListView', () => {
327327
});
328328
});
329329

330+
it('defaultSortDirection overrides saved direction when provided', () => {
331+
let selectorCallCount = 0;
332+
mockUseSelector.mockImplementation(() => {
333+
selectorCallCount++;
334+
if (selectorCallCount % 2 === 1) {
335+
return ['BTC'];
336+
}
337+
return { optionId: 'priceChange', direction: 'desc' };
338+
});
339+
340+
renderHook(() =>
341+
usePerpsMarketListView({
342+
defaultSortOptionId: 'priceChange',
343+
defaultSortDirection: 'asc',
344+
}),
345+
);
346+
347+
expect(mockUsePerpsSorting).toHaveBeenCalledWith({
348+
initialOptionId: 'priceChange',
349+
initialDirection: 'asc',
350+
});
351+
});
352+
330353
it('preserves saved direction when defaultSortOptionId matches the saved option', () => {
331354
let selectorCallCount = 0;
332355
mockUseSelector.mockImplementation(() => {

app/components/UI/Perps/hooks/usePerpsMarketListView.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ interface UsePerpsMarketListViewParams {
3939
* @default undefined (falls back to saved user preference)
4040
*/
4141
defaultSortOptionId?: SortOptionId;
42+
/**
43+
* Initial sort direction — overrides the persisted user preference when provided.
44+
* @default undefined (falls back to saved user preference/default override behavior)
45+
*/
46+
defaultSortDirection?: SortDirection;
4247
/**
4348
* Show markets with $0.00 volume
4449
* @default false
@@ -140,6 +145,7 @@ export const usePerpsMarketListView = ({
140145
showWatchlistOnly = false,
141146
defaultMarketTypeFilter = 'all',
142147
defaultSortOptionId,
148+
defaultSortDirection,
143149
showZeroVolume = false,
144150
}: UsePerpsMarketListViewParams = {}): UsePerpsMarketListViewReturn => {
145151
// Fetch markets data
@@ -205,18 +211,21 @@ export const usePerpsMarketListView = ({
205211

206212
// Use sorting hook for sort state and sorting logic.
207213
// defaultSortOptionId (from navigation params) takes precedence over the saved user
208-
// preference. When it overrides a *different* option, reset direction to the default
209-
// so the market list opens sorted the same way the explore feed displayed it (always desc).
210-
// When there is no override, or the override matches the saved option, carry the saved direction.
214+
// preference. A route-provided direction also takes precedence so Explore can
215+
// open the market list with the same ordering as the source section.
216+
// Without an explicit direction, reset changed sort options to the default
217+
// direction; otherwise carry the saved direction.
211218
const isOptionOverridden =
212219
defaultSortOptionId !== undefined &&
213220
defaultSortOptionId !== savedSortPreference.optionId;
214221
const sortingHook = usePerpsSorting({
215222
initialOptionId: (defaultSortOptionId ??
216223
savedSortPreference.optionId) as SortOptionId,
217-
initialDirection: isOptionOverridden
218-
? MARKET_SORTING_CONFIG.DefaultDirection
219-
: savedSortPreference.direction,
224+
initialDirection:
225+
defaultSortDirection ??
226+
(isOptionOverridden
227+
? MARKET_SORTING_CONFIG.DefaultDirection
228+
: savedSortPreference.direction),
220229
});
221230

222231
// Wrap handleOptionChange to save preference to PerpsController

app/components/UI/Perps/types/navigation.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type OrderType,
66
type PerpsMarketData,
77
type TPSLTrackingData,
8+
type SortDirection,
89
type SortOptionId,
910
} from '@metamask/perps-controller';
1011
import { PerpsTransaction } from './transactionHistory';
@@ -93,6 +94,7 @@ export interface PerpsNavigationParamList extends ParamListBase {
9394
| 'forex'
9495
| 'new';
9596
defaultSortOptionId?: SortOptionId;
97+
defaultSortDirection?: SortDirection;
9698
fromHome?: boolean;
9799
button_clicked?: string;
98100
button_location?: string;

app/components/Views/TrendingView/feeds/perps/perpsNavigation.test.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,9 @@ describe('navigateToPerpsMarketList', () => {
6464
navigate,
6565
} as unknown as NavigationProp<PerpsNavigationParamList>;
6666

67-
navigateToPerpsMarketList(
68-
navigation,
69-
'all',
70-
'priceChange',
71-
PERPS_EVENT_VALUE.SOURCE.HOME_SECTION,
72-
);
67+
navigateToPerpsMarketList(navigation, 'all', 'priceChange', {
68+
source: PERPS_EVENT_VALUE.SOURCE.HOME_SECTION,
69+
});
7370

7471
expect(navigate).toHaveBeenCalledWith(
7572
Routes.PERPS.ROOT,
@@ -88,12 +85,9 @@ describe('navigateToPerpsMarketList', () => {
8885
navigate,
8986
} as unknown as NavigationProp<PerpsNavigationParamList>;
9087

91-
navigateToPerpsMarketList(
92-
navigation,
93-
'all',
94-
undefined,
95-
PERPS_EVENT_VALUE.SOURCE.HOME_SECTION,
96-
);
88+
navigateToPerpsMarketList(navigation, 'all', undefined, {
89+
source: PERPS_EVENT_VALUE.SOURCE.HOME_SECTION,
90+
});
9791

9892
expect(navigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, {
9993
screen: Routes.PERPS.MARKET_LIST,
@@ -103,4 +97,24 @@ describe('navigateToPerpsMarketList', () => {
10397
},
10498
});
10599
});
100+
101+
it('passes a custom sort direction', () => {
102+
const navigate = jest.fn();
103+
const navigation = {
104+
navigate,
105+
} as unknown as NavigationProp<PerpsNavigationParamList>;
106+
107+
navigateToPerpsMarketList(navigation, 'all', 'priceChange', {
108+
sortDirection: 'asc',
109+
});
110+
111+
expect(navigate).toHaveBeenCalledWith(
112+
Routes.PERPS.ROOT,
113+
expect.objectContaining({
114+
params: expect.objectContaining({
115+
defaultSortDirection: 'asc',
116+
}),
117+
}),
118+
);
119+
});
106120
});
Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NavigationProp } from '@react-navigation/native';
22
import {
33
PERPS_EVENT_VALUE,
4+
type SortDirection,
45
type SortOptionId,
56
} from '@metamask/perps-controller';
67
import type { PerpsNavigationParamList } from '../../../../UI/Perps/types/navigation';
@@ -9,19 +10,30 @@ import Routes from '../../../../../constants/navigation/Routes';
910
type PerpsNavigationSource =
1011
(typeof PERPS_EVENT_VALUE.SOURCE)[keyof typeof PERPS_EVENT_VALUE.SOURCE];
1112

13+
interface NavigateToPerpsMarketListOptions {
14+
source?: PerpsNavigationSource;
15+
sortDirection?: SortDirection;
16+
}
17+
1218
/** Navigate to the perps market list, optionally pre-filtering by market type and pre-sorting by a sort option. */
1319
export const navigateToPerpsMarketList = (
1420
navigation: NavigationProp<PerpsNavigationParamList>,
1521
filter: string = 'all',
1622
sortOptionId?: SortOptionId,
17-
source: PerpsNavigationSource = PERPS_EVENT_VALUE.SOURCE.EXPLORE,
23+
{
24+
source = PERPS_EVENT_VALUE.SOURCE.EXPLORE,
25+
sortDirection,
26+
}: NavigateToPerpsMarketListOptions = {},
1827
): void => {
1928
navigation.navigate(Routes.PERPS.ROOT, {
2029
screen: Routes.PERPS.MARKET_LIST,
2130
params: {
2231
defaultMarketTypeFilter: filter,
2332
source,
2433
...(sortOptionId !== undefined && { defaultSortOptionId: sortOptionId }),
34+
...(sortDirection !== undefined && {
35+
defaultSortDirection: sortDirection,
36+
}),
2537
},
2638
});
2739
};

app/components/Views/TrendingView/feeds/perps/usePerpsFeed.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010

1111
import { renderHook } from '@testing-library/react-hooks';
1212
import type { PerpsMarketData } from '@metamask/perps-controller';
13-
import { usePerpsFeed, PERPS_VARIANT_SORT_OPTION } from './usePerpsFeed';
13+
import {
14+
filterAndSortByPriceChangeDirection,
15+
usePerpsFeed,
16+
PERPS_VARIANT_SORT_OPTION,
17+
} from './usePerpsFeed';
1418

1519
// ---------------------------------------------------------------------------
1620
// Core dependency mocks
@@ -140,6 +144,36 @@ describe('usePerpsFeed', () => {
140144
});
141145
});
142146

147+
describe('price-change mover filtering', () => {
148+
it('filters gainers to positive price changes sorted descending', () => {
149+
const markets = [
150+
makeMarket('LOSER', '-3', 100),
151+
makeMarket('HIGH_GAINER', '5', 50),
152+
makeMarket('LOW_GAINER', '1', 75),
153+
];
154+
155+
expect(
156+
filterAndSortByPriceChangeDirection(markets, 'gainers').map(
157+
(market) => market.symbol,
158+
),
159+
).toEqual(['HIGH_GAINER', 'LOW_GAINER']);
160+
});
161+
162+
it('filters losers to negative price changes sorted ascending', () => {
163+
const markets = [
164+
makeMarket('GAINER', '3', 100),
165+
makeMarket('SMALL_LOSER', '-1', 50),
166+
makeMarket('BIG_LOSER', '-7', 75),
167+
];
168+
169+
expect(
170+
filterAndSortByPriceChangeDirection(markets, 'losers').map(
171+
(market) => market.symbol,
172+
),
173+
).toEqual(['BIG_LOSER', 'SMALL_LOSER']);
174+
});
175+
});
176+
143177
describe('query path', () => {
144178
it('preserves Fuse.js relevance order for non-macro variants', () => {
145179
const markets = [

app/components/Views/TrendingView/feeds/perps/usePerpsFeed.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useSelector } from 'react-redux';
33
import {
44
filterMarketsByQuery,
55
type PerpsMarketData,
6+
type SortDirection,
67
type SortOptionId,
78
} from '@metamask/perps-controller';
89
import { usePerpsMarkets } from '../../../../UI/Perps/hooks';
@@ -22,6 +23,7 @@ export type { PerpsFeedItem } from '../../../../UI/Perps/types/perpsFeedTypes';
2223
const EMPTY_WATCHLIST_SYMBOLS: string[] = [];
2324

2425
export type PerpsVariant = 'all' | 'crypto' | 'rwa' | 'macro';
26+
export type PerpsPriceChangeDirection = 'gainers' | 'losers';
2527

2628
interface UsePerpsFeedOptions {
2729
/** @default 'all' */
@@ -57,6 +59,14 @@ export const PERPS_VARIANT_SORT_OPTION: Record<PerpsVariant, SortOptionId> = {
5759
macro: 'volume',
5860
};
5961

62+
export const PERPS_PRICE_CHANGE_SORT_DIRECTION: Record<
63+
PerpsPriceChangeDirection,
64+
SortDirection
65+
> = {
66+
gainers: 'desc',
67+
losers: 'asc',
68+
};
69+
6070
const sortByVolumeDesc = (a: PerpsMarketData, b: PerpsMarketData) => {
6171
const av = (a as PerpsMarketDataWithVolumeNumber).volumeNumber ?? 0;
6272
const bv = (b as PerpsMarketDataWithVolumeNumber).volumeNumber ?? 0;
@@ -66,6 +76,9 @@ const sortByVolumeDesc = (a: PerpsMarketData, b: PerpsMarketData) => {
6676
const sortByChange24hDesc = (a: PerpsMarketData, b: PerpsMarketData) =>
6777
(parseFloat(b.change24hPercent) || 0) - (parseFloat(a.change24hPercent) || 0);
6878

79+
const sortByChange24hAsc = (a: PerpsMarketData, b: PerpsMarketData) =>
80+
(parseFloat(a.change24hPercent) || 0) - (parseFloat(b.change24hPercent) || 0);
81+
6982
/** Maps each SortOptionId to the comparator used inside the feed. */
7083
const SORT_FNS: Record<
7184
SortOptionId,
@@ -101,6 +114,22 @@ const filterByVariant = (
101114
}
102115
};
103116

117+
export const filterAndSortByPriceChangeDirection = (
118+
markets: PerpsMarketData[],
119+
priceChangeDirection: PerpsPriceChangeDirection,
120+
) => {
121+
switch (priceChangeDirection) {
122+
case 'gainers':
123+
return markets
124+
.filter((market) => parseFloat(market.change24hPercent) > 0)
125+
.sort(sortByChange24hDesc);
126+
case 'losers':
127+
return markets
128+
.filter((market) => parseFloat(market.change24hPercent) < 0)
129+
.sort(sortByChange24hAsc);
130+
}
131+
};
132+
104133
/**
105134
* Perps markets feed. Returns enriched items (market + optional sparkline +
106135
* watchlist flag) so consumers don't have to stitch data themselves.

0 commit comments

Comments
 (0)