Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275import 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, REACTIONS } from "#lib/api/descriptors.ts";import { enrich, handleOf, target, type Sidecar } from "#lib/api/enrich.ts";import { INVALID_HANDLE } from "#lib/api/identity.ts";import { COMMENT_COLLECTION } from "#lib/api/comment.ts";import { moderationTargets, subjectModeration, type Moderation} from "#lib/api/moderationLabels.ts";import { didFromUri, rkeyFromUri } from "#lib/api/uri.ts";import type { CommitSummary } from "#lib/api/repo.ts";import { reactionGroupsOf, 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; // carried so an edit rewrites the record without dropping what it pinned embed?: OrgTangledEmbedCommit.View; reactions?: ReactionGroup[]; deleted?: boolean; unloaded?: 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 parentfunction deletedComment(uri: string, createdAt: string, cid?: string): CommentView { return { uri, cid, rkey: rkeyFromUri(uri), authorDid: "", authorHandle: "", createdAt, body: "", deleted: true };}
function buildCommentThreads(inputs: ThreadInput[], complete = true): 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) { const self = deletedComment(replyTo, comment.createdAt); parent = { self: complete ? self : { ...self, unloaded: true }, 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), target(REACTIONS, ["items[].uri"]), ...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 });
const toThreadInputs = ( items: CommentListPage["items"], docs: Sidecar, extra?: (uri: string) => Partial<CommentView>): ThreadInput[] => items.map((item): ThreadInput => { const author = commentAuthorOf(docs, item); return { comment: { uri: item.uri, cid: item.cid, rkey: rkeyFromUri(item.uri), authorDid: author.did, authorHandle: author.handle ?? INVALID_HANDLE, moderation: commentModerationOf(docs, item), createdAt: item.createdAt, body: item.body?.text ?? "", embed: item.embed, ...extra?.(item.uri) }, replyTo: item.replyTo?.uri ?? null, replyToCid: item.replyTo?.cid }; });
export interface CommentPage { inputs: ThreadInput[]; cursor: string | null; total: number;}
export const NO_COMMENTS: CommentPage = { inputs: [], cursor: null, total: 0 };
export const loadCommentPage = async ( ctx: BobbinContext, subjectUri: string, viewerDid: string | undefined, cursor?: string): Promise<CommentPage> => { const { output, data } = await enrich<CommentListPage>(ctx, { xrpc: "org.tangled.feed.listComments", params: { subject: subjectUri, order: "desc", limit: 100, ...(cursor ? { cursor } : {}) }, enrich: COMMENT_ENRICH, viewer: viewerDid }); const items = output.items.toReversed(); return { inputs: toThreadInputs(items, data, (uri) => ({ reactions: reactionGroupsOf(data, uri) })), cursor: items.length > 0 ? (output.cursor ?? null) : null, total: output.total ?? items.length };};
export const withOlderPage = (shown: CommentPage, older: CommentPage): CommentPage => { const uris = new Set(shown.inputs.map(({ comment }) => comment.uri)); return { inputs: [...older.inputs.filter(({ comment }) => !uris.has(comment.uri)), ...shown.inputs], cursor: older.cursor, total: shown.total };};
export const threadsOf = (page: CommentPage): CommentThread[] => buildCommentThreads(page.inputs, page.cursor === null);
export const withComment = (page: CommentPage, added: ThreadInput): CommentPage => ({ ...page, inputs: [...page.inputs, added], total: page.total + 1});
export const withEdited = (page: CommentPage, { comment }: ThreadInput): CommentPage => ({ ...page, inputs: page.inputs.map((input) => input.comment.uri === comment.uri ? { ...input, comment } : input )});
// keeps a removed parent as deleted when it has replies, otherwise threadsOf marks it unloadedexport const withRemoved = (page: CommentPage, uri: string): CommentPage => { const replied = page.inputs.some(({ replyTo }) => replyTo === uri); return { ...page, inputs: page.inputs.flatMap((input) => input.comment.uri !== uri ? [input] : replied ? [{ ...input, comment: { ...input.comment, body: "", deleted: true } }] : [] ), total: page.total - 1 };};