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