From f9c5bcd397d976f0c1c2ba20a023de65362b7d6f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 12:58:13 +0000 Subject: [PATCH 1/2] feat(social-controllers): add moderation block methods to SocialService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the social-api block, blocked-profiles, and blocked-content endpoints so clients can record and list moderation blocks. Co-authored-by: João Santos --- packages/social-controllers/CHANGELOG.md | 7 + .../src/SocialService-method-action-types.ts | 56 +++- .../src/SocialService.test.ts | 312 ++++++++++++++++++ .../social-controllers/src/SocialService.ts | 156 +++++++++ packages/social-controllers/src/index.ts | 10 + .../src/social-constants.ts | 8 + .../social-controllers/src/social-types.ts | 94 ++++++ 7 files changed, 642 insertions(+), 1 deletion(-) diff --git a/packages/social-controllers/CHANGELOG.md b/packages/social-controllers/CHANGELOG.md index e1881d347f0..586460b0368 100644 --- a/packages/social-controllers/CHANGELOG.md +++ b/packages/social-controllers/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `block`, `fetchBlockedProfiles`, and `fetchBlockedContent` methods to `SocialService` (and the matching `SocialService:block`, `SocialService:fetchBlockedProfiles`, and `SocialService:fetchBlockedContent` messenger actions) for the social-api moderation block endpoints + - `block` calls `PUT /moderation/block` with exactly one of `profileId`, `commentId`, or `replyId`, plus an optional `reason`. The endpoint returns 204. + - `fetchBlockedProfiles` calls `GET /moderation/blocks/profiles` and returns traders the caller has blocked, most recently blocked first. Pass `cursor` from the previous page; `cursor` is `null` on the last page. + - `fetchBlockedContent` calls `GET /moderation/blocks/content` and returns comments and replies the caller has blocked, most recently blocked first, with the same cursor pagination. + ## [3.5.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 cbf357bcdcf..9ec93b41984 100644 --- a/packages/social-controllers/src/SocialService-method-action-types.ts +++ b/packages/social-controllers/src/SocialService-method-action-types.ts @@ -313,6 +313,57 @@ export type SocialServiceRefreshNotificationPreferencesCacheAction = { handler: SocialService['refreshNotificationPreferencesCache']; }; +/** + * Blocks a trader, swap comment, or reply for the current user. + * + * Calls `PUT ${baseUrl}/moderation/block`. Provide exactly one of + * `profileId`, `commentId`, or `replyId`. Blocking the same target again + * replaces `reason` when one is given. The caller is identified server-side + * from the JWT sub claim carried in the Authorization header. + * + * @param options - Options bag. Exactly one target, plus an optional reason. + * @param options.profileId - Profile id (UUID) of the trader to block. + * @param options.commentId - Swap comment to block. + * @param options.replyId - Comment reply to block. + * @param options.reason - Free-form reason stored on the block. + */ +export type SocialServiceBlockAction = { + type: `SocialService:block`; + handler: SocialService['block']; +}; + +/** + * Fetches traders the current user has blocked. + * + * Calls `GET ${baseUrl}/moderation/blocks/profiles`. Results are most + * recently blocked first, 250 per page. The caller is identified + * server-side from the JWT sub claim carried in the Authorization header. + * + * @param options - Options bag. + * @param options.cursor - Cursor from the previous page. Omit for the first page. + * @returns The blocked profiles page. + */ +export type SocialServiceFetchBlockedProfilesAction = { + type: `SocialService:fetchBlockedProfiles`; + handler: SocialService['fetchBlockedProfiles']; +}; + +/** + * Fetches comments and replies the current user has blocked. + * + * Calls `GET ${baseUrl}/moderation/blocks/content`. Results are most + * recently blocked first, 250 per page. The caller is identified + * server-side from the JWT sub claim carried in the Authorization header. + * + * @param options - Options bag. + * @param options.cursor - Cursor from the previous page. Omit for the first page. + * @returns The blocked content page. + */ +export type SocialServiceFetchBlockedContentAction = { + type: `SocialService:fetchBlockedContent`; + handler: SocialService['fetchBlockedContent']; +}; + /** * Union of all SocialService action types. */ @@ -334,4 +385,7 @@ export type SocialServiceMethodActions = | SocialServiceUnfollowAction | SocialServiceOptOutOfLeaderboardAction | SocialServiceOptInToLeaderboardAction - | SocialServiceRefreshNotificationPreferencesCacheAction; + | SocialServiceRefreshNotificationPreferencesCacheAction + | SocialServiceBlockAction + | SocialServiceFetchBlockedProfilesAction + | SocialServiceFetchBlockedContentAction; diff --git a/packages/social-controllers/src/SocialService.test.ts b/packages/social-controllers/src/SocialService.test.ts index 08425f23f56..7ac660c7b33 100644 --- a/packages/social-controllers/src/SocialService.test.ts +++ b/packages/social-controllers/src/SocialService.test.ts @@ -2012,4 +2012,316 @@ describe('SocialService', () => { expect(result).toBeUndefined(); }); }); + + describe('block', () => { + it('sends PUT with a profile target and reason to /moderation/block', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204 }); + + const service = createService(); + const result = await service.block({ + profileId: mockProfileSummary.profileId, + reason: 'spam', + }); + + expect(result).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/moderation/block`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${MOCK_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + profileId: mockProfileSummary.profileId, + reason: 'spam', + }), + }); + }); + + it('sends a comment target without a reason', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204 }); + + const service = createService(); + await service.block({ commentId: 'comment-1' }); + + expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/moderation/block`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${MOCK_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ commentId: 'comment-1' }), + }); + }); + + it('sends a reply target', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204 }); + + const service = createService(); + await service.block({ replyId: 'reply-1', reason: 'harassment' }); + + expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/moderation/block`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${MOCK_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ replyId: 'reply-1', reason: 'harassment' }), + }); + }); + + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 400 }); + + const service = createService(); + + await expect( + service.block({ profileId: mockProfileSummary.profileId }), + ).rejects.toThrow(`${SocialServiceErrorMessage.BLOCK_FAILED}: 400`); + }); + + it('always sends the request on repeated calls (staleTime: 0)', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204 }); + + const service = createService(); + await service.block({ commentId: 'comment-1' }); + await service.block({ commentId: 'comment-1' }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('is callable via messenger action', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204 }); + + const messenger = createMessenger(); + // eslint-disable-next-line no-new + new SocialService({ messenger, baseUrl: BASE_URL }); + + const result = await messenger.call('SocialService:block', { + replyId: 'reply-1', + }); + + expect(result).toBeUndefined(); + }); + }); + + describe('fetchBlockedProfiles', () => { + const mockBlockedProfilesResponse = { + items: [ + { + blockedAt: 1700000000, + reason: 'spam', + profile: mockProfileSummary, + }, + ], + cursor: '1710000000000', + }; + + it('fetches the first page from /moderation/blocks/profiles', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockBlockedProfilesResponse), + }); + + const service = createService(); + const result = await service.fetchBlockedProfiles(); + + expect(result).toStrictEqual(mockBlockedProfilesResponse); + expect(mockFetch).toHaveBeenCalledWith( + `${V1_URL}/moderation/blocks/profiles`, + { headers: { Authorization: `Bearer ${MOCK_TOKEN}` } }, + ); + }); + + it('sends the cursor query param for the next page', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ ...mockBlockedProfilesResponse, cursor: null }), + }); + + const service = createService(); + const result = await service.fetchBlockedProfiles({ + cursor: '1710000000000', + }); + + expect(result.cursor).toBeNull(); + expect(mockFetch).toHaveBeenCalledWith( + `${V1_URL}/moderation/blocks/profiles?cursor=1710000000000`, + { headers: { Authorization: `Bearer ${MOCK_TOKEN}` } }, + ); + }); + + it('accepts a profile that omits imageUrl', async () => { + const { imageUrl: _imageUrl, ...profileWithoutImage } = + mockProfileSummary; + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + items: [ + { + blockedAt: 1700000000, + reason: null, + profile: profileWithoutImage, + }, + ], + cursor: null, + }), + }); + + const service = createService(); + const result = await service.fetchBlockedProfiles(); + + expect(result.items[0]?.profile).toStrictEqual(profileWithoutImage); + }); + + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 401 }); + + const service = createService(); + + await expect(service.fetchBlockedProfiles()).rejects.toThrow( + `${SocialServiceErrorMessage.FETCH_BLOCKED_PROFILES_FAILED}: 401`, + ); + }); + + it('throws when response schema is invalid', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ items: 'not-an-array', cursor: null }), + }); + + const service = createService(); + + await expect(service.fetchBlockedProfiles()).rejects.toThrow( + SocialServiceErrorMessage.FETCH_BLOCKED_PROFILES_INVALID_RESPONSE, + ); + }); + + it('is callable via messenger action', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockBlockedProfilesResponse), + }); + + const messenger = createMessenger(); + // eslint-disable-next-line no-new + new SocialService({ messenger, baseUrl: BASE_URL }); + + const result = await messenger.call( + 'SocialService:fetchBlockedProfiles', + { cursor: '1710000000000' }, + ); + + expect(result).toStrictEqual(mockBlockedProfilesResponse); + }); + }); + + describe('fetchBlockedContent', () => { + const mockBlockedContentResponse = { + items: [ + { + blockedAt: 1700000000, + reason: 'misleading', + type: 'comment', + id: 'comment-1', + text: 'this is a post', + createdAt: 1690000000, + author: mockProfileSummary, + }, + { + blockedAt: 1690000000, + reason: null, + type: 'reply', + id: 'reply-1', + text: 'this is a reply', + createdAt: 1680000000, + author: null, + }, + ], + cursor: null, + }; + + it('fetches the first page from /moderation/blocks/content', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockBlockedContentResponse), + }); + + const service = createService(); + const result = await service.fetchBlockedContent(); + + expect(result).toStrictEqual(mockBlockedContentResponse); + expect(mockFetch).toHaveBeenCalledWith( + `${V1_URL}/moderation/blocks/content`, + { headers: { Authorization: `Bearer ${MOCK_TOKEN}` } }, + ); + }); + + it('sends the cursor query param for the next page', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockBlockedContentResponse), + }); + + const service = createService(); + await service.fetchBlockedContent({ cursor: '1710000000000' }); + + expect(mockFetch).toHaveBeenCalledWith( + `${V1_URL}/moderation/blocks/content?cursor=1710000000000`, + { headers: { Authorization: `Bearer ${MOCK_TOKEN}` } }, + ); + }); + + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 }); + + const service = createService(); + + await expect(service.fetchBlockedContent()).rejects.toThrow( + `${SocialServiceErrorMessage.FETCH_BLOCKED_CONTENT_FAILED}: 500`, + ); + }); + + it('throws when response schema is invalid', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + items: [{ ...mockBlockedContentResponse.items[0], type: 'post' }], + cursor: null, + }), + }); + + const service = createService(); + + await expect(service.fetchBlockedContent()).rejects.toThrow( + SocialServiceErrorMessage.FETCH_BLOCKED_CONTENT_INVALID_RESPONSE, + ); + }); + + it('is callable via messenger action', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockBlockedContentResponse), + }); + + const messenger = createMessenger(); + // eslint-disable-next-line no-new + new SocialService({ messenger, baseUrl: BASE_URL }); + + const result = await messenger.call('SocialService:fetchBlockedContent'); + + expect(result).toStrictEqual(mockBlockedContentResponse); + }); + }); }); diff --git a/packages/social-controllers/src/SocialService.ts b/packages/social-controllers/src/SocialService.ts index ded0b5bb17d..59b9173b2e0 100644 --- a/packages/social-controllers/src/SocialService.ts +++ b/packages/social-controllers/src/SocialService.ts @@ -24,9 +24,13 @@ import { import { serviceName, SocialServiceErrorMessage } from './social-constants.js'; import type { + BlockedContentResponse, + BlockedProfilesResponse, + BlockOptions, CommentEngagement, CreateSwapCommentOptions, FeedResponse, + FetchBlockedListOptions, FetchFeedOptions, FetchFollowersOptions, FetchLeaderboardOptions, @@ -279,6 +283,32 @@ const UnfollowResponseStruct = structType({ unfollowed: array(ProfileSummaryStruct), }); +const BlockedProfileStruct = structType({ + blockedAt: number(), + reason: nullable(string()), + profile: ProfileSummaryStruct, +}); + +const BlockedProfilesResponseStruct = structType({ + items: array(BlockedProfileStruct), + cursor: nullable(string()), +}); + +const BlockedContentStruct = structType({ + blockedAt: number(), + reason: nullable(string()), + type: enums(['comment', 'reply']), + id: string(), + text: string(), + createdAt: number(), + author: nullable(ProfileSummaryStruct), +}); + +const BlockedContentResponseStruct = structType({ + items: array(BlockedContentStruct), + cursor: nullable(string()), +}); + // --------------------------------------------------------------------------- // Messenger types // --------------------------------------------------------------------------- @@ -302,6 +332,9 @@ const MESSENGER_EXPOSED_METHODS = [ 'optOutOfLeaderboard', 'optInToLeaderboard', 'refreshNotificationPreferencesCache', + 'block', + 'fetchBlockedProfiles', + 'fetchBlockedContent', ] as const; export type SocialServiceActions = @@ -1128,6 +1161,129 @@ export class SocialService extends BaseDataService< }); } + /** + * Blocks a trader, swap comment, or reply for the current user. + * + * Calls `PUT ${baseUrl}/moderation/block`. Provide exactly one of + * `profileId`, `commentId`, or `replyId`. Blocking the same target again + * replaces `reason` when one is given. The caller is identified server-side + * from the JWT sub claim carried in the Authorization header. + * + * @param options - Options bag. Exactly one target, plus an optional reason. + * @param options.profileId - Profile id (UUID) of the trader to block. + * @param options.commentId - Swap comment to block. + * @param options.replyId - Comment reply to block. + * @param options.reason - Free-form reason stored on the block. + */ + async block(options: BlockOptions): Promise { + await this.fetchQuery({ + queryKey: [`${this.name}:block`, options], + staleTime: 0, + queryFn: async () => { + const url = `${this.#v1Url}/moderation/block`; + const authHeaders = await this.#getAuthHeaders(); + const response = await fetch(url, { + method: 'PUT', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify(options), + }); + SocialService.#throwIfNotOk( + response, + SocialServiceErrorMessage.BLOCK_FAILED, + ); + return null; + }, + }); + } + + /** + * Fetches traders the current user has blocked. + * + * Calls `GET ${baseUrl}/moderation/blocks/profiles`. Results are most + * recently blocked first, 250 per page. The caller is identified + * server-side from the JWT sub claim carried in the Authorization header. + * + * @param options - Options bag. + * @param options.cursor - Cursor from the previous page. Omit for the first page. + * @returns The blocked profiles page. + */ + async fetchBlockedProfiles( + options?: FetchBlockedListOptions, + ): Promise { + const cursor = options?.cursor; + + return await this.fetchQuery({ + queryKey: [`${this.name}:fetchBlockedProfiles`, cursor ?? null], + staleTime: 0, + queryFn: async () => { + const url = new URL(`${this.#v1Url}/moderation/blocks/profiles`); + if (cursor) { + url.searchParams.append('cursor', cursor); + } + + const authHeaders = await this.#getAuthHeaders(); + const response = await fetch(url.toString(), { + headers: authHeaders, + }); + SocialService.#throwIfNotOk( + response, + SocialServiceErrorMessage.FETCH_BLOCKED_PROFILES_FAILED, + ); + const blockedProfiles = (await response.json()) as unknown; + if (!is(blockedProfiles, BlockedProfilesResponseStruct)) { + throw new Error( + SocialServiceErrorMessage.FETCH_BLOCKED_PROFILES_INVALID_RESPONSE, + ); + } + return blockedProfiles; + }, + }); + } + + /** + * Fetches comments and replies the current user has blocked. + * + * Calls `GET ${baseUrl}/moderation/blocks/content`. Results are most + * recently blocked first, 250 per page. The caller is identified + * server-side from the JWT sub claim carried in the Authorization header. + * + * @param options - Options bag. + * @param options.cursor - Cursor from the previous page. Omit for the first page. + * @returns The blocked content page. + */ + async fetchBlockedContent( + options?: FetchBlockedListOptions, + ): Promise { + const cursor = options?.cursor; + + return await this.fetchQuery({ + queryKey: [`${this.name}:fetchBlockedContent`, cursor ?? null], + staleTime: 0, + queryFn: async () => { + const url = new URL(`${this.#v1Url}/moderation/blocks/content`); + if (cursor) { + url.searchParams.append('cursor', cursor); + } + + const authHeaders = await this.#getAuthHeaders(); + const response = await fetch(url.toString(), { + headers: authHeaders, + }); + SocialService.#throwIfNotOk( + response, + SocialServiceErrorMessage.FETCH_BLOCKED_CONTENT_FAILED, + ); + const blockedContent = (await response.json()) as unknown; + if (!is(blockedContent, BlockedContentResponseStruct)) { + throw new Error( + SocialServiceErrorMessage.FETCH_BLOCKED_CONTENT_INVALID_RESPONSE, + ); + } + return blockedContent; + }, + }); + } + /** * Shared helper for fetching open/closed positions. * diff --git a/packages/social-controllers/src/index.ts b/packages/social-controllers/src/index.ts index 213c9222cd4..debff5ced66 100644 --- a/packages/social-controllers/src/index.ts +++ b/packages/social-controllers/src/index.ts @@ -28,6 +28,9 @@ export type { export { SocialService } from './SocialService.js'; export type { + SocialServiceBlockAction, + SocialServiceFetchBlockedContentAction, + SocialServiceFetchBlockedProfilesAction, SocialServiceFetchClosedPositionsAction, SocialServiceFetchFeedAction, SocialServiceFetchFollowersAction, @@ -62,11 +65,18 @@ export type { } from './SocialRealtimeService.js'; export type { AuthorComment, + BlockedContent, + BlockedContentResponse, + BlockedContentType, + BlockedProfile, + BlockedProfilesResponse, + BlockOptions, CommentEngagement, CommentReaction, CommentReactionProfile, CopytradedAllTime, CreateSwapCommentOptions, + FetchBlockedListOptions, FeedActorSummary, FeedItem, FeedPagination, diff --git a/packages/social-controllers/src/social-constants.ts b/packages/social-controllers/src/social-constants.ts index 1c5f38d6970..5180acb739b 100644 --- a/packages/social-controllers/src/social-constants.ts +++ b/packages/social-controllers/src/social-constants.ts @@ -53,4 +53,12 @@ export const SocialServiceErrorMessage = { 'SocialService: Swap comment returned invalid response', NOTIFICATION_PREFERENCES_CACHE_REFRESH_FAILED: 'SocialService: Notification preferences cache refresh request failed', + BLOCK_FAILED: 'SocialService: Block request failed', + FETCH_BLOCKED_PROFILES_FAILED: + 'SocialService: Blocked profiles request failed', + FETCH_BLOCKED_PROFILES_INVALID_RESPONSE: + 'SocialService: Blocked profiles returned invalid response', + FETCH_BLOCKED_CONTENT_FAILED: 'SocialService: Blocked content request failed', + FETCH_BLOCKED_CONTENT_INVALID_RESPONSE: + 'SocialService: Blocked content returned invalid response', } as const; diff --git a/packages/social-controllers/src/social-types.ts b/packages/social-controllers/src/social-types.ts index d06f6679031..17fd1a03a34 100644 --- a/packages/social-controllers/src/social-types.ts +++ b/packages/social-controllers/src/social-types.ts @@ -601,6 +601,100 @@ export type UnfollowOptions = { targets: string[]; }; +// --------------------------------------------------------------------------- +// Moderation blocks +// --------------------------------------------------------------------------- + +/** + * Options for `PUT /v1/moderation/block`. Provide exactly one target. Blocking + * the same target again replaces `reason` when one is given. + */ +export type BlockOptions = + | { + /** Profile id (UUID) of the trader to block. */ + profileId: string; + /** Free-form reason stored on the block. Max 1000 characters. */ + reason?: string; + } + | { + /** Swap comment (post) to block. */ + commentId: string; + /** Free-form reason stored on the block. Max 1000 characters. */ + reason?: string; + } + | { + /** Comment reply to block. */ + replyId: string; + /** Free-form reason stored on the block. Max 1000 characters. */ + reason?: string; + }; + +/** + * Options for the cursor-paginated moderation list endpoints. Omit `cursor` + * for the first page. + */ +export type FetchBlockedListOptions = { + /** The `cursor` from the previous page. */ + cursor?: string; +}; + +/** + * A trader the current user has blocked. + */ +export type BlockedProfile = { + /** When the block was placed, in Unix epoch seconds. */ + blockedAt: number; + /** Reason given when the block was placed, if any. */ + reason: string | null; + /** The blocked trader. */ + profile: ProfileSummary; +}; + +/** + * Response from `GET /v1/moderation/blocks/profiles`. + */ +export type BlockedProfilesResponse = { + /** Blocked traders, most recently blocked first. */ + items: BlockedProfile[]; + /** Pass as `cursor` for the next page. `null` on the last page. */ + cursor: string | null; +}; + +/** + * Whether a blocked content row is a swap comment or a reply. + */ +export type BlockedContentType = 'comment' | 'reply'; + +/** + * A comment or reply the current user has blocked. + */ +export type BlockedContent = { + /** When the block was placed, in Unix epoch seconds. */ + blockedAt: number; + /** Reason given when the block was placed, if any. */ + reason: string | null; + /** Whether `id` refers to a swap comment or a reply. */ + type: BlockedContentType; + /** Comment or reply id (UUID). */ + id: string; + /** Text of the blocked comment or reply. */ + text: string; + /** When the content was posted, in Unix epoch seconds. */ + createdAt: number; + /** Author of the content. `null` when the profile cannot be resolved. */ + author: ProfileSummary | null; +}; + +/** + * Response from `GET /v1/moderation/blocks/content`. + */ +export type BlockedContentResponse = { + /** Blocked comments and replies, most recently blocked first. */ + items: BlockedContent[]; + /** Pass as `cursor` for the next page. `null` on the last page. */ + cursor: string | null; +}; + // --------------------------------------------------------------------------- // Controller state // --------------------------------------------------------------------------- From 02b01f3128875f0ab3332d06e486b5fa2dd87d20 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 12:58:57 +0000 Subject: [PATCH 2/2] docs(social-controllers): link moderation changelog entry to the PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: João Santos --- packages/social-controllers/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/social-controllers/CHANGELOG.md b/packages/social-controllers/CHANGELOG.md index 586460b0368..2d17f8aea4e 100644 --- a/packages/social-controllers/CHANGELOG.md +++ b/packages/social-controllers/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `block`, `fetchBlockedProfiles`, and `fetchBlockedContent` methods to `SocialService` (and the matching `SocialService:block`, `SocialService:fetchBlockedProfiles`, and `SocialService:fetchBlockedContent` messenger actions) for the social-api moderation block endpoints +- Add `block`, `fetchBlockedProfiles`, and `fetchBlockedContent` methods to `SocialService` (and the matching `SocialService:block`, `SocialService:fetchBlockedProfiles`, and `SocialService:fetchBlockedContent` messenger actions) for the social-api moderation block endpoints ([#10656](https://github.com/MetaMask/core/pull/10656)) - `block` calls `PUT /moderation/block` with exactly one of `profileId`, `commentId`, or `replyId`, plus an optional `reason`. The endpoint returns 204. - `fetchBlockedProfiles` calls `GET /moderation/blocks/profiles` and returns traders the caller has blocked, most recently blocked first. Pass `cursor` from the previous page; `cursor` is `null` on the last page. - `fetchBlockedContent` calls `GET /moderation/blocks/content` and returns comments and replies the caller has blocked, most recently blocked first, with the same cursor pagination.