Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241import type { BobbinContext } from "$lib/api/client";import type { CommentListPage, CommentRecord } from "$lib/api/comment";import { COMMENT_AUTHOR_DOCS } from "$lib/api/descriptors";import { authorOf, enrich, target, type Sidecar } from "$lib/api/enrich";import { INVALID_HANDLE } from "$lib/api/identity";import { orNull } from "$lib/api/load";import { rkeyFromUri } from "$lib/api/uri";import { renderMarkup, type MarkupContext } from "$lib/markup";import type { CommitSummary } from "$lib/api/repo";import type { ReactionGroup } from "$lib/components/reaction/reactions";
export interface CommentView { uri: string; cid?: string; rkey: string; authorDid: string; authorHandle: string; createdAt: string; // the appview's comment header shows "Edited <time>" in place of the created one editedAt?: string; body: string; bodyHtml: string | null; // carried so an edit rewrites the record without dropping what it pinned embed?: CommentRecord["embed"]; reactions?: ReactionGroup[]; deleted?: boolean;}
export interface CommentThread { self: CommentView; replies: CommentView[];}
export interface ThreadInput { comment: CommentView; replyTo: string | null; // lets us reply to a parent even after it's deleted replyToCid?: string;}
const byCreatedAt = (a: CommentView, b: CommentView) => a.createdAt.localeCompare(b.createdAt);
// stand-in for a deleted parentexport function deletedComment(uri: string, createdAt: string, cid?: string): CommentView { return { uri, cid, rkey: rkeyFromUri(uri), authorDid: "", authorHandle: "", createdAt, body: "", bodyHtml: null, deleted: true };}
export function buildCommentThreads(inputs: ThreadInput[]): CommentThread[] { const threads = new Map<string, CommentThread>();
for (const { comment, replyTo } of inputs) { if (replyTo === null) { threads.set(comment.uri, { self: comment, replies: [] }); } }
for (const { comment, replyTo, replyToCid } of inputs) { if (replyTo === null) continue; let parent = threads.get(replyTo); if (!parent) { parent = { self: deletedComment(replyTo, comment.createdAt), replies: [] }; threads.set(replyTo, parent); } if (parent.self.deleted && replyToCid && !parent.self.cid) { parent.self.cid = replyToCid; } parent.replies.push(comment); }
const list = [...threads.values()].sort((a, b) => byCreatedAt(a.self, b.self)); for (const thread of list) thread.replies.sort(byCreatedAt); return list;}
export interface CommentGroup { /** version this group follows; undefined for the comments that predate v0 */ version?: number; comments: CommentView[];}
/** * A comment belongs to the newest version created at or before it. There is no * "commented on version N" field on the record, and there will not be one -- * createdAt on both sides is the only ordering there is. * * `versions` must be in chronological order, as `pull.versions` is. */export function groupCommentsByVersion( comments: CommentView[], versions: { createdAt: string }[]): CommentGroup[] { // slot 0 holds anything older than v0 and renders without a header const groups: CommentGroup[] = [ { comments: [] }, ...versions.map((_, version) => ({ version, comments: [] })) ]; // parsed rather than string-compared: a boundary must not turn on whether the // two sides happen to share an offset or the same fractional precision const at = (createdAt: string) => Date.parse(createdAt); // both sides ascend, so one walk places every comment -- slot never goes back let slot = 0; for (const comment of [...comments].sort((a, b) => at(a.createdAt) - at(b.createdAt))) { while (slot < versions.length && at(versions[slot].createdAt) <= at(comment.createdAt)) slot++; groups[slot].comments.push(comment); } // v0 lands with the pull itself, so slot 0 is empty in practice -- drop it then return groups.filter((group) => group.version !== undefined || group.comments.length);}
export function countCommentsByCommit(comments: CommentView[]): Map<string, number> { const counts = new Map<string, number>(); for (const { embed } of comments) { if (!embed) continue; const oid = embed.commit.oid; counts.set(oid, (counts.get(oid) ?? 0) + 1); } return counts;}
export function filterCommentsByCommit( comments: CommentView[], commits: CommitSummary[], filter?: (commit: CommitSummary) => boolean): CommentView[] { if (!filter) return comments; return comments.filter((comment) => { // full oids on both sides, so equality is enough -- no prefix matching const oid = comment.embed?.commit.oid; if (!oid) return true; const commit = commits.find((c) => c.hash === oid); return !!commit && filter(commit); });}
export const toThreadInputs = async ( items: CommentListPage["items"], docs: Sidecar, markupCtx: MarkupContext, extra?: (uri: string) => Partial<CommentView>): Promise<ThreadInput[]> => Promise.all( items.map(async (item): Promise<ThreadInput> => { const author = authorOf(docs, item.uri, COMMENT_AUTHOR_DOCS); const body = item.value.body?.text ?? ""; return { comment: { uri: item.uri, cid: item.cid, rkey: rkeyFromUri(item.uri), authorDid: author.did, authorHandle: author.handle ?? INVALID_HANDLE, createdAt: item.value.createdAt, body, bodyHtml: body ? await orNull(renderMarkup(body, markupCtx)) : null, ...extra?.(item.uri) }, replyTo: item.value.replyTo?.uri ?? null, replyToCid: item.value.replyTo?.cid }; }) );
export interface CommentItems { items: CommentListPage["items"]; docs: Sidecar;}
export const listCommentItems = async ( ctx: BobbinContext, subjectUri: string): Promise<CommentItems> => { const result = await orNull( enrich<CommentListPage>(ctx, { xrpc: "sh.tangled.feed.listComments", params: { subject: subjectUri, order: "asc", limit: 100 }, enrich: [target(COMMENT_AUTHOR_DOCS, ["items[].uri"])] }) );
return { items: result?.output?.items ?? [], docs: result?.data ?? {} };};
export const loadCommentThreads = async ( ctx: BobbinContext, subjectUri: string, markupCtx: MarkupContext, extra?: (uri: string) => Partial<CommentView>): Promise<CommentThread[]> => { const { items, docs } = await listCommentItems(ctx, subjectUri); return buildCommentThreads(await toThreadInputs(items, docs, markupCtx, extra));};
export const insertComment = ( threads: CommentThread[], { comment, replyTo }: ThreadInput): CommentThread[] => { if (replyTo === null) { return [...threads, { self: comment, replies: [] }]; } return threads.map((thread) => thread.self.uri === replyTo ? { ...thread, replies: [...thread.replies, comment] } : thread );};
export const updateComment = ( threads: CommentThread[], { comment }: ThreadInput): CommentThread[] => { return threads.map((thread) => ({ self: thread.self.uri === comment.uri ? comment : thread.self, replies: thread.replies.map((reply) => (reply.uri === comment.uri ? comment : reply)) }));};
export const removeComment = (threads: CommentThread[], uri: string): CommentThread[] => { return threads .map((thread) => { if (thread.self.uri === uri) { return thread.replies.length ? { ...thread, self: { ...thread.self, body: "", bodyHtml: null, deleted: true } } : null; } return { ...thread, replies: thread.replies.filter((r) => r.uri !== uri) }; }) .filter((t): t is CommentThread => t !== null);};