import 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 parent
function 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 too
export 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 unloaded
export 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
};
};