diff --git a/packages/social-controllers/CHANGELOG.md b/packages/social-controllers/CHANGELOG.md index a3f0bb9a25..a5a92fb054 100644 --- a/packages/social-controllers/CHANGELOG.md +++ b/packages/social-controllers/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `fetchFeed` method to `SocialService` (and the `SocialService:fetchFeed` messenger action) for the trader-activity feed. Calls `GET /feed` with an optional `scope` (`following`, default, personalized to the JWT-identified user; or `leaderboard`, the generic shared feed), `chains` (as CAIP-2 chain ids), `limit`, and cursor pagination (`olderThan`/`newerThan`) for infinite scroll ([#9447](https://github.com/MetaMask/core/pull/9447)) +- Add `FeedItem`, `FeedPagination`, `FeedResponse`, and `FetchFeedOptions` types. `FeedItem` extends `Position` with the trade's `actor` (`ProfileSummary`) and creation `timestamp`; `FeedPagination` exposes the `olderCursor`/`newerCursor` cursors ([#9447](https://github.com/MetaMask/core/pull/9447)) + ## [2.4.0] ### Added diff --git a/packages/social-controllers/src/SocialService-method-action-types.ts b/packages/social-controllers/src/SocialService-method-action-types.ts index 656883c7a4..cef6d46f89 100644 --- a/packages/social-controllers/src/SocialService-method-action-types.ts +++ b/packages/social-controllers/src/SocialService-method-action-types.ts @@ -109,6 +109,22 @@ export type SocialServiceFetchFollowingAction = { handler: SocialService['fetchFollowing']; }; +/** + * Fetches a page of the trader-activity feed. + * + * Calls `GET ${baseUrl}/feed`. For the `following` scope the current user is + * identified server-side from the JWT sub claim carried in the Authorization + * header; the `leaderboard` scope is generic and shared by all users. + * + * @param options - Optional feed query parameters (scope, chains, limit, and + * `olderThan`/`newerThan` pagination cursors). + * @returns The feed response with items and pagination cursors. + */ +export type SocialServiceFetchFeedAction = { + type: `SocialService:fetchFeed`; + handler: SocialService['fetchFeed']; +}; + /** * Follows one or more traders on behalf of the current user. * @@ -168,6 +184,7 @@ export type SocialServiceMethodActions = | SocialServiceFetchFollowersAction | SocialServiceFetchPositionByIdAction | SocialServiceFetchFollowingAction + | SocialServiceFetchFeedAction | SocialServiceFollowAction | SocialServiceUnfollowAction | SocialServiceOptOutOfLeaderboardAction diff --git a/packages/social-controllers/src/SocialService.test.ts b/packages/social-controllers/src/SocialService.test.ts index 2194cfd4ad..35aaee3dd4 100644 --- a/packages/social-controllers/src/SocialService.test.ts +++ b/packages/social-controllers/src/SocialService.test.ts @@ -863,6 +863,129 @@ describe('SocialService', () => { }); }); + describe('fetchFeed', () => { + const mockFeedItem = { + ...mockPosition, + actor: mockProfileSummary, + timestamp: 1700000000, + }; + + const mockFeedResponse = { + items: [mockFeedItem], + pagination: { olderCursor: 'older-cursor', newerCursor: 'newer-cursor' }, + }; + + it('fetches the feed from the correct endpoint', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockFeedResponse), + }); + + const service = createService(); + const result = await service.fetchFeed(); + + expect(result).toStrictEqual(mockFeedResponse); + expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/feed`, { + headers: { Authorization: `Bearer ${MOCK_TOKEN}` }, + }); + }); + + it('appends scope, chains, limit, and pagination cursors', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockFeedResponse), + }); + + const service = createService(); + await service.fetchFeed({ + scope: 'leaderboard', + chains: ['base', 'solana'], + limit: 25, + olderThan: 'older-cursor', + newerThan: 'newer-cursor', + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('scope=leaderboard'); + expect(calledUrl).toContain('chains=base'); + expect(calledUrl).toContain('chains=solana'); + expect(calledUrl).toContain('limit=25'); + expect(calledUrl).toContain('olderThan=older-cursor'); + expect(calledUrl).toContain('newerThan=newer-cursor'); + }); + + it('validates a perp feed item', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + items: [ + { ...mockPerpPosition, actor: mockProfileSummary, timestamp: 1 }, + ], + pagination: { olderCursor: null, newerCursor: null }, + }), + }); + + const service = createService(); + const result = await service.fetchFeed(); + + expect(result.items).toHaveLength(1); + expect(result.pagination).toStrictEqual({ + olderCursor: null, + newerCursor: null, + }); + }); + + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 }); + + const service = createService(); + + await expect(service.fetchFeed()).rejects.toThrow( + `${SocialServiceErrorMessage.FETCH_FEED_FAILED}: 500`, + ); + }); + + it('throws when the feed item is missing its actor', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + items: [{ ...mockPosition, timestamp: 1700000000 }], + pagination: { olderCursor: null, newerCursor: null }, + }), + }); + + const service = createService(); + + await expect(service.fetchFeed()).rejects.toThrow( + SocialServiceErrorMessage.FETCH_FEED_INVALID_RESPONSE, + ); + }); + + it('throws when the pagination shape is invalid', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + items: [mockFeedItem], + pagination: { olderCursor: 123, newerCursor: null }, + }), + }); + + const service = createService(); + + await expect(service.fetchFeed()).rejects.toThrow( + SocialServiceErrorMessage.FETCH_FEED_INVALID_RESPONSE, + ); + }); + }); + describe('fetchFollowing', () => { const mockFollowingResponse = { following: [mockProfileSummary], diff --git a/packages/social-controllers/src/SocialService.ts b/packages/social-controllers/src/SocialService.ts index 3b85c82102..7fbd5c26a6 100644 --- a/packages/social-controllers/src/SocialService.ts +++ b/packages/social-controllers/src/SocialService.ts @@ -10,6 +10,7 @@ import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import { array, + assign, boolean, enums, is, @@ -23,6 +24,8 @@ import { import { serviceName, SocialServiceErrorMessage } from './social-constants'; import type { + FeedResponse, + FetchFeedOptions, FetchFollowersOptions, FetchLeaderboardOptions, FetchPositionByIdOptions, @@ -154,6 +157,27 @@ const PositionsResponseStruct = structType({ computedAt: optional(nullable(string())), }); +// A feed item is a position plus the trader who made the trade (`actor`) and +// the item's creation timestamp. Reuses PositionStruct so the trade/position +// fields stay in lockstep with the positions endpoints. +const FeedItemStruct = assign( + PositionStruct, + structType({ + actor: ProfileSummaryStruct, + timestamp: number(), + }), +); + +const FeedPaginationStruct = structType({ + olderCursor: nullable(string()), + newerCursor: nullable(string()), +}); + +const FeedResponseStruct = structType({ + items: array(FeedItemStruct), + pagination: FeedPaginationStruct, +}); + const FollowersResponseStruct = structType({ followers: array(ProfileSummaryStruct), count: number(), @@ -184,6 +208,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'fetchFollowers', 'fetchFollowing', 'fetchPositionById', + 'fetchFeed', 'follow', 'unfollow', 'optOutOfLeaderboard', @@ -471,6 +496,71 @@ export class SocialService extends BaseDataService< return positionResponse; } + /** + * Fetches a page of the trader-activity feed. + * + * Calls `GET ${baseUrl}/feed`. For the `following` scope the current user is + * identified server-side from the JWT sub claim carried in the Authorization + * header; the `leaderboard` scope is generic and shared by all users. + * + * Cursor pagination supports infinite scroll: pass `pagination.olderCursor` + * from a prior response back as `olderThan` to load older items, and + * `pagination.newerCursor` as `newerThan` to fetch newer items. + * + * @param options - Options bag. + * @param options.scope - `following` (default) or `leaderboard`. + * @param options.chains - Filter by one or more chains. + * @param options.limit - Number of results per page. + * @param options.olderThan - Cursor for older items (scroll down). + * @param options.newerThan - Cursor for newer items (refresh). + * @returns The feed response with items and pagination cursors. + */ + async fetchFeed(options?: FetchFeedOptions): Promise { + const feedResponse = await this.fetchQuery({ + queryKey: [`${this.name}:fetchFeed`, options ?? null], + staleTime: 0, + queryFn: async () => { + const { scope, chains, limit, olderThan, newerThan } = options ?? {}; + const url = new URL(`${this.#v1Url}/feed`); + if (scope) { + url.searchParams.append('scope', scope); + } + if (chains) { + for (const chain of chains) { + url.searchParams.append('chains', chain); + } + } + if (limit !== undefined) { + url.searchParams.append('limit', String(limit)); + } + if (olderThan) { + url.searchParams.append('olderThan', olderThan); + } + if (newerThan) { + url.searchParams.append('newerThan', newerThan); + } + + const authHeaders = await this.#getAuthHeaders(); + const response = await fetch(url.toString(), { + headers: authHeaders, + }); + SocialService.#throwIfNotOk( + response, + SocialServiceErrorMessage.FETCH_FEED_FAILED, + ); + const feedData = await response.json(); + if (!is(feedData, FeedResponseStruct)) { + throw new Error( + SocialServiceErrorMessage.FETCH_FEED_INVALID_RESPONSE, + ); + } + return feedData as FeedResponse; + }, + }); + + return feedResponse; + } + /** * Fetches the list of traders the current user is following. * diff --git a/packages/social-controllers/src/index.ts b/packages/social-controllers/src/index.ts index 9136ca157e..99c031d380 100644 --- a/packages/social-controllers/src/index.ts +++ b/packages/social-controllers/src/index.ts @@ -29,6 +29,7 @@ export { SocialService } from './SocialService'; export type { SocialServiceFetchClosedPositionsAction, + SocialServiceFetchFeedAction, SocialServiceFetchFollowersAction, SocialServiceFetchFollowingAction, SocialServiceFetchLeaderboardAction, @@ -43,6 +44,10 @@ export type { export { TradeStruct } from './social-types'; export type { + FeedItem, + FeedPagination, + FeedResponse, + FetchFeedOptions, FetchFollowersOptions, FetchLeaderboardOptions, FetchPositionByIdOptions, diff --git a/packages/social-controllers/src/social-constants.ts b/packages/social-controllers/src/social-constants.ts index 3a71e41bda..3eeb73c5b0 100644 --- a/packages/social-controllers/src/social-constants.ts +++ b/packages/social-controllers/src/social-constants.ts @@ -33,4 +33,6 @@ export const SocialServiceErrorMessage = { LEADERBOARD_OPT_OUT_FAILED: 'SocialService: Leaderboard opt-out request failed', LEADERBOARD_OPT_IN_FAILED: 'SocialService: Leaderboard opt-in request failed', + FETCH_FEED_FAILED: 'SocialService: Feed request failed', + FETCH_FEED_INVALID_RESPONSE: 'SocialService: Feed returned invalid response', } as const; diff --git a/packages/social-controllers/src/social-types.ts b/packages/social-controllers/src/social-types.ts index 168072cd5d..91d803e467 100644 --- a/packages/social-controllers/src/social-types.ts +++ b/packages/social-controllers/src/social-types.ts @@ -203,6 +203,40 @@ export type PositionsResponse = { computedAt?: string | null; }; +// --------------------------------------------------------------------------- +// Feed +// --------------------------------------------------------------------------- + +/** + * A single trader-activity feed item: a {@link Position} the trade belongs to, + * plus the {@link ProfileSummary} of the trader who made it (`actor`) and the + * item's creation `timestamp` (Unix seconds). + */ +export type FeedItem = Position & { + /** The trader who made this trade. */ + actor: ProfileSummary; + /** Unix timestamp (seconds) when the feed item was created. */ + timestamp: number; +}; + +/** + * Cursor pagination for the feed. Pass `olderCursor` back as `olderThan` to + * load older items (infinite scroll), and `newerCursor` as `newerThan` to + * fetch newer items. `null` when there are no items in that direction. + */ +export type FeedPagination = { + olderCursor: string | null; + newerCursor: string | null; +}; + +/** + * Response from `GET /v1/feed`. + */ +export type FeedResponse = { + items: FeedItem[]; + pagination: FeedPagination; +}; + // --------------------------------------------------------------------------- // Followers // --------------------------------------------------------------------------- @@ -274,6 +308,26 @@ export type FetchFollowersOptions = { addressOrId: string; }; +export type FetchFeedOptions = { + /** + * Which feed to fetch: `following` (personalized to the current user, + * identified server-side from the JWT) or `leaderboard` (generic, shared by + * all users). Defaults to `following` server-side when omitted. + */ + scope?: 'following' | 'leaderboard'; + /** + * Filter by one or more chains, given as CAIP-2 chain ids (e.g. + * `eip155:8453`). Omit for the server defaults. + */ + chains?: string[]; + /** Number of results to return. */ + limit?: number; + /** Cursor for older items (infinite scroll). Use `pagination.olderCursor`. */ + olderThan?: string; + /** Cursor for newer items (pull to refresh). Use `pagination.newerCursor`. */ + newerThan?: string; +}; + export type FetchPositionByIdOptions = { /** Unique position ID (UUID). */ positionId: string;