diff --git a/src/lib/api/coves/client.test.ts b/src/lib/api/coves/client.test.ts --- a/src/lib/api/coves/client.test.ts +++ b/src/lib/api/coves/client.test.ts @@ -275,11 +275,52 @@ uri: 'at://did:plc:abc/post/1', }) }) - it('getPost() calls query with correct NSID', async () => { - await client.getPost({ uri: 'at://did:plc:abc/post/1' as AtUri }) + it('getPosts() calls query with a repeated uris array', async () => { + await client.getPosts({ + uris: [ + 'at://did:plc:abc/post/1' as AtUri, + 'at://did:plc:abc/post/2' as AtUri, + ], + }) expect(querySpy).toHaveBeenCalledWith(NSID.getPost, { - uri: 'at://did:plc:abc/post/1', + uris: ['at://did:plc:abc/post/1', 'at://did:plc:abc/post/2'], }) + }) + + it('getPost() wraps the batch endpoint with a single URI', async () => { + const post = { uri: 'at://did:plc:abc/post/1', cid: 'bafy1' } + querySpy.mockResolvedValueOnce({ posts: [post] }) + + const result = await client.getPost('at://did:plc:abc/post/1' as AtUri) + + expect(querySpy).toHaveBeenCalledWith(NSID.getPost, { + uris: ['at://did:plc:abc/post/1'], + }) + expect(result).toBe(post) + }) + + it('getPost() throws when the batch endpoint returns an empty array', async () => { + querySpy.mockResolvedValueOnce({ posts: [] }) + + await expect( + client.getPost('at://did:plc:abc/post/1' as AtUri), + ).rejects.toThrow(/0 posts, expected exactly 1/) + }) + + it('getPost() throws when posts is missing from the response', async () => { + querySpy.mockResolvedValueOnce({}) + + await expect( + client.getPost('at://did:plc:abc/post/1' as AtUri), + ).rejects.toThrow(/non-array posts field, expected exactly 1/) + }) + + it('getPost() throws when posts is not an array', async () => { + querySpy.mockResolvedValueOnce({ posts: null }) + + await expect( + client.getPost('at://did:plc:abc/post/1' as AtUri), + ).rejects.toThrow(/non-array posts field, expected exactly 1/) }) }) diff --git a/src/lib/api/coves/client.ts b/src/lib/api/coves/client.ts --- a/src/lib/api/coves/client.ts +++ b/src/lib/api/coves/client.ts @@ -21,13 +21,14 @@ GetCommentsParams, GetCommentsResponse, GetCommunityFeedParams, GetCommunityParams, - GetPostParams, + GetPostsParams, + GetPostsResponse, GetDiscoverParams, GetProfileParams, GetTimelineParams, ListCommunitiesParams, ListCommunitiesResponse, - PostView, + PostViewUnion, ProfileViewDetailed, SearchCommunitiesParams, SubscribeCommunityInput, @@ -171,7 +172,27 @@ deletePost(input: { uri: AtUri }): Promise { return this.xrpc.procedure(NSID.deletePost, input) } - getPost(params: GetPostParams): Promise { + // The wired endpoint is batch (1–25 URIs); `posts` mirrors `uris` order 1:1. + getPosts(params: GetPostsParams): Promise { return this.xrpc.query(NSID.getPost, params) + } + + // Single-URI convenience wrapper over the batch endpoint. + // Contract: 1 URI in ⇒ exactly 1 element out. A short/empty array (or a + // non-array `posts`) is a backend contract violation, not a "removed" post, + // so we throw rather than silently returning undefined. + async getPost(uri: AtUri): Promise { + const { posts } = await this.getPosts({ uris: [uri] }) + if (!Array.isArray(posts)) { + throw new Error( + `getPost(${uri}): batch endpoint returned a non-array posts field, expected exactly 1`, + ) + } + if (posts.length === 0) { + throw new Error( + `getPost(${uri}): batch endpoint returned 0 posts, expected exactly 1`, + ) + } + return posts[0] } } diff --git a/src/lib/api/coves/types.test.ts b/src/lib/api/coves/types.test.ts --- a/src/lib/api/coves/types.test.ts +++ b/src/lib/api/coves/types.test.ts @@ -7,8 +7,9 @@ parseAtUri, isValidCID, asCID, tryAsCID, + isHydratedPost, } from './types' -import type { AtUri } from './types' +import type { AtUri, CID, PostView, PostViewUnion } from './types' // --------------------------------------------------------------------------- // isValidAtUri @@ -186,3 +187,50 @@ it('returns null for a string with invalid characters', () => { expect(tryAsCID('bafy!invalid')).toBeNull() }) }) + +// --------------------------------------------------------------------------- +// isHydratedPost +// --------------------------------------------------------------------------- + +describe('isHydratedPost', () => { + const postView = { + uri: 'at://did:plc:abc/social.coves.community.post/1' as AtUri, + cid: 'bafyreib2rxk3rybsftg4qpz' as CID, + } as PostView + + it('accepts a hydrated post view', () => { + expect(isHydratedPost(postView)).toBe(true) + }) + + it('rejects a notFound sentinel', () => { + const el: PostViewUnion = { + uri: 'at://did:plc:abc/social.coves.community.post/2' as AtUri, + notFound: true, + } + expect(isHydratedPost(el)).toBe(false) + }) + + it('rejects a blocked sentinel', () => { + const el: PostViewUnion = { + uri: 'at://did:plc:abc/social.coves.community.post/3' as AtUri, + blocked: true, + } + expect(isHydratedPost(el)).toBe(false) + }) + + it('treats an element without a known unavailable flag as a post', () => { + // Flag-negative discrimination: only the documented sentinels + // (notFound/blocked) are unavailable. A real-but-malformed post that arrives + // missing `cid` is still a post — it must not be silently reclassified as + // removed (the failure mode of probing for a positive `cid` shape). + const el = { + uri: 'at://did:plc:abc/social.coves.community.post/4' as AtUri, + } as unknown as PostViewUnion + expect(isHydratedPost(el)).toBe(true) + }) + + it('rejects null and undefined', () => { + expect(isHydratedPost(null)).toBe(false) + expect(isHydratedPost(undefined)).toBe(false) + }) +}) diff --git a/src/lib/api/coves/types.ts b/src/lib/api/coves/types.ts --- a/src/lib/api/coves/types.ts +++ b/src/lib/api/coves/types.ts @@ -145,6 +145,46 @@ viewer?: PostViewerState stats?: PostStats } +/** A requested post that could not be hydrated (deleted/unindexed/unresolvable). */ +export interface NotFoundPost { + uri: AtUri + notFound: true +} + +/** + * A requested post withheld because the viewer blocks its author. Emitted by the + * backend's blocked-by-author path. The response preserves the requested URI's + * position in the `posts` array. + */ +export interface BlockedPost { + uri: AtUri + blocked: true + blockedBy?: DID + author?: { did: DID } +} + +/** + * One element of `getPosts` — either a hydrated post or an unavailable sentinel. + * The response preserves the order of the requested URIs. + */ +export type PostViewUnion = PostView | NotFoundPost | BlockedPost + +/** + * Discriminates a hydrated post from the unavailable sentinels. Mirrors the + * backend's flag-based contract: each sentinel carries a documented const + * discriminator (`notFound` / `blocked`) and a hydrated `PostView` carries + * neither. We therefore treat the *absence* of every known unavailable flag as + * "this is a post", which (a) narrows correctly via TS control flow and (b) + * keeps a real-but-malformed post (e.g. one momentarily missing `cid`) from + * being silently misclassified as removed. A future sentinel must register its + * flag here, matching how the backend would emit it. + */ +export function isHydratedPost( + el: PostViewUnion | null | undefined, +): el is PostView { + return el != null && !('notFound' in el) && !('blocked' in el) +} + // --------------------------------------------------------------------------- // Feed wrapper types // --------------------------------------------------------------------------- @@ -402,7 +442,9 @@ cursor?: string } export interface GetCommentsResponse { - post: PostRef + // The backend returns the full hydrated post here, not just a strong ref — + // so it could serve as a fallback source for the post on the permalink page. + post: PostView comments: ThreadViewComment[] cursor?: string } @@ -494,8 +536,13 @@ // --------------------------------------------------------------------------- // Request / response types — post retrieval // --------------------------------------------------------------------------- -export interface GetPostParams { - uri: AtUri +/** 1–25 URIs; the response `posts` array mirrors this order 1:1. */ +export interface GetPostsParams { + uris: AtUri[] +} + +export interface GetPostsResponse { + posts: PostViewUnion[] } // --------------------------------------------------------------------------- diff --git a/src/lib/api/coves/xrpc.test.ts b/src/lib/api/coves/xrpc.test.ts --- a/src/lib/api/coves/xrpc.test.ts +++ b/src/lib/api/coves/xrpc.test.ts @@ -79,6 +79,37 @@ expect(url.searchParams.get('limit')).toBe('10') expect(url.searchParams.get('sort')).toBe('hot') }) + it('serializes array params as repeated keys', async () => { + const mockFetch = createMockFetch({ posts: [] }) + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + await client.query('social.coves.community.post.get', { + uris: ['at://did:plc:a/post/1', 'at://did:plc:b/post/2'], + }) + + const calledUrl = (mockFetch as ReturnType).mock + .calls[0][0] as string + const url = new URL(calledUrl) + expect(url.searchParams.getAll('uris')).toEqual([ + 'at://did:plc:a/post/1', + 'at://did:plc:b/post/2', + ]) + }) + + it('throws when an array param contains a nullish element', async () => { + // A hole in an array param would silently send a shorter list and desync + // any positional 1:1 response array (e.g. getPosts' `posts`). Fail fast. + const mockFetch = createMockFetch({ posts: [] }) + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + await expect( + client.query('social.coves.community.post.get', { + uris: ['at://did:plc:a/post/1', undefined, 'at://did:plc:b/post/2'], + }), + ).rejects.toThrow('nullish element') + expect(mockFetch).not.toHaveBeenCalled() + }) + it('skips undefined and null params', async () => { const mockFetch = createMockFetch({ items: [] }) client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) diff --git a/src/lib/api/coves/xrpc.ts b/src/lib/api/coves/xrpc.ts --- a/src/lib/api/coves/xrpc.ts +++ b/src/lib/api/coves/xrpc.ts @@ -34,7 +34,26 @@ for (const [key, value] of Object.entries( params as Record, )) { if (value === undefined || value === null) continue - searchParams.set(key, String(value)) + // ATProto serializes array params as repeated keys (`uris=…&uris=…`), + // not a comma-joined string. Append each element under the same key. + if (Array.isArray(value)) { + for (const item of value) { + if (item === undefined || item === null) { + // A hole in an array param (e.g. from a caller's `.map()` that + // yielded `undefined`) would silently send a shorter list, + // desyncing any positional 1:1 response array (such as + // getPosts' `posts`). Fail fast on the programming error rather + // than quietly truncating. + throw new Error( + `[XrpcClient] Array param "${key}" contains a nullish element; ` + + `filter the array before calling so positional responses stay aligned.`, + ) + } + searchParams.append(key, String(item)) + } + } else { + searchParams.set(key, String(value)) + } } url.search = searchParams.toString() } diff --git a/src/lib/feature/community/CommunityCard.svelte b/src/lib/feature/community/CommunityCard.svelte --- a/src/lib/feature/community/CommunityCard.svelte +++ b/src/lib/feature/community/CommunityCard.svelte @@ -81,19 +81,30 @@