Skip to content

Commit 24080df

Browse files
authored
feat: add watchlist star in token details (#32962)
<!-- 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 --> Adds a feature-flag-gated star toggle to the Token Details page header, allowing users to add/remove tokens from their watchlist with a single tap. The star icon shows outlined when not watched and filled when watched. Tapping triggers an optimistic UI update, persists to Authenticated User Storage (AUS), shows a toast notification, and fires analytics events. Also fixes a latent query-key conflict between `useTokenWatchlistMutations` and `useTokenWatchlistQuery` by giving the hydrated query its own dedicated cache key (`['tokenWatchlist', 'hydrated']`), and consolidates the duplicate query-key files into one. Additionally corrects a case-sensitivity issue in git where the `watchlist/` directory was tracked under lowercase `assets/` instead of capital `Assets/`. ## **Changelog** <!-- mms-check: type=changelog required=true blocking=true --> CHANGELOG entry: Added a star icon on the Token Details page to add or remove tokens from the watchlist ## **Related issues** <!-- mms-check: type=issue-link required=true --> Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3118 ## **Manual testing steps** <!-- mms-check: type=manual-testing required=true --> ```gherkin Feature: Watchlist star toggle on Token Details Scenario: user adds a token to watchlist Given the token watchlist feature flag is enabled And the user is on a Token Details page for a token not in their watchlist When user taps the star icon in the header Then the star icon becomes filled And a toast "Added to Watchlist" is shown And the token is persisted to watchlist storage Scenario: user removes a token from watchlist Given the token watchlist feature flag is enabled And the user is on a Token Details page for a token already in their watchlist When user taps the filled star icon in the header Then the star icon becomes outlined And a toast "Removed from Watchlist" is shown And the token is removed from watchlist storage Scenario: star is hidden when feature flag is off Given the token watchlist feature flag is disabled When user navigates to any Token Details page Then no star icon is visible in the header ``` ## **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/62e460ff-6511-459f-90c2-951e237b4d6c ## **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** > Touches persisted user watchlist state, feature-flag gating, and React Query cache keys; misalignment on the renamed flag key could hide the UI in production. > > **Overview** > Adds a **feature-flag-gated star** on Token Details so users can add or remove the current token from the global watchlist. The header shows outline vs filled star, calls a new **`useTokenWatchlist`** hook (blob cache + add/remove mutations), shows add/remove toasts, and emits **`WATCHLIST_TOKEN_ADDED` / `WATCHLIST_TOKEN_REMOVED`** analytics with `source: token_details`. > > Watchlist React Query keys are cleaned up: hydrated list metadata uses **`tokenWatchlistQueryKeys.hydrated`** instead of sharing the raw blob key, mutation **`onSettled`** also invalidates hydrated, and query keys live in **`watchlist-query-keys.ts`** (replacing the old `useTokenWatchlist.keys` file). The remote flag constant is renamed to **`assetsGlobalWatchlistV1`** for LaunchDarkly alignment. > > Test API defaults mock **GET/PUT** for `preferences/assets-watchlist`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 0adf40d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e5e6ae0 commit 24080df

28 files changed

Lines changed: 481 additions & 26 deletions

app/components/UI/Assets/selectors/featureFlags.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe('selectTokenWatchlistEnabled', () => {
3434
});
3535

3636
it('exposes the LaunchDarkly flag key for registry alignment', () => {
37-
expect(ASSET_GLOBAL_WATCHLIST_FLAG_KEY).toBe('assets-global-watchlist-v1');
37+
expect(ASSET_GLOBAL_WATCHLIST_FLAG_KEY).toBe('assetsGlobalWatchlistV1');
3838
});
3939

4040
const testCases: {

app/components/UI/Assets/selectors/featureFlags.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
VersionGatedFeatureFlag,
66
} from '../../../../util/remoteFeatureFlag';
77

8-
export const ASSET_GLOBAL_WATCHLIST_FLAG_KEY = 'assets-global-watchlist-v1';
8+
export const ASSET_GLOBAL_WATCHLIST_FLAG_KEY = 'assetsGlobalWatchlistV1';
99

1010
/**
1111
* Whether the global token watchlist feature is enabled for the current

app/components/UI/assets/watchlist/hooks/index.ts renamed to app/components/UI/Assets/watchlist/hooks/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
export { tokenWatchlistQueryKeys } from './useTokenWatchlist.keys';
1+
export { tokenWatchlistQueryKeys } from './watchlist-query-keys';
2+
export {
3+
useTokenWatchlist,
4+
type UseTokenWatchlistResult,
5+
} from './useTokenWatchlist';
26
export {
37
tokenWatchlistBatcher,
48
useTokenWatchlistAddItemMutation,

app/components/UI/assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.test.ts renamed to app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.test.ts

File renamed without changes.

app/components/UI/assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.ts renamed to app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.ts

File renamed without changes.
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { renderHook, act } from '@testing-library/react-hooks';
2+
import { useQuery, useQueryClient } from '@tanstack/react-query';
3+
import type { CaipAssetType } from '@metamask/utils';
4+
5+
import { useTokenWatchlist } from './useTokenWatchlist';
6+
import {
7+
useTokenWatchlistAddItemMutation,
8+
useTokenWatchlistRemoveItemMutation,
9+
} from './useTokenWatchlistMutations';
10+
import { tokenWatchlistQueryKeys } from './watchlist-query-keys';
11+
12+
jest.mock('@tanstack/react-query', () => ({
13+
...jest.requireActual('@tanstack/react-query'),
14+
useQueryClient: jest.fn(),
15+
useQuery: jest.fn(),
16+
}));
17+
18+
jest.mock('./useTokenWatchlistMutations', () => ({
19+
useTokenWatchlistAddItemMutation: jest.fn(),
20+
useTokenWatchlistRemoveItemMutation: jest.fn(),
21+
}));
22+
23+
const mockGetQueryData = jest.fn();
24+
const mockMutateAdd = jest.fn();
25+
const mockMutateRemove = jest.fn();
26+
27+
const ASSET_ID =
28+
'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType;
29+
30+
describe('useTokenWatchlist', () => {
31+
beforeEach(() => {
32+
jest.clearAllMocks();
33+
(useQueryClient as jest.Mock).mockReturnValue({
34+
getQueryData: mockGetQueryData,
35+
});
36+
(useTokenWatchlistAddItemMutation as jest.Mock).mockReturnValue({
37+
mutate: mockMutateAdd,
38+
isPending: false,
39+
});
40+
(useTokenWatchlistRemoveItemMutation as jest.Mock).mockReturnValue({
41+
mutate: mockMutateRemove,
42+
isPending: false,
43+
});
44+
});
45+
46+
const mockBlobQuery = (assets: string[]) => {
47+
(useQuery as jest.Mock).mockReturnValue({
48+
data: { assets, version: 1 },
49+
});
50+
};
51+
52+
it('returns noop result when assetId is null', () => {
53+
(useQuery as jest.Mock).mockReturnValue({ data: undefined });
54+
55+
const { result } = renderHook(() => useTokenWatchlist(null));
56+
57+
expect(result.current.isWatched).toBe(false);
58+
expect(result.current.isLoading).toBe(false);
59+
60+
act(() => result.current.toggle());
61+
expect(mockMutateAdd).not.toHaveBeenCalled();
62+
expect(mockMutateRemove).not.toHaveBeenCalled();
63+
});
64+
65+
it('returns isWatched=false when blob is empty', () => {
66+
mockBlobQuery([]);
67+
68+
const { result } = renderHook(() => useTokenWatchlist(ASSET_ID));
69+
70+
expect(result.current.isWatched).toBe(false);
71+
expect(useQuery).toHaveBeenCalledWith(
72+
expect.objectContaining({
73+
queryKey: tokenWatchlistQueryKeys.blob,
74+
}),
75+
);
76+
});
77+
78+
it('returns isWatched=true when asset is in blob', () => {
79+
mockBlobQuery([String(ASSET_ID)]);
80+
81+
const { result } = renderHook(() => useTokenWatchlist(ASSET_ID));
82+
83+
expect(result.current.isWatched).toBe(true);
84+
});
85+
86+
it('returns isWatched=false when blob has no data (undefined)', () => {
87+
(useQuery as jest.Mock).mockReturnValue({ data: undefined });
88+
89+
const { result } = renderHook(() => useTokenWatchlist(ASSET_ID));
90+
91+
expect(result.current.isWatched).toBe(false);
92+
});
93+
94+
it('calls addMutation when toggling an unwatched asset', () => {
95+
mockBlobQuery([]);
96+
97+
const { result } = renderHook(() => useTokenWatchlist(ASSET_ID));
98+
99+
act(() => result.current.toggle());
100+
101+
expect(mockMutateAdd).toHaveBeenCalledWith(ASSET_ID);
102+
expect(mockMutateRemove).not.toHaveBeenCalled();
103+
});
104+
105+
it('calls removeMutation when toggling a watched asset', () => {
106+
mockBlobQuery([String(ASSET_ID)]);
107+
108+
const { result } = renderHook(() => useTokenWatchlist(ASSET_ID));
109+
110+
act(() => result.current.toggle());
111+
112+
expect(mockMutateRemove).toHaveBeenCalledWith(ASSET_ID);
113+
expect(mockMutateAdd).not.toHaveBeenCalled();
114+
});
115+
116+
it('returns isLoading=true when add mutation is pending', () => {
117+
(useTokenWatchlistAddItemMutation as jest.Mock).mockReturnValue({
118+
mutate: mockMutateAdd,
119+
isPending: true,
120+
});
121+
mockBlobQuery([]);
122+
123+
const { result } = renderHook(() => useTokenWatchlist(ASSET_ID));
124+
125+
expect(result.current.isLoading).toBe(true);
126+
});
127+
128+
it('returns isLoading=true when remove mutation is pending', () => {
129+
(useTokenWatchlistRemoveItemMutation as jest.Mock).mockReturnValue({
130+
mutate: mockMutateRemove,
131+
isPending: true,
132+
});
133+
mockBlobQuery([String(ASSET_ID)]);
134+
135+
const { result } = renderHook(() => useTokenWatchlist(ASSET_ID));
136+
137+
expect(result.current.isLoading).toBe(true);
138+
});
139+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { useCallback } from 'react';
2+
import { useQuery } from '@tanstack/react-query';
3+
import type { CaipAssetType } from '@metamask/utils';
4+
5+
import { readFromTokenWatchList, type WatchlistBlob } from '../storage';
6+
import {
7+
useTokenWatchlistAddItemMutation,
8+
useTokenWatchlistRemoveItemMutation,
9+
} from './useTokenWatchlistMutations';
10+
import { tokenWatchlistQueryKeys } from './watchlist-query-keys';
11+
12+
export interface UseTokenWatchlistResult {
13+
isWatched: boolean;
14+
isLoading: boolean;
15+
toggle: () => void;
16+
}
17+
18+
const NOOP_RESULT: UseTokenWatchlistResult = {
19+
isWatched: false,
20+
isLoading: false,
21+
toggle: () => undefined,
22+
};
23+
24+
/**
25+
* Convenience hook that exposes `isWatched` and a `toggle` function for a
26+
* single asset. Combines the add/remove mutation hooks with a reactive
27+
* cache subscription via useQuery so optimistic updates are reflected
28+
* immediately.
29+
*/
30+
export function useTokenWatchlist(
31+
assetId: CaipAssetType | null,
32+
): UseTokenWatchlistResult {
33+
const addMutation = useTokenWatchlistAddItemMutation();
34+
const removeMutation = useTokenWatchlistRemoveItemMutation();
35+
36+
const { data: blob } = useQuery<WatchlistBlob>({
37+
queryKey: tokenWatchlistQueryKeys.blob,
38+
queryFn: readFromTokenWatchList,
39+
staleTime: Infinity,
40+
});
41+
42+
const assetIdStr = assetId ? String(assetId) : null;
43+
const isWatched = assetIdStr
44+
? (blob?.assets.includes(assetIdStr) ?? false)
45+
: false;
46+
47+
const isLoading = addMutation.isPending || removeMutation.isPending;
48+
49+
const toggle = useCallback(() => {
50+
if (!assetId) return;
51+
if (isWatched) {
52+
removeMutation.mutate(assetId);
53+
} else {
54+
addMutation.mutate(assetId);
55+
}
56+
}, [assetId, isWatched, addMutation, removeMutation]);
57+
58+
if (!assetId) {
59+
return NOOP_RESULT;
60+
}
61+
62+
return { isWatched, isLoading, toggle };
63+
}

app/components/UI/assets/watchlist/hooks/useTokenWatchlistMutations.test.tsx renamed to app/components/UI/Assets/watchlist/hooks/useTokenWatchlistMutations.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
type WatchlistBlob,
1515
writeToTokenWatchList,
1616
} from '../storage';
17-
import { tokenWatchlistQueryKeys } from './useTokenWatchlist.keys';
17+
import { tokenWatchlistQueryKeys } from './watchlist-query-keys';
1818
import {
1919
tokenWatchlistBatcher,
2020
useTokenWatchlistAddItemMutation,

app/components/UI/assets/watchlist/hooks/useTokenWatchlistMutations.ts renamed to app/components/UI/Assets/watchlist/hooks/useTokenWatchlistMutations.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
writeToTokenWatchList,
99
} from '../storage';
1010
import { createAsyncBatcher } from '../utils/createAsyncBatcher';
11-
import { tokenWatchlistQueryKeys } from './useTokenWatchlist.keys';
11+
import { tokenWatchlistQueryKeys } from './watchlist-query-keys';
1212

1313
export type WatchlistAddInput = CaipAssetType | CaipAssetType[];
1414
export type WatchlistRemoveInput = CaipAssetType | CaipAssetType[];
@@ -111,6 +111,9 @@ const useWatchlistMutation = <TInput>({
111111
queryClient.invalidateQueries({
112112
queryKey: tokenWatchlistQueryKeys.blob,
113113
});
114+
queryClient.invalidateQueries({
115+
queryKey: tokenWatchlistQueryKeys.hydrated,
116+
});
114117
},
115118
});
116119
};

app/components/UI/assets/watchlist/hooks/useTokenWatchlistQuery.test.ts renamed to app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ describe('useTokenWatchlistQuery', () => {
8989
});
9090

9191
expect(
92-
queryClient.getQueryData(tokenWatchlistQueryKeys.blob),
92+
queryClient.getQueryData(tokenWatchlistQueryKeys.hydrated),
9393
).toStrictEqual([]);
9494
});
9595

@@ -308,7 +308,7 @@ describe('useTokenWatchlistQuery', () => {
308308
queryClient.getQueryData(tokenWatchlistQueryKeys.suggested),
309309
).toStrictEqual([]);
310310
expect(
311-
queryClient.getQueryData(tokenWatchlistQueryKeys.blob),
311+
queryClient.getQueryData(tokenWatchlistQueryKeys.hydrated),
312312
).toBeUndefined();
313313
});
314314

0 commit comments

Comments
 (0)