Something went wrong. Try again.
grain.social is a photo sharing platform built on atproto. grain.social
appview atproto photography
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221// Reading a community's pool: the shared half of listPoolGalleries (one pool)// and listPoolFeed (every pool the viewer is in).//// A pool is a permissioned space anchored on the community, holding one repo// per member who has written into it. None of it is indexed — nothing in a// space reaches a firehose — so a read means asking the space who has written,// then reading those repos on the hosts that hold them, with a credential the// community's host issues to the viewer. A viewer who is not a member never// gets the credential, which is the whole access check.
import { blobCid, listSpaceRecords, listSpaceRepos, type PdsCall, poolUri, type SpaceRecord,} from "../spaces/client.ts";
interface GalleryValue { title?: string; description?: string; createdAt?: string;}interface ItemValue { gallery?: string; item?: string; position?: number;}interface PhotoValue { photo?: unknown; alt?: string; aspectRatio?: { width: number; height: number };}interface FavoriteValue { subject?: string; createdAt?: string;}interface CommentValue { subject?: string; text?: string; facets?: unknown[]; replyTo?: string; createdAt?: string;}
export interface PoolPhoto { uri: string; did: string; cid: string; alt?: string; aspectRatio?: { width: number; height: number }; position: number;}
export interface PoolComment { /** `at://<author>/social.grain.comment/<rkey>` — the address the record would have. */ uri: string; did: string; rkey: string; text: string; facets?: unknown[]; replyTo?: string; createdAt?: string;}
export interface PoolGallery { space: string; /** The community whose pool it is in. */ group: string; /** Whose gallery it is. */ did: string; rkey: string; /** `at://<author>/social.grain.gallery/<rkey>` — what a favourite or comment names. */ uri: string; title: string; description?: string; createdAt?: string; photoCount: number; photos: PoolPhoto[]; /** Favourites and comments from inside the pool — never the public ones. */ favCount: number; /** The rkey of the viewer's own favourite, in their own repo, if they have one. */ viewerFav?: string; commentCount: number; comments: PoolComment[];}
/** An item names its gallery and its photo by the uri each would have in the author's public repo. */const selfUri = (did: string, collection: string, rkey: string) => `at://${did}/${collection}/${rkey}`;
/** * Every gallery in one community's pool, newest first. * * Throws whatever the space threw — a 401 from the authority is how "not a * member" arrives, and it is the caller's business whether that is an error * (one pool asked for by name) or a pool to skip (a feed across many). */export async function readPool( pds: PdsCall, viewerDid: string, group: string,): Promise<PoolGallery[]> { const space = poolUri(group); const repos = await listSpaceRepos(pds, viewerDid, space); const perRepo = await Promise.all( repos.map(async (repo) => { // Five collections, because that is what a gallery in a pool is made of: // the gallery, its photos, the items joining them, and what other members // have said and starred about it. Every one of them is somebody's own // record in their own repo — a comment on your gallery is in the // commenter's repo, which is why this is a read per repo and not per // gallery. const [galleries, items, photos, favorites, comments] = await Promise.all([ listSpaceRecords(pds, viewerDid, space, repo.did, "social.grain.gallery"), listSpaceRecords(pds, viewerDid, space, repo.did, "social.grain.gallery.item"), listSpaceRecords(pds, viewerDid, space, repo.did, "social.grain.photo"), listSpaceRecords(pds, viewerDid, space, repo.did, "social.grain.favorite"), listSpaceRecords(pds, viewerDid, space, repo.did, "social.grain.comment"), ]); return { did: repo.did, galleries, items, photos, favorites, comments }; }), );
// Favourites and comments are gathered across every repo first, then handed // to the gallery each one names. const favsFor = new Map<string, { did: string; rkey: string }[]>(); const commentsFor = new Map<string, PoolComment[]>(); for (const { did, favorites, comments } of perRepo) { for (const f of favorites) { const subject = (f.value as FavoriteValue).subject; if (!subject) continue; favsFor.set(subject, [...(favsFor.get(subject) ?? []), { did, rkey: f.rkey }]); } for (const c of comments) { const value = c.value as CommentValue; if (!value.subject || !value.text) continue; commentsFor.set(value.subject, [ ...(commentsFor.get(value.subject) ?? []), { uri: selfUri(did, "social.grain.comment", c.rkey), did, rkey: c.rkey, text: value.text, ...(value.facets ? { facets: value.facets } : {}), ...(value.replyTo ? { replyTo: value.replyTo } : {}), ...(value.createdAt ? { createdAt: value.createdAt } : {}), }, ]); } }
const out = perRepo.flatMap(({ did, galleries, items, photos }) => { const byUri = new Map( photos.map((p: SpaceRecord) => [selfUri(did, "social.grain.photo", p.rkey), p]), ); return galleries.map((g: SpaceRecord): PoolGallery => { const value = g.value as GalleryValue; const uri = selfUri(did, "social.grain.gallery", g.rkey); const mine = items .map((i: SpaceRecord) => i.value as ItemValue) .filter((i) => i.gallery === uri) .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); const gathered = mine.flatMap((i, index): PoolPhoto[] => { const photo = i.item ? byUri.get(i.item) : undefined; const photoValue = photo?.value as PhotoValue | undefined; const cid = blobCid(photoValue?.photo); // A photo whose record or blob is gone loses itself, not the gallery. if (!photo || !cid || !i.item) return []; return [ { uri: i.item, did, cid, ...(photoValue?.alt ? { alt: photoValue.alt } : {}), ...(photoValue?.aspectRatio ? { aspectRatio: photoValue.aspectRatio } : {}), position: i.position ?? index, }, ]; }); const favs = favsFor.get(uri) ?? []; const said = (commentsFor.get(uri) ?? []).sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""), ); const myFav = favs.find((f) => f.did === viewerDid); return { space, group, did, rkey: g.rkey, uri, title: value.title ?? "Untitled", ...(value.description ? { description: value.description } : {}), ...(value.createdAt ? { createdAt: value.createdAt } : {}), photoCount: gathered.length, photos: gathered, favCount: favs.length, ...(myFav ? { viewerFav: myFav.rkey } : {}), commentCount: said.length, comments: said, }; }); });
out.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? "")); return out;}
/** The communities whose roster the viewer is on — a public fact, already indexed. */export async function groupsOf( db: { query: (sql: string, params?: unknown[]) => Promise<unknown[]> }, viewerDid: string,): Promise<string[]> { const rows = (await db.query( `SELECT DISTINCT did FROM "community.opensocial.member" WHERE member = $1`, [viewerDid], )) as { did: string }[]; return rows.map((r) => r.did);}