data
tcmts.examples.userinput.data · #n43ebly4 · ts file
// tcm: namespace userinput
// What each page shows, read from the network: a space and who moderates it, its board, a discussion and its
// threads, someone's notifications. Each is assembled from records in many repos, found through the backlinks that
// point at the space or discussion; the rules (rules.ts) decide what counts.
import assert from "node:assert/strict";
import {
atUri, authorOf, authorityOf, backlinkCount, backlinks, edited, getRecord, getRecords, indexed, latest, linkCounts, linkUri, listRecords,
lockedAt, moderationOf, NSID, parseAtUri, replyCount, STATES, statusOf, threads,
} from "#codebase/userinput";
import type {
Authority, BanRecord, CheckedRecord, DiscussionRecord, EditRecord, Item, LockRecord, MemberRecord, Moderation, ModerationRecord,
ReplyRecord, Role, SpaceRecord, State, StatusRecord, Stored, StrongRef, Tally, Thread, VisitedRecord, VoteRecord,
} from "#codebase/userinput";
/** The records of some backlinks. */
const recordsOf = async <T,>(links: { did: string; collection: string; rkey: string }[]): Promise<Stored<T>[]> => getRecords<T>(links.map(linkUri));
/** A space, with who has a say in it and the moderation that counts. */
export interface SpaceView {
space: Stored<SpaceRecord>;
authority: Authority;
moderation: Moderation;
/** everyone with a say, as `did` filters for the records only they can write */
moderators: string[];
}
/** A discussion or reply as userinput.app's index has it: the record (its author's edits applied), and what it adds up to. */
export interface IndexedPost<T = DiscussionRecord> {
uri: string;
cid: string;
authorDid: string;
value: T;
editedAt: string | null;
votes: { up: number; down: number };
replyCount?: number;
/** the status that counts (a moderator's latest) */
status?: { state: string; note?: string; createdAt?: string; duplicateOf?: string | StrongRef | null } | null;
hidden: boolean;
banned: boolean;
/** a reply's: the reply it answers */
parentUri?: string | null;
}
/** A board, as the index has it: the space, who moderates it, what's pinned, and every discussion. */
export interface IndexedBoard {
board: Stored<SpaceRecord>;
members: { uri: string; subject: string; role: Role }[];
pinned: string[];
posts: IndexedPost[];
}
/** A discussion's page, as the index has it: the discussion, its lock, and its replies. */
export interface IndexedThread {
space: Stored<SpaceRecord>;
lock?: string | { createdAt?: string } | null;
discussion: IndexedPost;
replies: IndexedPost<ReplyRecord>[];
}
/** The index path of a board or thread (`board/<did>/<rkey>`). */
const indexPath = (kind: "board" | "thread", uri: string): string | undefined => {
const at = parseAtUri(uri);
return at && `${kind}/${at.did}/${at.rkey}`;
};
/** A space as the index has it: who has a say (the owner, and who the owner made a moderator), and its moderation. */
export function spaceFromIndex(b: IndexedBoard): SpaceView {
const authority: Authority = { owner: authorOf(b.board.uri), roles: new Map(b.members.map((m) => [m.subject, m.role])) };
return {
space: { uri: b.board.uri, cid: b.board.cid, value: b.board.value },
authority,
moderation: {
pinned: new Set(b.pinned),
hidden: new Set(b.posts.filter((p) => p.hidden).map((p) => p.uri)),
banned: new Set(b.posts.filter((p) => p.banned).map((p) => p.authorDid)),
},
moderators: [authority.owner, ...authority.roles.keys()],
};
}
/** A status as the index has it, as a page shows it: open, unless a moderator said otherwise. */
export function statusFromIndex(s: IndexedPost["status"]): ReturnType<typeof statusOf> {
if (!s || !(STATES as readonly string[]).includes(s.state)) return { state: "open" };
const duplicateOf = typeof s.duplicateOf === "string" ? s.duplicateOf : (s.duplicateOf?.uri ?? undefined);
return { state: s.state as State, ...(s.note ? { note: s.note } : {}), ...(duplicateOf ? { duplicateOf } : {}), ...(s.createdAt ? { at: s.createdAt } : {}) };
}
/** Votes as the index counts them, with `me`'s (from their own repo, which has it before the index does). */
const tallyOf = (votes: { up: number; down: number }, mine: "up" | "down" | undefined): Tally => ({ up: votes.up, down: votes.down, score: votes.up - votes.down, mine });
/** A record as the index has it, as the pages show it. */
const shownOf = <T,>(p: IndexedPost<T>): Stored<T> & { editedAt?: string } => ({ uri: p.uri, cid: p.cid, value: p.value, ...(p.editedAt ? { editedAt: p.editedAt } : {}) });
/**
* A board from the index, as the board lists it: hidden discussions and banned people's left out, with `me`'s votes,
* and `me`'s own discussions in the space that the index hasn't read yet (just posted).
*/
export function itemsFromIndex(b: IndexedBoard, view: SpaceView, votes: Map<string, "up" | "down">, mine: Stored<DiscussionRecord>[]): Item[] {
const items: Item[] = b.posts
.filter((p) => !view.moderation.hidden.has(p.uri) && !view.moderation.banned.has(p.authorDid))
.map((p) => ({ discussion: shownOf(p), status: statusFromIndex(p.status).state, tally: tallyOf(p.votes, votes.get(p.uri)), replies: p.replyCount ?? 0, pinned: view.moderation.pinned.has(p.uri) }));
const known = new Set(b.posts.map((p) => p.uri));
for (const d of mine)
if (!known.has(d.uri) && d.value.space.uri === b.board.uri)
items.push({ discussion: d, status: "open", tally: tallyOf({ up: 0, down: 0 }, votes.get(d.uri)), replies: 0, pinned: false });
return items;
}
/** A discussion's page from the index, with `me`'s votes, and `me`'s replies to it that the index hasn't read yet. */
export function discussionFromIndex(t: IndexedThread, view: SpaceView, votes: Map<string, "up" | "down">, mine: Stored<ReplyRecord>[]): DiscussionView {
const replies: ShownReply[] = t.replies.map((r) => {
const shown = shownOf(r);
// (the reply it answers, if the record doesn't say)
const value = r.parentUri && !shown.value.parent ? { ...shown.value, parent: { uri: r.parentUri, cid: "" } } : shown.value;
return { ...shown, value, tally: tallyOf(r.votes, votes.get(r.uri)) };
});
const known = new Set(replies.map((r) => r.uri));
for (const r of mine) if (r.value.subject.uri === t.discussion.uri && !known.has(r.uri)) replies.push({ ...r, tally: tallyOf({ up: 0, down: 0 }, votes.get(r.uri)) });
const moderation = {
...view.moderation,
hidden: new Set([...view.moderation.hidden, ...t.replies.filter((r) => r.hidden).map((r) => r.uri)]),
banned: new Set([...view.moderation.banned, ...t.replies.filter((r) => r.banned).map((r) => r.authorDid)]),
};
const locked = typeof t.lock === "string" ? t.lock : (t.lock?.createdAt ?? undefined);
const ts = threads(replies, moderation, locked);
return {
view,
discussion: shownOf(t.discussion),
status: statusFromIndex(t.discussion.status),
locked,
tally: tallyOf(t.discussion.votes, votes.get(t.discussion.uri)),
threads: ts,
replies: replyCount(ts),
votes: new Map(replies.map((r) => [r.uri, r.tally])),
};
}
/** Read a discussion's thread from the index ahead (a pointer on its row): opened, it's there. */
export function readAhead(discussionUri: string): void {
const path = indexPath("thread", discussionUri);
if (path) void indexed<IndexedThread>(path);
}
/** A space by its URI, with its moderation; undefined if it's gone. From the index if it has it, in one request. */
export async function loadSpace(uri: string): Promise<SpaceView | undefined> {
const path = indexPath("board", uri);
const b = path ? await indexed<IndexedBoard>(path) : undefined;
if (b) return spaceFromIndex(b);
const space = await getRecord<SpaceRecord>(uri);
if (!space) return undefined;
const owner = authorOf(uri);
const members = await recordsOf<MemberRecord>(await backlinks(uri, NSID.member, "space.uri", { dids: [owner] }));
const authority = authorityOf(uri, members);
const moderators = [owner, ...authority.roles.keys()];
const [pins, hides, bans] = await Promise.all([
backlinks(uri, NSID.pin, "space.uri", { dids: moderators }).then(recordsOf<ModerationRecord>),
backlinks(uri, NSID.hide, "space.uri", { dids: moderators }).then(recordsOf<ModerationRecord>),
backlinks(uri, NSID.ban, "space.uri", { dids: moderators }).then(recordsOf<BanRecord>),
]);
return { space, authority, moderation: moderationOf(authority, pins, hides, bans), moderators };
}
/** How `me` has voted, by what each vote is on: read from their repo once, not a subject at a time. */
export async function myVotes(me: string): Promise<Map<string, "up" | "down">> {
const [up, down] = await Promise.all([listRecords<VoteRecord>(me, NSID.upvote, 5000), listRecords<VoteRecord>(me, NSID.downvote, 5000)]);
return new Map([...down.map((v) => [v.value.subject.uri, "down"] as const), ...up.map((v) => [v.value.subject.uri, "up"] as const)]);
}
/** How `me` voted on a subject. */
export async function myVote(subjectUri: string, me: string | undefined): Promise<"up" | "down" | undefined> {
return me ? (await myVotes(me)).get(subjectUri) : undefined;
}
/** How many records of a kind point at something, from its counts. */
export const countOf = (counts: Map<string, number>, collection: string, path = "subject.uri"): number => counts.get(`${collection}:${path}`) ?? 0;
/** A subject's votes: how many each way (from its counts), and `me`'s. */
export async function votesOn(subjectUri: string, me: string | undefined): Promise<Tally> {
const [counts, mine] = await Promise.all([linkCounts(subjectUri), myVote(subjectUri, me)]);
const up = countOf(counts, NSID.upvote);
const down = countOf(counts, NSID.downvote);
return { up, down, score: up - down, mine };
}
/** Records of a kind pointing at a subject, by some authors; none asked for if its counts say there are none. */
async function linked<T>(counts: Map<string, number>, subject: string, collection: string, dids: string[]): Promise<Stored<T>[]> {
return countOf(counts, collection) ? recordsOf<T>(await backlinks(subject, collection, "subject.uri", { dids })) : [];
}
/**
* What a space's moderators wrote of a kind (statuses, locks), by what it's about: read from their repos once for
* every discussion, rather than asked about each.
*/
export async function byModerators<T extends { subject: StrongRef }>(view: SpaceView, collection: string): Promise<Map<string, Stored<T>[]>> {
const all = (await Promise.all(view.moderators.map((m) => listRecords<T>(m, collection, 5000)))).flat();
const out = new Map<string, Stored<T>[]>();
for (const r of all) out.set(r.value.subject.uri, [...(out.get(r.value.subject.uri) ?? []), r]);
return out;
}
/** A discussion's own edits (by its author), its status and its lock. */
export async function discussionState(d: Stored<DiscussionRecord>, view: SpaceView) {
const [counts, statuses, locks] = await Promise.all([
linkCounts(d.uri),
byModerators<StatusRecord>(view, NSID.status),
byModerators<LockRecord>(view, NSID.lock),
]);
const edits = await linked<EditRecord>(counts, d.uri, NSID.edit, [authorOf(d.uri)]);
return { discussion: edited(d, edits), status: statusOf(statuses.get(d.uri) ?? [], view.authority), locked: lockedAt(locks.get(d.uri) ?? [], view.authority) };
}
/**
* A space's board: every discussion that counts (not hidden, not by someone banned), as the board lists it. What
* doesn't depend on the space's moderation (the discussions, their counts, your votes) is read while it is.
*/
export async function loadBoard(spaceUri: string, me: string | undefined): Promise<Item[]> {
// the index, in one request, with `me`'s votes and posts from their own repo, read meanwhile
const voting = me ? myVotes(me).catch(() => new Map<string, "up" | "down">()) : Promise.resolve(new Map<string, "up" | "down">());
const posting = me ? listRecords<DiscussionRecord>(me, NSID.discussion).catch(() => []) : Promise.resolve([]);
const path = indexPath("board", spaceUri);
const b = path ? await indexed<IndexedBoard>(path) : undefined;
if (b) return itemsFromIndex(b, spaceFromIndex(b), await voting, await posting);
// else from the network, record by record
const viewing = loadSpace(spaceUri);
const links = await backlinks(spaceUri, NSID.discussion, "space.uri", { most: 2000 });
const reading = recordsOf<DiscussionRecord>(links);
for (const l of links) void linkCounts(linkUri(l)).catch(() => undefined);
const view = await viewing;
if (!view) return [];
const shown = new Set(links.filter((l) => !view.moderation.banned.has(l.did) && !view.moderation.hidden.has(linkUri(l))).map(linkUri));
const discussions = await reading;
return Promise.all(
discussions
.filter((d) => shown.has(d.uri) && d.value.space.uri === spaceUri)
.map(async (d): Promise<Item> => {
const [state, votes, counts] = await Promise.all([discussionState(d, view), votesOn(d.uri, me), linkCounts(d.uri)]);
return { discussion: state.discussion, status: state.status.state, tally: votes, replies: countOf(counts, NSID.reply), pinned: view.moderation.pinned.has(d.uri) };
}),
);
}
/** A reply as a thread shows it: its author's edits applied, and its votes. */
export type ShownReply = Stored<ReplyRecord> & { editedAt?: string; tally: Tally };
/** A discussion page: the discussion as it reads now, its status, whether it's locked, and its replies as threads. */
export interface DiscussionView {
view: SpaceView;
discussion: Stored<DiscussionRecord> & { editedAt?: string };
status: ReturnType<typeof statusOf>;
locked?: string;
tally: Tally;
threads: Thread[];
replies: number;
/** every reply's votes, by URI */
votes: Map<string, Tally>;
}
/** A discussion by its URI, with everything its page shows; undefined if it or its space is gone. */
export async function loadDiscussion(uri: string, me: string | undefined): Promise<DiscussionView | undefined> {
// the index's thread, and its space's board (read already, coming from it), with `me`'s votes and replies
const myVoting = me ? myVotes(me).catch(() => new Map<string, "up" | "down">()) : Promise.resolve(new Map<string, "up" | "down">());
const myReplying = me ? listRecords<ReplyRecord>(me, NSID.reply).catch(() => []) : Promise.resolve([]);
const path = indexPath("thread", uri);
const t = path ? await indexed<IndexedThread>(path) : undefined;
const indexedView = t && (await loadSpace(t.space.uri));
if (t && indexedView) return discussionFromIndex(t, indexedView, await myVoting, await myReplying);
// else from the network, record by record
const d = await getRecord<DiscussionRecord>(uri);
if (!d) return undefined;
// (its replies and votes don't wait for the space)
const viewing = loadSpace(d.value.space.uri);
const voting = votesOn(uri, me);
const replying = backlinks(uri, NSID.reply, "subject.uri", { most: 2000 }).then(recordsOf<ReplyRecord>);
const view = await viewing;
if (!view) return undefined;
const [state, votes, replyRecords] = await Promise.all([discussionState(d, view), voting, replying]);
const shown = await Promise.all(
replyRecords.map(async (r) => {
const counts = await linkCounts(r.uri);
const [edits, t] = await Promise.all([linked<EditRecord>(counts, r.uri, NSID.edit, [authorOf(r.uri)]), votesOn(r.uri, me)]);
return { ...edited(r, edits), tally: t };
}),
);
const ts = threads(shown, view.moderation, state.locked);
return {
view,
discussion: state.discussion,
status: state.status,
locked: state.locked,
tally: votes,
threads: ts,
replies: replyCount(ts),
votes: new Map(shown.map((r) => [r.uri, r.tally])),
};
}
/** Something that happened to what you posted. */
export interface Notification {
kind: "reply" | "status";
/** the reply, or the status record */
uri: string;
by: string;
at: string;
/** the discussion it's about */
discussion: Stored<DiscussionRecord>;
text: string;
state?: State;
}
/** When `me` last looked at their notifications. */
export async function checkedAt(me: string): Promise<string | undefined> {
return (await getRecord<CheckedRecord>(atUri(me, NSID.checked, "self")))?.value.checkedAt;
}
/** What happened to `me`'s discussions and replies: replies to them, and statuses set on them; newest first. */
export async function loadNotifications(me: string): Promise<Notification[]> {
const [mine, myReplies] = await Promise.all([listRecords<DiscussionRecord>(me, NSID.discussion, 200), listRecords<ReplyRecord>(me, NSID.reply, 400)]);
const out: Notification[] = [];
await Promise.all(
mine.map(async (d) => {
const [replies, statuses] = await Promise.all([
backlinks(d.uri, NSID.reply, "subject.uri", { most: 200 }).then(recordsOf<ReplyRecord>),
backlinks(d.uri, NSID.status, "subject.uri", { most: 50 }).then(recordsOf<StatusRecord>),
]);
const view = statuses.length ? await loadSpace(d.value.space.uri) : undefined;
for (const r of replies) if (authorOf(r.uri) !== me && !r.value.parent) out.push({ kind: "reply", uri: r.uri, by: authorOf(r.uri), at: r.value.createdAt, discussion: d, text: r.value.body });
for (const s of view ? statuses.filter((x) => view.moderators.includes(authorOf(x.uri)) && authorOf(x.uri) !== me) : [])
out.push({ kind: "status", uri: s.uri, by: authorOf(s.uri), at: s.value.createdAt, discussion: d, text: s.value.note ?? "", state: s.value.state });
}),
);
// replies to my replies, wherever they are
await Promise.all(
myReplies.map(async (mineReply) => {
const answers = await backlinks(mineReply.uri, NSID.reply, "parent.uri", { most: 100 }).then(recordsOf<ReplyRecord>);
const d = answers.length ? await getRecord<DiscussionRecord>(mineReply.value.subject.uri) : undefined;
if (d) for (const a of answers) if (authorOf(a.uri) !== me) out.push({ kind: "reply", uri: a.uri, by: authorOf(a.uri), at: a.value.createdAt, discussion: d, text: a.value.body });
}),
);
return [...new Map(out.map((n) => [n.uri, n])).values()].sort((a, b) => b.at.localeCompare(a.at));
}
/** A space someone has been to, and when they last were. */
export interface VisitedSpace {
space: Stored<SpaceRecord>;
lastVisited: string;
}
/** The spaces `me` has been to, most recent first (the ones still there). */
export async function loadVisited(me: string): Promise<VisitedSpace[]> {
const visits = await listRecords<VisitedRecord>(me, NSID.visited);
const spaces = await getRecords<SpaceRecord>(visits.map((v) => v.value.space.uri));
return spaces
.map((s) => ({ space: s, lastVisited: visits.find((v) => v.value.space.uri === s.uri)!.value.lastVisited }))
.sort((a, b) => b.lastVisited.localeCompare(a.lastVisited));
}
/** The spaces someone made. */
export const loadOwnSpaces = (did: string): Promise<Stored<SpaceRecord>[]> => listRecords<SpaceRecord>(did, NSID.space);
/** Someone's discussions, newest first. */
export async function loadPostedBy(did: string): Promise<Stored<DiscussionRecord>[]> {
return (await listRecords<DiscussionRecord>(did, NSID.discussion, 300)).sort((a, b) => b.value.createdAt.localeCompare(a.value.createdAt));
}
/** How many discussions a space has. */
export const discussionCount = (spaceUri: string): Promise<number> => backlinkCount(spaceUri, NSID.discussion, "space.uri");
/** The newest of someone's records of a kind, if any. */
export async function newestOf<T extends { createdAt: string }>(did: string, collection: string): Promise<Stored<T> | undefined> {
return latest(await listRecords<T>(did, collection, 100));
}
/** The index's board, as the board shows it: what counts, pinned, with your votes, and your post it hasn't read yet */
export const testBoardFromTheIndex = () => {
const space = "at://did:plc:owner/app.userinput.space/s1";
const ref = { uri: space, cid: "c" };
const post = (rkey: string, by: string, extra: Partial<IndexedPost> = {}): IndexedPost => ({
uri: `at://${by}/app.userinput.discussion/${rkey}`, cid: "c", authorDid: by, editedAt: null, votes: { up: 3, down: 1 }, replyCount: 2, status: null, hidden: false, banned: false,
value: { space: ref, title: rkey, createdAt: "2026-01-01T00:00:00Z" } as DiscussionRecord, ...extra,
});
const b: IndexedBoard = {
board: { uri: space, cid: "c", value: { name: "s" } as SpaceRecord },
members: [{ uri: "at://did:plc:owner/app.userinput.member/m", subject: "did:plc:mod", role: "moderator" }],
pinned: [post("a", "did:plc:x").uri],
posts: [
post("a", "did:plc:x", { status: { state: "planned", note: "soon", createdAt: "2026-02-01T00:00:00Z" }, editedAt: "2026-01-02T00:00:00Z" }),
post("b", "did:plc:y", { hidden: true }),
post("c", "did:plc:banned", { banned: true }),
post("d", "did:plc:z", { status: { state: "nonsense" } }),
],
};
const view = spaceFromIndex(b);
assert.deepEqual([...view.authority.roles], [["did:plc:mod", "moderator"]]);
assert.deepEqual(view.moderators, ["did:plc:owner", "did:plc:mod"]);
const mine = { uri: "at://did:plc:me/app.userinput.discussion/new", cid: "c", value: { space: ref, title: "new", createdAt: "2026-03-01T00:00:00Z" } as DiscussionRecord };
const items = itemsFromIndex(b, view, new Map([[b.posts[0]!.uri, "up"]]), [mine]);
assert.deepEqual(items.map((i) => i.discussion.value.title), ["a", "d", "new"], "hidden and banned left out, mine added");
assert.deepEqual(items[0]!.tally, { up: 3, down: 1, score: 2, mine: "up" });
assert.equal(items[0]!.status, "planned");
assert.equal(items[0]!.pinned, true);
assert.equal(items[0]!.discussion.editedAt, "2026-01-02T00:00:00Z");
assert.equal(items[1]!.status, "open", "a state it doesn't know is open");
assert.deepEqual(statusFromIndex(b.posts[0]!.status), { state: "planned", note: "soon", at: "2026-02-01T00:00:00Z" });
};
/** The index's thread, as the page shows it: replies threaded by the one they answer, hidden ones left out, locked */
export const testThreadFromTheIndex = () => {
const space = "at://did:plc:owner/app.userinput.space/s1";
const d = "at://did:plc:x/app.userinput.discussion/d";
const reply = (rkey: string, extra: Partial<IndexedPost<ReplyRecord>> = {}): IndexedPost<ReplyRecord> => ({
uri: `at://did:plc:r/app.userinput.reply/${rkey}`, cid: "c", authorDid: "did:plc:r", editedAt: null, votes: { up: 1, down: 0 }, hidden: false, banned: false, parentUri: null,
value: { subject: { uri: d, cid: "c" }, body: rkey, createdAt: `2026-01-0${rkey.length}T00:00:00Z` } as ReplyRecord, ...extra,
});
const top = reply("a");
const t: IndexedThread = {
space: { uri: space, cid: "c", value: { name: "s" } as SpaceRecord },
lock: null,
discussion: { uri: d, cid: "c", authorDid: "did:plc:x", editedAt: null, votes: { up: 2, down: 0 }, replyCount: 2, status: null, hidden: false, banned: false, value: { space: { uri: space, cid: "c" }, title: "d", createdAt: "2026-01-01T00:00:00Z" } as DiscussionRecord },
replies: [top, reply("bb", { parentUri: top.uri }), reply("ccc", { hidden: true })],
};
const view = spaceFromIndex({ board: t.space, members: [], pinned: [], posts: [] });
const page = discussionFromIndex(t, view, new Map(), []);
assert.equal(page.threads.length, 1);
assert.deepEqual(page.threads[0]!.children.map((c) => c.reply.value.body), ["bb"]);
assert.equal(page.replies, 2, "the hidden one isn't counted");
assert.deepEqual(page.tally, { up: 2, down: 0, score: 2, mine: undefined });
assert.equal(discussionFromIndex({ ...t, lock: { createdAt: "2026-01-01T12:00:00Z" } }, view, new Map(), []).replies, 1, "nothing after the lock");
};
used by