Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259import type { BobbinContext } from "#lib/api/client.ts";import type { CommentListPage } from "#lib/api/comment.ts";import type * as OrgTangledEmbedCommit from "#lib/api/lexicons/types/org/tangled/embed/commit.ts";import { COMMENT_AUTHOR_DOCS } from "#lib/api/descriptors.ts";import { enrich, handleOf, target, type Sidecar } from "#lib/api/enrich.ts";import { COMMENT_COLLECTION } from "#lib/api/comment.ts";import { moderationTargets, subjectModeration, type Moderation} from "#lib/api/moderationLabels.ts";import { orNull } from "#lib/api/load.ts";import { didFromUri, rkeyFromUri } from "#lib/api/uri.ts";import type { CommitSummary } from "#lib/api/repo.ts";import type { ReactionGroup } from "#lib/components/reaction/reactions.ts";
export interface CommentView { uri: string; cid?: string; rkey: string; authorDid: string; authorHandle: string; moderation?: Moderation; 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?: OrgTangledEmbedCommit.View; 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; 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) => { // a filter narrows to commits, so a comment pinned to none has nothing to match const embed = comment.embed; if (!embed) return false; const commit = commits.find( (c) => c.hash === embed.commit || (!!embed.change && c.changeId === embed.change) ); return !!commit && filter(commit); });}
// knot comment uris name the repo, so the enrich has to fetch the editor's doc tooexport const COMMENT_AUTHOR_TARGETS = ["items[].uri", "items[].editor"];
export const COMMENT_ENRICH = [ target(COMMENT_AUTHOR_DOCS, COMMENT_AUTHOR_TARGETS), ...moderationTargets(COMMENT_COLLECTION, { records: ["items[].uri"], authors: COMMENT_AUTHOR_TARGETS })];
type CommentRef = Pick<CommentListPage["items"][number], "uri" | "editor">;
export const commentAuthorOf = ( docs: Sidecar, item: CommentRef): { did: string; handle: string } => { const did = item.editor ?? didFromUri(item.uri); return { did, handle: handleOf(docs, did, COMMENT_AUTHOR_DOCS) };};
export const commentModerationOf = (docs: Sidecar, item: CommentRef): Moderation | undefined => subjectModeration(docs, COMMENT_COLLECTION, { uri: item.uri, author: commentAuthorOf(docs, item).did });
export interface CommentItems { items: CommentListPage["items"]; docs: Sidecar;}
// order is asc, so past this many pages the newest comments silently don't loadconst COMMENT_PAGES = 20;
export const listCommentItems = async ( ctx: BobbinContext, subjectUri: string): Promise<CommentItems> => { const items: CommentListPage["items"] = []; const docs: Sidecar = {}; let cursor: string | undefined; for (let page = 0; page < COMMENT_PAGES; page++) { const result = await orNull( enrich<CommentListPage>(ctx, { xrpc: "org.tangled.feed.listComments", params: { subject: subjectUri, order: "asc", limit: 100, ...(cursor ? { cursor } : {}) }, enrich: COMMENT_ENRICH }) ); if (!result) break; items.push(...result.output.items); for (const [ref, sources] of Object.entries(result.data)) { for (const [source, types] of Object.entries(sources)) { docs[ref] = { ...docs[ref], [source]: { ...docs[ref]?.[source], ...types } }; } } cursor = result.output.cursor ?? undefined; if (!cursor || result.output.items.length === 0) break; } return { items, docs };};
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);};