From 47abcaa05d747d74904324a6bfff8d149697267b Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Mon, 27 Jul 2026 11:19:48 +0000 Subject: [PATCH] web/components: handle deleted comments in threads gracefully - top level comments with no replies: these just vanish - top level comments with replies: shows "This comment was deleted" - replies: just vanish Signed-off-by: oppiliappan --- web/src/lib/components/comment/Comment.svelte | 50 ++++++++++++++++++++++++++++++-------------------- web/src/lib/components/comment/CommentCard.svelte | 7 +++++-- web/src/lib/components/comment/comments.ts | 59 ++++++++++++++++++++++++++++++++++------------------------- web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte | 16 +++++++++++----- web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts | 3 ++- 5 file(s) changed, 82 insertion(s)(+), 53 deletion(s)(-) diff --git a/web/src/lib/components/comment/Comment.svelte b/web/src/lib/components/comment/Comment.svelte --- a/web/src/lib/components/comment/Comment.svelte +++ b/web/src/lib/components/comment/Comment.svelte @@ -10,6 +10,7 @@ body: string; bodyHtml: string | null; variant?: "top" | "reply"; + deleted?: boolean; // when provided, replaces the body region (e.g. an inline edit form), leaving // the avatar/handle header untouched so it doesn't shift editor?: Snippet; @@ -22,30 +23,39 @@ body, bodyHtml, variant = "top", + deleted = false, editor }: Props = $props(); -
-
- +{#if deleted} +
+ This comment was deleted.
-
-
- {authorHandle} - - - +{:else} +
+
+
- {#if editor} -
{@render editor()}
- {:else if bodyHtml} - -
{@html bodyHtml}
- {:else if body} -
{body}
- {/if} +
+
+ {authorHandle} + + + +
+ {#if editor} +
{@render editor()}
+ {:else if bodyHtml} + +
{@html bodyHtml}
+ {:else if body} +
{body}
+ {/if} +
-
+{/if} diff --git a/web/src/lib/components/comment/CommentCard.svelte b/web/src/lib/components/comment/CommentCard.svelte --- a/web/src/lib/components/comment/CommentCard.svelte +++ b/web/src/lib/components/comment/CommentCard.svelte @@ -118,9 +118,10 @@ body={thread.self.body} bodyHtml={thread.self.bodyHtml} variant="top" + deleted={thread.self.deleted} editor={editingUri === thread.self.uri ? selfEditor : undefined} /> - {#if editingUri !== thread.self.uri} + {#if editingUri !== thread.self.uri && !thread.self.deleted} {@render commentActions(thread.self)} {/if}
@@ -159,7 +160,9 @@
{/if} - {#if replying && currentUser} + {#if thread.self.deleted && !thread.self.cid} + + {:else if replying && currentUser}
a.createdAt.localeCompare(b.createdAt); +// stand-in for a deleted parent +export function deletedComment(uri: string, createdAt: string, cid?: string): CommentView { + return { + uri, + cid, + rkey: uri.split("/").pop() ?? "", + authorDid: "", + authorHandle: "", + createdAt, + body: "", + bodyHtml: null, + deleted: true + }; +} + export function buildCommentThreads(inputs: ThreadInput[]): CommentThread[] { const threads = new Map(); - const orphanReplies: ThreadInput[] = []; - for (const input of inputs) { - if (input.replyTo === null) { - threads.set(input.comment.uri, { self: input.comment, replies: [] }); + for (const { comment, replyTo } of inputs) { + if (replyTo === null) { + threads.set(comment.uri, { self: comment, replies: [] }); } } - for (const input of inputs) { - if (input.replyTo === null) continue; - const parent = threads.get(input.replyTo); - if (parent) { - parent.replies.push(input.comment); - } else { - // parent not found (e.g. legacy/cross-collection ref): surface as top-level - // so nothing is dropped. - orphanReplies.push(input); + 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); } - for (const orphan of orphanReplies) { - threads.set(orphan.comment.uri, { self: orphan.comment, replies: [] }); - } - - const list = [...threads.values()]; - list.sort((a, b) => byCreatedAt(a.self, b.self)); + const list = [...threads.values()].sort((a, b) => byCreatedAt(a.self, b.self)); for (const thread of list) thread.replies.sort(byCreatedAt); return list; } diff --git a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte --- a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte @@ -53,11 +53,17 @@ const removeComment = (uri: string) => { comments = comments - .filter((thread) => thread.self.uri !== uri) - .map((thread) => ({ - ...thread, - replies: thread.replies.filter((reply) => reply.uri !== uri) - })); + .map((thread) => { + // a deleted parent that still has replies stays as a tombstone so its + // replies remain grouped; otherwise drop it entirely + 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((reply) => reply.uri !== uri) }; + }) + .filter((thread) => thread !== null); }; const handleSaved = async (saved: RecordView) => { diff --git a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts --- a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts +++ b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts @@ -58,7 +58,8 @@ body: commentBody, bodyHtml: commentBodyHtml }, - replyTo: item.value.replyTo?.uri ?? null + replyTo: item.value.replyTo?.uri ?? null, + replyToCid: item.value.replyTo?.cid }; }) ); -- tangled.sh