0
2
// The pools the viewer is in, as one feed.
// GET /xrpc/social.grain.unspecced.listPoolFeed
//
// Not a `defineFeed`: those are SQL over the index with a cursor, and there is
// no index here to page through. This is assembled per viewer, per request —
// which is also why it cannot be shared, cached or handed to anyone else.
//
// Two halves, both private. *Which* groups the viewer belongs to comes
// from their own PDS — the members spaces they have written an acceptance
// into — each confirmed with the group's host (see helpers/membership.ts).
// Signed in as a group, it is the group's own pool: a group belongs to no
// groups, and its pool is the one it keeps.
// What is in each group's pool is a credentialed read per pool, and a pool
// that refuses is dropped rather than failing the feed: a group need not
// keep a pool at all.
import { defineQuery, InvalidRequestError } from "$hatk";
import type { GrainActorProfile } from "$hatk";
import { hydrateGroups } from "../hydrate/groups.ts";
import { lookupHandles } from "../helpers/lookupHandles.ts";
import { groupsOf, isGroup } from "../helpers/membership.ts";
import { type PoolGallery, readPool } from "../helpers/pool.ts";
import { throwSpaceError } from "../spaces/errors.ts";
export default defineQuery("social.grain.unspecced.listPoolFeed", async (ctx) => {
const { ok, db, viewer, params } = ctx;
if (!viewer) throw new InvalidRequestError("Authentication required");
const limit = Math.min(Number(params.limit) || 30, 100);
const groups = (await isGroup(db, viewer.did))
? [viewer.did]
: await groupsOf(db, ctx, viewer.did);
if (groups.length === 0) return ok({ galleries: [], groups: [] });
// One pool at a time would be one round trip to two hosts per group; they do
// not depend on each other, so they go together. `allSettled`, because a
// refusal from one group's host is a pool to skip.
const settled = await Promise.allSettled(groups.map((group) => readPool(ctx, viewer.did, group)));
// Every pool refusing is not "an empty feed" — it is the session or the
// network, and it gets reported like any other failed space read.
const reachable = settled.filter((r) => r.status === "fulfilled");
if (reachable.length === 0 && settled.length > 0) {
return throwSpaceError((settled[0] as PromiseRejectedResult).reason, db, viewer.did);
}
const galleries: PoolGallery[] = reachable
.flatMap((r) => (r as PromiseFulfilledResult<PoolGallery[]>).value)
.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""))
.slice(0, limit);
const dids = [...new Set(galleries.map((g) => g.did))];
const shown = [...new Set(galleries.map((g) => g.group))];
const [handles, profiles, groupViews] = await Promise.all([
lookupHandles(db, dids),
ctx.lookup<GrainActorProfile>("social.grain.actor.profile", "did", dids),
hydrateGroups(ctx, shown, { mine: groups }),
]);
return ok({
galleries: galleries.map((g) => {
const profile = profiles.get(g.did);
const name = profile?.value.displayName;
const avatar = profile?.value.avatar
? ctx.blobUrl(g.did, profile.value.avatar, "avatar")
: undefined;
return {
space: g.space,
group: g.group,
did: g.did,
rkey: g.rkey,
title: g.title,
...(g.description ? { description: g.description } : {}),
...(g.createdAt ? { createdAt: g.createdAt } : {}),
uri: g.uri,
photoCount: g.photoCount,
favCount: g.favCount,
...(g.viewerFav ? { viewerFav: g.viewerFav } : {}),
commentCount: g.commentCount,
items: g.photos,
...(handles.get(g.did) ? { handle: handles.get(g.did) } : {}),
...(avatar ? { avatar } : {}),
...(name ? { displayName: name } : {}),
};
}),
// The pools these came out of, so each card can say whose it is.
groups: groupViews,
});
});