// Private galleries other accounts have shared with the viewer. // GET /xrpc/social.grain.unspecced.listSharedGalleries // // The one place grain keeps something the protocol does not. A space's member // list lives with its authority and being added leaves no trace on the member's // own PDS, so a reader has nothing to enumerate — listSpaces on their server // returns only spaces they have written into, which for a reader is none. // // So grain records who a gallery was shared with as it shares it. That record // is a hint and never an authority: every row here is confirmed by actually // reading the gallery through a credential the authority issues, so a viewer // since removed from the space sees a 403 and the row is dropped from the // answer rather than shown. import { defineQuery, InvalidRequestError } from "$hatk"; import { listSpaceRecords, parseSpaceUri, SpaceError } from "../spaces/client.ts"; import { throwSpaceError } from "../spaces/errors.ts"; interface InviteRow { space: string; author_did: string; created_at: string; } export default defineQuery("social.grain.unspecced.listSharedGalleries", async (ctx) => { const { ok, db, viewer, params } = ctx; if (!viewer) throw new InvalidRequestError("Authentication required"); const limit = Math.min(Number(params.limit) || 25, 50); const rows = (await db.query( `SELECT space, author_did, created_at FROM _space_invites WHERE member_did = $1 ORDER BY created_at DESC LIMIT $2`, [viewer.did, limit], )) as InviteRow[]; if (rows.length === 0) return ok({ galleries: [] }); let galleries; try { galleries = await Promise.all( rows.map(async (row) => { let skey: string; let authority: string; try { ({ skey, authority } = parseSpaceUri(row.space)); } catch { return null; } try { const records = await listSpaceRecords( ctx, viewer.did, row.space, authority, "social.grain.gallery", ); const record = records.find((r) => r.rkey === skey) ?? records[0]; const value = record?.value as { title?: string; createdAt?: string } | undefined; return { space: row.space, skey, author: row.author_did, ...(value?.title ? { title: value.title } : {}), ...(value?.createdAt ? { createdAt: value.createdAt } : {}), }; } catch (err) { // Removed from the space, or the gallery is gone. Either way it is // not the viewer's to see, and a row we cannot read is not one to // show. A dead session is not that — it fails every row, and is // reported rather than rendered as an empty list. if (err instanceof SpaceError && err.status !== 401) return null; throw err; } }), ); } catch (err) { return throwSpaceError(err, db, viewer.did); } const items = galleries.filter((g): g is NonNullable => g !== null); // Handles, so a shared gallery says who shared it. A DID that resolves to // nothing keeps the row — the gallery is still readable. const authors = [...new Set(items.map((g) => g.author))]; const handleRows = authors.length > 0 ? ((await db.query( `SELECT did, handle FROM _repos WHERE did IN (${authors.map((_, i) => `$${i + 1}`).join(",")})`, authors, )) as { did: string; handle: string }[]) : []; const handles = new Map(handleRows.map((r) => [r.did, r.handle])); return ok({ galleries: items.map((g) => ({ ...g, ...(handles.get(g.author) ? { authorHandle: handles.get(g.author) } : {}), })), }); });