Skip to content

Commit fafd355

Browse files
authored
feat: Perps Watchlist empty state (MetaMask#31242)
<!-- 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 blocks the PR check. Sections without a directive are checked for structural presence only. --> ## **Description** <!-- mms-check: type=text required=true --> 1. Empty state: When the watchlist is empty, suggests the top 5 markets by 24h volume. Each row shows the asset's live price and 24h change alongside a "+" button to add to the watchlist. 2. Expand/collapse: When the watchlist has more than 3 markets, only the first 3 are shown with a "Show X more" toggle. A "Show less" button collapses it back. 3. Watchlist filter badge: A star badge appears at the start of the market list filter row (before Crypto/Stocks/etc.) when the user has watchlist markets. Tapping it filters the list to watchlisted assets only. Mutually exclusive with category filters. When empty, we show the same default state as on perps home 4. Watchlist header chevron: The "Watchlist" section header on the home screen now has an arrow that deep-links into the full market list with the star filter pre-selected. Hidden when the watchlist is empty. ## **Changelog** <!-- mms-check: type=changelog required=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 new empty state for Perps Watchlist ## **Related issues** <!-- mms-check: type=issue-link required=true --> Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2725 ## **Manual testing steps** <!-- mms-check: type=manual-testing required=true --> ```gherkin Feature: Perps market list filter mutual exclusivity Scenario: Watchlist deep link clears active category filter Given the market list screen is already mounted And a category filter (e.g. "Stocks") is active When the user navigates from the home watchlist header with showWatchlistOnly Then the star (watchlist) filter is turned on And the category filter is reset to "All" And all watchlisted markets are visible regardless of category Scenario: Selecting a category deactivates the watchlist filter Given the watchlist filter is active When the user taps a category badge (e.g. "Crypto") Then the category filter is applied And the watchlist filter is turned off And all markets in that category are shown Scenario: Activating the watchlist filter clears an active category Given a category filter is active When the user taps the star badge to enable the watchlist filter Then the watchlist filter is turned on And the category filter is reset to "All" Feature: Category badge screen reader labels Scenario: Screen reader announces correct category name Given a category badge is displayed (e.g. "Forex") When a screen reader focuses the badge Then it announces "Forex" And not a raw i18n key or empty string ``` ## **Screenshots/Recordings** <!-- mms-check: type=screenshot required=true --> https://github.com/user-attachments/assets/00278952-9e7e-478a-a84f-3afec948bdf3 ## **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. --> - [x] 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). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] 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) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] 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 - [x] 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** > Touches watchlist persistence paths and navigation params across several Perps screens; behavior is well covered by tests but scope is broad for a user-facing feature. > > **Overview** > Adds a **Perps watchlist empty state and discovery flow** across home, market list, wallet tab, and market details. > > **Watchlist UI:** `PerpsWatchlistMarkets` now accepts **suggested markets** (top volume via `getSuggestedWatchlistMarkets`), shows an empty subtitle with **+ add** rows, **show more/less** when more than three saved markets, and an optional **header chevron** (`onSeeAllPress`) to open the full list with watchlist-only mode. Suggested rows hide when the list hits **`WATCHLIST_LIMIT` (10)**. > > **Market list:** A **star filter badge** on the category row toggles watchlist-only view, **mutually exclusive** with category filters (synced in `usePerpsMarketListView` and list handlers). Empty watchlist filter reuses `PerpsWatchlistMarkets` instead of the old favorites empty copy; sort row hides while the star filter is active. > > **Actions & limits:** New **`usePerpsWatchlistActions`** centralizes add/remove with analytics and toasts; **`PerpsMarketRowItem`** gains optional **`onAddPress`**. Market details blocks favoriting at the cap with a limit toast. > > **Wallet Perps tab:** **`PerpsTabView`** (with styles/tests) shows watchlist + explore markets when there are no positions/orders, wired through **`usePerpsTabExploreData`**. > > **Supporting:** New i18n strings, test IDs, trace name `PerpsTabView`, category badge **icon mode** + required **`accessibilityLabel`**. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fe82a1e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent b470975 commit fafd355

34 files changed

Lines changed: 2933 additions & 427 deletions

app/components/UI/Perps/Perps.testIds.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,24 @@ export const PerpsMarketListViewSelectorsIDs = {
148148
MARKET_LIST: 'perps-market-list',
149149
SORT_FILTERS: 'perps-market-list-sort-filters',
150150
WATCHLIST_TOGGLE: 'perps-market-list-watchlist-toggle',
151+
/** Star badge in the category row that filters to watchlisted markets */
152+
WATCHLIST_FILTER_BADGE: 'perps-market-list-sort-filters-categories-watchlist',
151153
};
152154

155+
// ========================================
156+
// PERPS WATCHLIST SECTION SELECTORS
157+
// ========================================
158+
159+
export const PerpsWatchlistSelectorsIDs = {
160+
SECTION: 'perps-watchlist-section',
161+
HEADER: 'perps-watchlist-header',
162+
EMPTY_STATE: 'perps-watchlist-empty-state',
163+
SHOW_MORE_BUTTON: 'perps-watchlist-show-more-button',
164+
SHOW_LESS_BUTTON: 'perps-watchlist-show-less-button',
165+
SUGGESTED_SECTION: 'perps-watchlist-suggested-section',
166+
SUGGESTED_HEADER: 'perps-watchlist-suggested-header',
167+
} as const;
168+
153169
// ========================================
154170
// PERPS MARKET ROW ITEM SELECTORS
155171
// ========================================
@@ -166,6 +182,8 @@ export const getPerpsMarketRowItemSelector = {
166182
`${PerpsMarketRowItemSelectorsIDs.ROW_ITEM}-${symbol}-token-logo`,
167183
badge: (symbol: string) =>
168184
`${PerpsMarketRowItemSelectorsIDs.ROW_ITEM}-${symbol}-badge`,
185+
addButton: (symbol: string) =>
186+
`${PerpsMarketRowItemSelectorsIDs.ROW_ITEM}-${symbol}-add-button`,
169187
};
170188

171189
// ========================================
@@ -238,6 +256,13 @@ export const PerpsHomeViewSelectorsIDs = {
238256
TAB_BAR_ACTIVITY: 'tab-bar-item-activity',
239257
};
240258

259+
export const PerpsTabViewSelectorsIDs = {
260+
START_NEW_TRADE_CTA: 'perps-tab-view-start-new-trade-cta',
261+
GEO_BLOCK_BOTTOM_SHEET_TOOLTIP:
262+
'perps-tab-view-geo-block-bottom-sheet-tooltip',
263+
SCROLL_VIEW: 'perps-tab-scroll-view',
264+
} as const;
265+
241266
export const PerpsPositionsViewSelectorsIDs = {
242267
REFRESH_CONTROL: 'refresh-control',
243268
BACK_BUTTON: 'back-button',

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ const PerpsHomeView = ({
243243
positions,
244244
orders,
245245
watchlistMarkets,
246+
suggestedWatchlistMarkets,
246247
perpsMarkets, // Crypto markets (renamed from trendingMarkets)
247248
commoditiesMarkets, // Commodity markets
248249
stocksMarkets, // Equity markets only
@@ -743,11 +744,21 @@ const PerpsHomeView = ({
743744
{/* Watchlist Section */}
744745
<PerpsWatchlistMarkets
745746
markets={watchlistMarkets}
747+
suggestedMarkets={suggestedWatchlistMarkets}
746748
isLoading={isLoading.markets}
747749
positions={positions}
748750
orders={orders}
749751
source={PERPS_EVENT_VALUE.SOURCE.PERPS_HOME}
750752
transactionActiveAbTests={transactionActiveAbTests}
753+
onSeeAllPress={
754+
watchlistMarkets.length > 0
755+
? () =>
756+
perpsNavigation.navigateToMarketList({
757+
showWatchlistOnly: true,
758+
source: PERPS_EVENT_VALUE.SOURCE.PERPS_HOME,
759+
})
760+
: undefined
761+
}
751762
/>
752763

753764
{/* Products Section - Category pills grid */}

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement';
116116
import { usePerpsOICap } from '../../hooks/usePerpsOICap';
117117
import { usePerpsTPSLUpdate } from '../../hooks/usePerpsTPSLUpdate';
118118
import { useStopLossPrompt } from '../../hooks/useStopLossPrompt';
119+
import usePerpsToasts from '../../hooks/usePerpsToasts';
120+
import { WATCHLIST_LIMIT } from '../../utils/marketUtils';
119121
import { selectPerpsChartPreferredCandlePeriod } from '../../selectors/chartPreferences';
120122
import {
121123
selectPerpsButtonColorTestVariant,
@@ -200,6 +202,7 @@ const PerpsMarketDetailsView: React.FC<PerpsMarketDetailsViewProps> = () => {
200202
transactionActiveAbTests,
201203
} = route.params || {};
202204
const { track } = usePerpsEventTracking();
205+
const { showToast, PerpsToastOptions } = usePerpsToasts();
203206

204207
// Get full market data from stream to ensure all fields (including maxLeverage) are available
205208
// This handles cases where navigation passes minimal market data (e.g., from Recent Activity)
@@ -674,12 +677,23 @@ const PerpsMarketDetailsView: React.FC<PerpsMarketDetailsViewProps> = () => {
674677
const handleWatchlistPress = useCallback(() => {
675678
if (!market?.symbol) return;
676679

680+
const controller = Engine.context.PerpsController;
681+
const isAdding = !isWatchlist;
682+
683+
// Guard: block adding when the watchlist is already full
684+
if (
685+
isAdding &&
686+
controller.getWatchlistMarkets().length >= WATCHLIST_LIMIT
687+
) {
688+
showToast(PerpsToastOptions.watchlist.limitReached);
689+
return;
690+
}
691+
677692
// Optimistic update - instant UI feedback
678-
const newWatchlistState = !isWatchlist;
693+
const newWatchlistState = isAdding;
679694
setOptimisticWatchlist(newWatchlistState);
680695

681696
// Actual state update
682-
const controller = Engine.context.PerpsController;
683697
controller.toggleWatchlistMarket(market.symbol);
684698

685699
// Track watchlist toggle event
@@ -695,7 +709,7 @@ const PerpsMarketDetailsView: React.FC<PerpsMarketDetailsViewProps> = () => {
695709
[PERPS_EVENT_PROPERTY.SOURCE]: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN,
696710
[PERPS_EVENT_PROPERTY.FAVORITES_COUNT]: watchlistCount,
697711
});
698-
}, [market, isWatchlist, track]);
712+
}, [market, isWatchlist, track, showToast, PerpsToastOptions]);
699713

700714
const handleCategorySearchPress = useCallback(() => {
701715
if (!market) return;

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

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import PerpsMarketBalanceActions from '../../components/PerpsMarketBalanceAction
1616
import PerpsMarketSortFieldBottomSheet from '../../components/PerpsMarketSortFieldBottomSheet';
1717
import PerpsMarketFiltersBar from './components/PerpsMarketFiltersBar';
1818
import PerpsMarketList from '../../components/PerpsMarketList';
19+
import PerpsWatchlistMarkets from '../../components/PerpsWatchlistMarkets/PerpsWatchlistMarkets';
1920
import {
2021
usePerpsMarketListView,
2122
usePerpsMeasurement,
@@ -92,7 +93,13 @@ const PerpsMarketListView = ({
9293
const { selectedOptionId, sortBy, direction, handleOptionChange } = sortState;
9394

9495
// Destructure favorites state for easier access
95-
const { showFavoritesOnly } = favoritesState;
96+
const {
97+
showFavoritesOnly,
98+
setShowFavoritesOnly,
99+
hasWatchlistMarkets,
100+
watchlistMarketObjects,
101+
suggestedMarkets,
102+
} = favoritesState;
96103

97104
// Destructure market type filter state
98105
const { marketTypeFilter, setMarketTypeFilter } = marketTypeFilterState;
@@ -115,7 +122,7 @@ const PerpsMarketListView = ({
115122

116123
const { track } = usePerpsEventTracking();
117124

118-
// Handle category badge selection
125+
// Handle category badge selection — clears watchlist filter (mutual exclusivity)
119126
const handleCategorySelect = useCallback(
120127
(category: MarketTypeFilter) => {
121128
if (category !== 'all') {
@@ -128,10 +135,23 @@ const PerpsMarketListView = ({
128135
});
129136
}
130137
setMarketTypeFilter(category);
138+
// Deactivate the watchlist filter whenever a category badge is activated
139+
if (category !== 'all') {
140+
setShowFavoritesOnly(false);
141+
}
131142
},
132-
[setMarketTypeFilter, track],
143+
[setMarketTypeFilter, setShowFavoritesOnly, track],
133144
);
134145

146+
// Toggle watchlist-only filter — clears category filter (mutual exclusivity)
147+
const handleWatchlistToggle = useCallback(() => {
148+
const willActivate = !showFavoritesOnly;
149+
setShowFavoritesOnly(willActivate);
150+
if (willActivate) {
151+
setMarketTypeFilter('all');
152+
}
153+
}, [showFavoritesOnly, setShowFavoritesOnly, setMarketTypeFilter]);
154+
135155
useEffect(() => {
136156
if (filteredMarkets.length > 0) {
137157
Animated.timing(fadeAnimation, {
@@ -221,31 +241,14 @@ const PerpsMarketListView = ({
221241
);
222242
}
223243

224-
// Empty favorites results - show when favorites filter is active but no favorites found
225-
if (showFavoritesOnly && filteredMarkets.length === 0) {
244+
// Empty watchlist — show suggested markets with the same default state as PerpsHome
245+
if (showFavoritesOnly && !hasWatchlistMarkets) {
226246
return (
227-
<View style={styles.emptyStateContainer}>
228-
<Icon
229-
name={IconName.Star}
230-
size={IconSize.Xl}
231-
color={theme.colors.icon.muted}
232-
style={styles.emptyStateIcon}
233-
/>
234-
<Text
235-
variant={TextVariant.HeadingSM}
236-
color={TextColor.Default}
237-
style={styles.emptyStateTitle}
238-
>
239-
{strings('perps.no_favorites_found')}
240-
</Text>
241-
<Text
242-
variant={TextVariant.BodyMD}
243-
color={TextColor.Alternative}
244-
style={styles.emptyStateDescription}
245-
>
246-
{strings('perps.no_favorites_description')}
247-
</Text>
248-
</View>
247+
<PerpsWatchlistMarkets
248+
markets={watchlistMarketObjects}
249+
suggestedMarkets={suggestedMarkets}
250+
showHeader={false}
251+
/>
249252
);
250253
}
251254

@@ -335,6 +338,9 @@ const PerpsMarketListView = ({
335338
onSortPress={() => setIsSortFieldSheetVisible(true)}
336339
marketTypeFilter={marketTypeFilter}
337340
onCategorySelect={handleCategorySelect}
341+
showWatchlistBadge
342+
isWatchlistSelected={showFavoritesOnly}
343+
onWatchlistToggle={handleWatchlistToggle}
338344
testID={PerpsMarketListViewSelectorsIDs.SORT_FILTERS}
339345
/>
340346
)}

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,16 +94,14 @@ describe('PerpsMarketListView', () => {
9494
});
9595
});
9696

97-
it('shows empty favorites state when view starts in watchlist-only mode with no favorites', async () => {
97+
it('shows empty watchlist state when view starts in watchlist-only mode with no favorites', async () => {
9898
renderPerpsMarketListView({
9999
initialParams: { showWatchlistOnly: true },
100+
streamOverrides: { marketData: marketDataWithCategories },
100101
});
101102

102103
expect(
103-
await screen.findByText(strings('perps.no_favorites_found')),
104-
).toBeOnTheScreen();
105-
expect(
106-
screen.getByText(strings('perps.no_favorites_description')),
104+
await screen.findByText(strings('perps.watchlist.empty_subtitle')),
107105
).toBeOnTheScreen();
108106
});
109107
});

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

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,27 +28,35 @@ const PerpsMarketFiltersBar: React.FC<PerpsMarketFiltersBarProps> = ({
2828
onSortPress,
2929
marketTypeFilter,
3030
onCategorySelect,
31+
showWatchlistBadge,
32+
isWatchlistSelected,
33+
onWatchlistToggle,
3134
testID,
3235
}) => {
3336
const { styles } = useStyles(styleSheet, {});
3437

3538
return (
3639
<View style={styles.container} testID={testID}>
37-
{/* Row 1: Category Badges */}
40+
{/* Row 1: Category Badges (+ optional watchlist star badge) */}
3841
<PerpsMarketCategoryBadges
3942
selectedCategory={marketTypeFilter}
4043
onCategorySelect={onCategorySelect}
44+
showWatchlistBadge={showWatchlistBadge}
45+
isWatchlistSelected={isWatchlistSelected}
46+
onWatchlistToggle={onWatchlistToggle}
4147
testID={testID ? `${testID}-categories` : undefined}
4248
/>
4349

44-
{/* Row 2: Sort Dropdown */}
45-
<View style={styles.sortRow}>
46-
<PerpsMarketSortDropdowns
47-
selectedOptionId={selectedOptionId}
48-
onSortPress={onSortPress}
49-
testID={testID ? `${testID}-sort` : undefined}
50-
/>
51-
</View>
50+
{/* Row 2: Sort Dropdown — hidden when watchlist filter is active */}
51+
{!isWatchlistSelected && (
52+
<View style={styles.sortRow}>
53+
<PerpsMarketSortDropdowns
54+
selectedOptionId={selectedOptionId}
55+
onSortPress={onSortPress}
56+
testID={testID ? `${testID}-sort` : undefined}
57+
/>
58+
</View>
59+
)}
5260
</View>
5361
);
5462
};

app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@ export interface PerpsMarketFiltersBarProps {
2727
*/
2828
onCategorySelect: (category: MarketTypeFilter) => void;
2929

30+
/**
31+
* Whether to show the watchlist (star) filter badge.
32+
*/
33+
showWatchlistBadge?: boolean;
34+
35+
/**
36+
* Whether the watchlist filter badge is currently active
37+
*/
38+
isWatchlistSelected?: boolean;
39+
40+
/**
41+
* Callback when the watchlist badge is pressed
42+
*/
43+
onWatchlistToggle?: () => void;
44+
3045
/**
3146
* Optional test ID for testing
3247
*/

0 commit comments

Comments
 (0)