0
2
import { views } from "$hatk";
import type { BaseContext, GrainActorProfile, GroupView } from "$hatk";
import { groupListing } from "../helpers/groupHost.ts";
import { lookupHandles } from "../helpers/lookupHandles.ts";
/**
* Build group views for a set of group DIDs.
*
* A group is an account that (a) declared itself a group and (b) reads
* like any other author. Its Grain profile is its identity, falling back to
* the group's own profile as its host lists it (see groupListing);
* its `social.grain.group.item` records are its pool. Who belongs is not
* public — the roster is members-only — so there is no member count, and
* whether the viewer belongs is theirs to say: `opts.mine`, the groups their
* own PDS lists (see helpers/membership.ts).
*/
export async function hydrateGroups(
ctx: BaseContext,
dids: string[],
opts: { gallery?: string; mine?: string[] } = {},
): Promise<GroupView[]> {
if (dids.length === 0) return [];
const ph = dids.map((_, i) => `$${i + 1}`).join(",");
const viewer = ctx.viewer?.did;
// One public read per group, cached: the profile and rules live in the
// group's meta space, which grain cannot read or index. Fresh for the
// group itself, which is who edits them.
const listings = new Map(
await Promise.all(
dids.map(async (did) => [did, await groupListing(did, { fresh: did === viewer })] as const),
),
);
const [profiles, handleMap, poolRows, pendingRows, submissionRows, itemRows, declineRows] =
await Promise.all([
ctx.lookup<GrainActorProfile>("social.grain.actor.profile", "did", dids),
lookupHandles(ctx.db, dids),
ctx.db.query(
`SELECT gi.did, COUNT(*) AS count, MAX(gi.created_at) AS last
FROM "social.grain.group.item" gi JOIN "social.grain.gallery" g ON g.uri = gi.gallery
WHERE gi.did IN (${ph}) GROUP BY gi.did`,
dids,
) as Promise<{ did: string; count: number; last: string }[]>,
// The queue only matters to whoever is acting as the group — the one
// viewer whose DID is the group's.
viewer && dids.includes(viewer)
? (ctx.db.query(
`SELECT COUNT(*) AS count FROM "social.grain.group.submission" s
WHERE s."group" = $1
AND NOT EXISTS (SELECT 1 FROM "social.grain.group.item" i WHERE i.did = s."group" AND i.gallery = s.gallery)
AND NOT EXISTS (SELECT 1 FROM "social.grain.group.decline" d WHERE d.did = s."group" AND d.submission = s.uri)`,
[viewer],
) as Promise<{ count: number }[]>)
: Promise.resolve([]),
viewer && opts.gallery
? (ctx.db.query(
`SELECT s.uri, s."group" AS grp FROM "social.grain.group.submission" s
WHERE s.did = $1 AND s.gallery = $2 AND s."group" IN (${dids.map((_, i) => `$${i + 3}`).join(",")})`,
[viewer, opts.gallery, ...dids],
) as Promise<{ uri: string; grp: string }[]>)
: Promise.resolve([]),
opts.gallery
? (ctx.db.query(
`SELECT uri, did FROM "social.grain.group.item" WHERE gallery = $1 AND did IN (${dids.map((_, i) => `$${i + 2}`).join(",")})`,
[opts.gallery, ...dids],
) as Promise<{ uri: string; did: string }[]>)
: Promise.resolve([]),
opts.gallery
? (ctx.db.query(
`SELECT submission, did FROM "social.grain.group.decline" WHERE gallery = $1 AND did IN (${dids.map((_, i) => `$${i + 2}`).join(",")})`,
[opts.gallery, ...dids],
) as Promise<{ submission: string; did: string }[]>)
: Promise.resolve([]),
]);
const pool = new Map(poolRows.map((r) => [r.did, r]));
const mine = new Set(opts.mine ?? []);
const submissionByGroup = new Map(submissionRows.map((r) => [r.grp, r.uri]));
const itemByGroup = new Map(itemRows.map((r) => [r.did, r.uri]));
const declined = new Set(declineRows.map((r) => r.submission));
const pending = Number(pendingRows[0]?.count ?? 0);
return dids.map((did) => {
const p = profiles.get(did);
const cp = listings.get(did);
const acting = viewer === did;
const member = mine.has(did);
const submission = submissionByGroup.get(did);
const item = itemByGroup.get(did);
const standing = submission
? {
uri: submission,
status: item ? "accepted" : declined.has(submission) ? "declined" : "pending",
...(item ? { item } : {}),
}
: item
? { uri: item, status: "accepted", item }
: undefined;
const joinPolicy = cp?.joinPolicy?.split("#").pop();
// A rule's address is its record in the meta space, which is what a
// moderation label cites.
const rules = cp?.rules?.map((r) => ({
uri: `at://${did}/space/fyi.opensocial.meta/self/${did}/fyi.opensocial.rule/${r.rkey}`,
title: r.title,
...(r.text ? { text: r.text } : {}),
}));
return views.groupView({
did,
handle: p?.handle ?? handleMap.get(did) ?? did,
displayName: p?.value.displayName ?? cp?.displayName,
description: p?.value.description ?? cp?.description,
avatar: (p ? ctx.blobUrl(did, p.value.avatar, "avatar") : undefined) ?? cp?.avatarUrl,
// Where the group is actually run. Grain shows a group; the
// group's own site is where its calendar, its boards and its
// moderation live, and this is the only place that fact is published.
...(cp?.url ? { url: cp.url } : {}),
poolCount: Number(pool.get(did)?.count ?? 0),
...(joinPolicy ? { joinPolicy } : {}),
...(rules?.length ? { rules } : {}),
...(pool.get(did)?.last ? { lastActivityAt: pool.get(did)!.last } : {}),
...(acting ? { pendingCount: pending } : {}),
...(acting || member || standing
? {
viewer: {
...(acting ? { acting } : {}),
...(member ? { member } : {}),
...(standing ? { submission: standing } : {}),
},
}
: {}),
});
});
}
/** DID or handle → DID, or null when unknown. */
export async function resolveGroupActor(ctx: BaseContext, actor: string): Promise<string | null> {
let did = actor;
if (!did.startsWith("did:")) {
const rows = (await ctx.db.query(`SELECT did FROM _repos WHERE handle = $1`, [actor])) as {
did: string;
}[];
if (!rows[0]?.did) return null;
did = rows[0].did;
}
const declared = (await ctx.db.query(
`SELECT 1 AS v FROM "fyi.opensocial.declaration" WHERE did = $1 LIMIT 1`,
[did],
)) as { v: number }[];
return declared.length ? did : null;
}