Skip to content

Commit 61a0b3c

Browse files
Bigshmowclaudexavier-brochardzone-live
authored
feat: wire follow/unfollow to SocialController using AuthenticationController profileId (#28843)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** **Wire follow/unfollow persistence via SocialController state** - Bump @metamask/social-controllers to ^1.0.0 which writes followingProfileIds to controller state on follow/unfollow/updateFollowing - Add AuthenticationController:getBearerToken delegation to SocialService messenger (required by 1.0.0 for JWT auth on all API requests) - Hydrate following state once at Engine startup via SocialController:updateFollowing - Read isFollowing from Redux (SocialController.followingProfileIds) instead of ephemeral local state - toggleFollow calls SocialController:followTrader/unfollowTrader directly — controller updates state, Redux syncs, UI re-renders **Test plan** 1. Follow a trader on homepage carousel > navigate to leaderboard > follow state persists 2. Unfollow a trader on leaderboard > navigate away and back > unfollow state persists 3. Follow/unfollow on trader profile > navigate back to leaderboard > state is consistent 4. Kill and reopen app > follow state survives (controller state is persisted) [TSA-389](https://consensyssoftware.atlassian.net/browse/TSA-389) <!-- 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: Wired up follow/unfollow functionality ## **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] --> ## **Pre-merge author checklist** - [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 - [ ] 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. [TSA-389]: https://consensyssoftware.atlassian.net/browse/TSA-389?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [TSA-401]: https://consensyssoftware.atlassian.net/browse/TSA-401?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Introduces new follow/unfollow wiring that calls `SocialController` via `Engine.controllerMessenger` and runs a new hydration request during `EngineService.start()`, which could affect app startup behavior and follow state consistency if messaging/auth fails. > > **Overview** > Moves follow state from local component overrides to **persisted `SocialController.followingProfileIds` in Redux**, via a new shared hook `useFollowToggle`/`useFollowToggleMany` that performs **optimistic follow/unfollow** and dispatches `SocialController:followTrader` / `SocialController:unfollowTrader` using the session `profileId` from `AuthenticationController`. > > Adds **engine-startup hydration** (`hydrateSocialFollowing`) to refresh the server following list by calling `SocialController:updateFollowing` (fire-and-forget, non-fatal on failure), plus a new selector `selectFollowingProfileIds` and expanded unit tests to cover seeding from controller state, optimistic updates, rejection reverts, and in-flight call de-duping. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4637abc. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Xavier Brochard <xavier.brochard@consensys.net> Co-authored-by: Antonio Regadas <antonio.regadas@consensys.net>
1 parent 208c27b commit 61a0b3c

13 files changed

Lines changed: 744 additions & 74 deletions

File tree

app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.test.ts

Lines changed: 116 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,37 @@
11
import { renderHook, act } from '@testing-library/react-native';
2+
import { useSelector } from 'react-redux';
23
import { useQuery } from '@metamask/react-data-query';
4+
import Engine from '../../../../../../core/Engine';
35
import Logger from '../../../../../../util/Logger';
46
import { useTopTraders } from './useTopTraders';
57

8+
jest.mock('react-redux', () => ({
9+
useSelector: jest.fn().mockReturnValue([]),
10+
}));
11+
12+
jest.mock('../../../../../../selectors/socialController', () => ({
13+
selectFollowingProfileIds: jest.fn(),
14+
}));
15+
616
jest.mock('../../../../../../util/Logger', () => ({
717
error: jest.fn(),
818
}));
919

20+
jest.mock('../../../../../../core/Engine', () => ({
21+
context: {
22+
AuthenticationController: {
23+
getSessionProfile: jest
24+
.fn()
25+
.mockResolvedValue({ profileId: 'mock-profile-id' }),
26+
},
27+
},
28+
controllerMessenger: {
29+
call: jest.fn(),
30+
subscribe: jest.fn(),
31+
unsubscribe: jest.fn(),
32+
},
33+
}));
34+
1035
const mockTraders = [
1136
{
1237
rank: 1,
@@ -43,6 +68,7 @@ jest.mock('@metamask/react-data-query');
4368

4469
const mockRefetch = jest.fn();
4570
const mockUseQuery = useQuery as jest.MockedFunction<typeof useQuery>;
71+
const mockUseSelector = useSelector as jest.MockedFunction<typeof useSelector>;
4672

4773
const makeQueryResult = (
4874
overrides: Partial<ReturnType<typeof useQuery>> = {},
@@ -59,6 +85,7 @@ describe('useTopTraders', () => {
5985
beforeEach(() => {
6086
jest.clearAllMocks();
6187
mockUseQuery.mockReturnValue(makeQueryResult());
88+
mockUseSelector.mockReturnValue([]);
6289
});
6390

6491
describe('data mapping', () => {
@@ -158,43 +185,120 @@ describe('useTopTraders', () => {
158185
});
159186
});
160187

188+
describe('isFollowing seeding from controller state', () => {
189+
it('seeds isFollowing true for traders present in followingProfileIds', () => {
190+
mockUseSelector.mockReturnValue([mockTraders[0].profileId]);
191+
mockUseQuery.mockReturnValue(
192+
makeQueryResult({ data: mockLeaderboardResponse as never }),
193+
);
194+
const { result } = renderHook(() => useTopTraders());
195+
expect(result.current.traders[0].isFollowing).toBe(true);
196+
expect(result.current.traders[1].isFollowing).toBe(false);
197+
});
198+
});
199+
161200
describe('toggleFollow', () => {
162201
beforeEach(() => {
163202
mockUseQuery.mockReturnValue(
164203
makeQueryResult({ data: mockLeaderboardResponse as never }),
165204
);
205+
(Engine.controllerMessenger.call as jest.Mock).mockResolvedValue({
206+
followed: [],
207+
unfollowed: [],
208+
});
166209
});
167210

168-
it('sets isFollowing to true on the first toggle', () => {
211+
it('calls followTrader when trader is not followed', async () => {
169212
const { result } = renderHook(() => useTopTraders());
170-
const traderId = mockTraders[0].profileId;
171213

172-
act(() => {
173-
result.current.toggleFollow(traderId);
214+
await act(async () => {
215+
await result.current.toggleFollow(mockTraders[0].profileId);
216+
});
217+
218+
expect(Engine.controllerMessenger.call).toHaveBeenCalledWith(
219+
'SocialController:followTrader',
220+
{
221+
addressOrUid: 'mock-profile-id',
222+
targets: [mockTraders[0].profileId],
223+
},
224+
);
225+
});
226+
227+
it('calls unfollowTrader when trader is already followed', async () => {
228+
mockUseSelector.mockReturnValue([mockTraders[0].profileId]);
229+
const { result } = renderHook(() => useTopTraders());
230+
231+
await act(async () => {
232+
await result.current.toggleFollow(mockTraders[0].profileId);
233+
});
234+
235+
expect(Engine.controllerMessenger.call).toHaveBeenCalledWith(
236+
'SocialController:unfollowTrader',
237+
{
238+
addressOrUid: 'mock-profile-id',
239+
targets: [mockTraders[0].profileId],
240+
},
241+
);
242+
});
243+
244+
it('flips isFollowing optimistically for the tapped trader', async () => {
245+
let resolveCall: (value: unknown) => void = () => undefined;
246+
(Engine.controllerMessenger.call as jest.Mock).mockImplementation(
247+
() =>
248+
new Promise((resolve) => {
249+
resolveCall = resolve;
250+
}),
251+
);
252+
const { result } = renderHook(() => useTopTraders());
253+
254+
expect(result.current.traders[0].isFollowing).toBe(false);
255+
256+
await act(async () => {
257+
result.current.toggleFollow(mockTraders[0].profileId);
174258
});
175259

176260
expect(result.current.traders[0].isFollowing).toBe(true);
261+
expect(result.current.traders[1].isFollowing).toBe(false);
262+
263+
await act(async () => {
264+
resolveCall({ followed: [], unfollowed: [] });
265+
});
177266
});
178267

179-
it('toggles isFollowing back to false on a second call', () => {
268+
it('reverts optimistic isFollowing when the API call rejects', async () => {
269+
(Engine.controllerMessenger.call as jest.Mock).mockRejectedValue(
270+
new Error('boom'),
271+
);
180272
const { result } = renderHook(() => useTopTraders());
181-
const traderId = mockTraders[0].profileId;
182273

183-
act(() => result.current.toggleFollow(traderId));
184-
act(() => result.current.toggleFollow(traderId));
274+
await act(async () => {
275+
await result.current.toggleFollow(mockTraders[0].profileId);
276+
});
185277

186278
expect(result.current.traders[0].isFollowing).toBe(false);
187279
});
188280

189-
it('does not affect other traders when one is toggled', () => {
281+
it('ignores concurrent toggleFollow calls for the same trader while in flight', async () => {
282+
let resolveCall: (value: unknown) => void = () => undefined;
283+
(Engine.controllerMessenger.call as jest.Mock).mockImplementation(
284+
() =>
285+
new Promise((resolve) => {
286+
resolveCall = resolve;
287+
}),
288+
);
190289
const { result } = renderHook(() => useTopTraders());
191290

192-
act(() => {
291+
await act(async () => {
292+
result.current.toggleFollow(mockTraders[0].profileId);
293+
});
294+
await act(async () => {
193295
result.current.toggleFollow(mockTraders[0].profileId);
194296
});
195297

196-
result.current.traders.slice(1).forEach((trader) => {
197-
expect(trader.isFollowing).toBe(false);
298+
expect(Engine.controllerMessenger.call).toHaveBeenCalledTimes(1);
299+
300+
await act(async () => {
301+
resolveCall({ followed: [], unfollowed: [] });
198302
});
199303
});
200304
});

app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.ts

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,26 @@
1-
import { useMemo, useCallback, useState, useEffect } from 'react';
1+
import { useMemo, useCallback, useEffect } from 'react';
22
import { useQuery } from '@metamask/react-data-query';
33
import type {
44
LeaderboardResponse,
55
FetchLeaderboardOptions,
66
} from '@metamask/social-controllers';
77
import Logger from '../../../../../../util/Logger';
8+
import { useFollowToggleMany } from '../../../../../hooks/useFollowToggle';
89
import type { TopTrader } from '../types';
910

10-
/**
11-
* Result interface for the useTopTraders hook.
12-
*/
1311
export interface UseTopTradersResult {
1412
traders: TopTrader[];
1513
isLoading: boolean;
1614
error: string | null;
1715
refresh: () => Promise<void>;
18-
toggleFollow: (traderId: string) => void;
16+
toggleFollow: (addressOrId: string) => void;
1917
}
2018

2119
interface UseTopTradersOptions {
2220
limit?: number;
2321
enabled?: boolean;
2422
}
2523

26-
/**
27-
* Hook that provides top traders data for the social leaderboard.
28-
*
29-
* Uses the Data Services Pattern -- `useQuery` from `@metamask/react-data-query`
30-
* resolves the `SocialService:fetchLeaderboard` query key through the messenger
31-
* adapter, so the SocialService handles caching, de-duplication, and retries.
32-
*
33-
* @param options - Optional configuration.
34-
* @param options.limit - Maximum number of traders to return.
35-
* @returns Object with traders, isLoading, error, refresh, toggleFollow
36-
*/
3724
export const useTopTraders = (
3825
options?: UseTopTradersOptions,
3926
): UseTopTradersResult => {
@@ -51,9 +38,7 @@ export const useTopTraders = (
5138
enabled: options?.enabled ?? true,
5239
});
5340

54-
const [localFollowOverrides, setLocalFollowOverrides] = useState<
55-
Record<string, boolean>
56-
>({});
41+
const { isFollowing, toggleFollow } = useFollowToggleMany();
5742

5843
const traders: TopTrader[] = useMemo(() => {
5944
if (!data?.traders) {
@@ -68,9 +53,9 @@ export const useTopTraders = (
6853
percentageChange: (entry.roiPercent30d ?? 0) * 100,
6954
pnlValue: entry.pnl30d,
7055
pnlPerChain: entry.pnlPerChain ?? {},
71-
isFollowing: localFollowOverrides[entry.profileId] ?? false,
56+
isFollowing: isFollowing(entry.profileId),
7257
}));
73-
}, [data, localFollowOverrides]);
58+
}, [data, isFollowing]);
7459

7560
const refresh = useCallback(async () => {
7661
try {
@@ -81,13 +66,6 @@ export const useTopTraders = (
8166
}
8267
}, [refetch]);
8368

84-
const toggleFollow = useCallback((traderId: string) => {
85-
setLocalFollowOverrides((prev) => ({
86-
...prev,
87-
[traderId]: !prev[traderId],
88-
}));
89-
}, []);
90-
9169
useEffect(() => {
9270
if (error) {
9371
Logger.error(error as Error, 'useTopTraders: leaderboard fetch failed');

app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1+
import type {
2+
Position,
3+
TraderProfileResponse,
4+
} from '@metamask/social-controllers';
5+
import { fireEvent, screen } from '@testing-library/react-native';
16
import React from 'react';
2-
import { screen, fireEvent } from '@testing-library/react-native';
7+
import Routes from '../../../../constants/navigation/Routes';
38
import renderWithProvider from '../../../../util/test/renderWithProvider';
9+
import type { UseTraderPositionsResult } from './hooks/useTraderPositions';
10+
import type { UseTraderProfileResult } from './hooks/useTraderProfile';
411
import TraderProfileView from './TraderProfileView';
512
import { TraderProfileViewSelectorsIDs } from './TraderProfileView.testIds';
6-
import Routes from '../../../../constants/navigation/Routes';
7-
import type { UseTraderProfileResult } from './hooks/useTraderProfile';
8-
import type { UseTraderPositionsResult } from './hooks/useTraderPositions';
9-
import type {
10-
TraderProfileResponse,
11-
Position,
12-
} from '@metamask/social-controllers';
1313

1414
const mockGoBack = jest.fn();
1515
const mockNavigate = jest.fn();

0 commit comments

Comments
 (0)