Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/social-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -168,6 +184,7 @@ export type SocialServiceMethodActions =
| SocialServiceFetchFollowersAction
| SocialServiceFetchPositionByIdAction
| SocialServiceFetchFollowingAction
| SocialServiceFetchFeedAction
| SocialServiceFollowAction
| SocialServiceUnfollowAction
| SocialServiceOptOutOfLeaderboardAction
Expand Down
123 changes: 123 additions & 0 deletions packages/social-controllers/src/SocialService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
88 changes: 88 additions & 0 deletions packages/social-controllers/src/SocialService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { Messenger } from '@metamask/messenger';
import type { AuthenticationController } from '@metamask/profile-sync-controller';
import {
array,
assign,
boolean,
enums,
is,
Expand All @@ -23,6 +24,8 @@ import {

import { serviceName, SocialServiceErrorMessage } from './social-constants';
import type {
FeedResponse,
FetchFeedOptions,
FetchFollowersOptions,
FetchLeaderboardOptions,
FetchPositionByIdOptions,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -184,6 +208,7 @@ const MESSENGER_EXPOSED_METHODS = [
'fetchFollowers',
'fetchFollowing',
'fetchPositionById',
'fetchFeed',
'follow',
'unfollow',
'optOutOfLeaderboard',
Expand Down Expand Up @@ -471,6 +496,69 @@ 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<FeedResponse> {
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.
*
Expand Down
5 changes: 5 additions & 0 deletions packages/social-controllers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export { SocialService } from './SocialService';

export type {
SocialServiceFetchClosedPositionsAction,
SocialServiceFetchFeedAction,
SocialServiceFetchFollowersAction,
SocialServiceFetchFollowingAction,
SocialServiceFetchLeaderboardAction,
Expand All @@ -43,6 +44,10 @@ export type {

export { TradeStruct } from './social-types';
export type {
FeedItem,
FeedPagination,
FeedResponse,
FetchFeedOptions,
FetchFollowersOptions,
FetchLeaderboardOptions,
FetchPositionByIdOptions,
Expand Down
2 changes: 2 additions & 0 deletions packages/social-controllers/src/social-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
54 changes: 54 additions & 0 deletions packages/social-controllers/src/social-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
Expand Down
Loading