This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

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 <me@oppi.li>

author
oppiliappan
committer
dawn
date (Jul 31, 2026, 10:54 PM +0300) commit 3d68fe33 parent 7ee8a31a change-id nxrpmqwl
+82 -53
+30 -20
web/src/lib/components/comment/Comment.svelte
··· 10 10 body: string; 11 11 bodyHtml: string | null; 12 12 variant?: "top" | "reply"; 13 + deleted?: boolean; 13 14 // when provided, replaces the body region (e.g. an inline edit form), leaving 14 15 // the avatar/handle header untouched so it doesn't shift 15 16 editor?: Snippet; ··· 22 23 body, 23 24 bodyHtml, 24 25 variant = "top", 26 + deleted = false, 25 27 editor 26 28 }: Props = $props(); 27 29 </script> 28 30 29 - <div 30 - class={`flex gap-2 ${variant === "top" ? "border-b border-border-default bg-background-default px-6 py-4" : "py-4 pr-4"}`} 31 - > 32 - <div class="shrink-0"> 33 - <Avatar did={authorDid} handle={authorHandle} size="size-8" tiny /> 31 + {#if deleted} 32 + <div 33 + class={`text-sm text-foreground-subtle italic ${variant === "top" ? "border-b border-border-default bg-background-default px-6 py-4" : "py-4 pr-4"}`} 34 + > 35 + This comment was deleted. 34 36 </div> 35 - <div class="min-w-0 flex-1"> 36 - <div class="flex flex-wrap items-center gap-x-1 gap-y-1 text-sm text-foreground-subtle"> 37 - <span class="text-foreground-default">{authorHandle}</span> 38 - <span class="before:mr-1 before:content-['·']"> 39 - <TimeAgo value={createdAt} /> 40 - </span> 37 + {:else} 38 + <div 39 + class={`flex gap-2 ${variant === "top" ? "border-b border-border-default bg-background-default px-6 py-4" : "py-4 pr-4"}`} 40 + > 41 + <div class="shrink-0"> 42 + <Avatar did={authorDid} handle={authorHandle} size="size-8" tiny /> 41 43 </div> 42 - {#if editor} 43 - <div class="mt-1">{@render editor()}</div> 44 - {:else if bodyHtml} 45 - <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in $lib/markup --> 46 - <article class="markup mt-1">{@html bodyHtml}</article> 47 - {:else if body} 48 - <article class="mt-1 whitespace-pre-wrap text-foreground-default">{body}</article> 49 - {/if} 44 + <div class="min-w-0 flex-1"> 45 + <div class="flex flex-wrap items-center gap-x-1 gap-y-1 text-sm text-foreground-subtle"> 46 + <span class="text-foreground-default">{authorHandle}</span> 47 + <span class="before:mr-1 before:content-['·']"> 48 + <TimeAgo value={createdAt} /> 49 + </span> 50 + </div> 51 + {#if editor} 52 + <div class="mt-1">{@render editor()}</div> 53 + {:else if bodyHtml} 54 + <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in $lib/markup --> 55 + <article class="markup mt-1">{@html bodyHtml}</article> 56 + {:else if body} 57 + <article class="mt-1 whitespace-pre-wrap text-foreground-default">{body}</article> 58 + {/if} 59 + </div> 50 60 </div> 51 - </div> 61 + {/if}
+5 -2
web/src/lib/components/comment/CommentCard.svelte
··· 118 118 body={thread.self.body} 119 119 bodyHtml={thread.self.bodyHtml} 120 120 variant="top" 121 + deleted={thread.self.deleted} 121 122 editor={editingUri === thread.self.uri ? selfEditor : undefined} 122 123 /> 123 - {#if editingUri !== thread.self.uri} 124 + {#if editingUri !== thread.self.uri && !thread.self.deleted} 124 125 {@render commentActions(thread.self)} 125 126 {/if} 126 127 </div> ··· 159 160 </div> 160 161 {/if} 161 162 162 - {#if replying && currentUser} 163 + {#if thread.self.deleted && !thread.self.cid} 164 + <!-- deleted parent with no recoverable strongRef; nothing to reply against --> 165 + {:else if replying && currentUser} 163 166 <div class={thread.replies.length ? "border-t border-border-default" : ""}> 164 167 <CommentBox 165 168 variant="thread"
+34 -25
web/src/lib/components/comment/comments.ts
··· 1 - // client-side comment threading, ported from NewCommentList in appview/models/comment.go. 2 - // bobbin returns comments flat; we group replies under the top-level comment their 3 - // replyTo strongRef points at, and sort everything oldest-first. 4 - 5 1 export interface CommentView { 6 2 uri: string; 7 - // content hash, needed to strongRef this comment when replying to it 8 3 cid?: string; 9 4 rkey: string; 10 5 authorDid: string; ··· 12 7 createdAt: string; 13 8 body: string; 14 9 bodyHtml: string | null; 10 + // the record is gone but we keep it to anchor its replies 11 + deleted?: boolean; 15 12 } 16 13 17 14 export interface CommentThread { ··· 19 16 replies: CommentView[]; 20 17 } 21 18 22 - // pairs a rendered view with the parent uri it replies to (null for top-level). 19 + // pairs a rendered view with the parent uri it replies to 23 20 export interface ThreadInput { 24 21 comment: CommentView; 25 22 replyTo: string | null; 23 + // lets us reply to a parent even after it's deleted 24 + replyToCid?: string; 26 25 } 27 26 28 27 const byCreatedAt = (a: CommentView, b: CommentView) => a.createdAt.localeCompare(b.createdAt); 29 28 29 + // stand-in for a deleted parent 30 + export function deletedComment(uri: string, createdAt: string, cid?: string): CommentView { 31 + return { 32 + uri, 33 + cid, 34 + rkey: uri.split("/").pop() ?? "", 35 + authorDid: "", 36 + authorHandle: "", 37 + createdAt, 38 + body: "", 39 + bodyHtml: null, 40 + deleted: true 41 + }; 42 + } 43 + 30 44 export function buildCommentThreads(inputs: ThreadInput[]): CommentThread[] { 31 45 const threads = new Map<string, CommentThread>(); 32 - const orphanReplies: ThreadInput[] = []; 33 46 34 - for (const input of inputs) { 35 - if (input.replyTo === null) { 36 - threads.set(input.comment.uri, { self: input.comment, replies: [] }); 47 + for (const { comment, replyTo } of inputs) { 48 + if (replyTo === null) { 49 + threads.set(comment.uri, { self: comment, replies: [] }); 37 50 } 38 51 } 39 52 40 - for (const input of inputs) { 41 - if (input.replyTo === null) continue; 42 - const parent = threads.get(input.replyTo); 43 - if (parent) { 44 - parent.replies.push(input.comment); 45 - } else { 46 - // parent not found (e.g. legacy/cross-collection ref): surface as top-level 47 - // so nothing is dropped. 48 - orphanReplies.push(input); 53 + for (const { comment, replyTo, replyToCid } of inputs) { 54 + if (replyTo === null) continue; 55 + let parent = threads.get(replyTo); 56 + if (!parent) { 57 + parent = { self: deletedComment(replyTo, comment.createdAt), replies: [] }; 58 + threads.set(replyTo, parent); 59 + } 60 + if (parent.self.deleted && replyToCid && !parent.self.cid) { 61 + parent.self.cid = replyToCid; 49 62 } 50 - } 51 - 52 - for (const orphan of orphanReplies) { 53 - threads.set(orphan.comment.uri, { self: orphan.comment, replies: [] }); 63 + parent.replies.push(comment); 54 64 } 55 65 56 - const list = [...threads.values()]; 57 - list.sort((a, b) => byCreatedAt(a.self, b.self)); 66 + const list = [...threads.values()].sort((a, b) => byCreatedAt(a.self, b.self)); 58 67 for (const thread of list) thread.replies.sort(byCreatedAt); 59 68 return list; 60 69 }
+11 -5
web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte
··· 53 53 54 54 const removeComment = (uri: string) => { 55 55 comments = comments 56 - .filter((thread) => thread.self.uri !== uri) 57 - .map((thread) => ({ 58 - ...thread, 59 - replies: thread.replies.filter((reply) => reply.uri !== uri) 60 - })); 56 + .map((thread) => { 57 + // a deleted parent that still has replies stays as a tombstone so its 58 + // replies remain grouped; otherwise drop it entirely 59 + if (thread.self.uri === uri) { 60 + return thread.replies.length 61 + ? { ...thread, self: { ...thread.self, body: "", bodyHtml: null, deleted: true } } 62 + : null; 63 + } 64 + return { ...thread, replies: thread.replies.filter((reply) => reply.uri !== uri) }; 65 + }) 66 + .filter((thread) => thread !== null); 61 67 }; 62 68 63 69 const handleSaved = async (saved: RecordView<IssueRecord>) => {
+2 -1
web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts
··· 58 58 body: commentBody, 59 59 bodyHtml: commentBodyHtml 60 60 }, 61 - replyTo: item.value.replyTo?.uri ?? null 61 + replyTo: item.value.replyTo?.uri ?? null, 62 + replyToCid: item.value.replyTo?.cid 62 63 }; 63 64 }) 64 65 );