This repository has no description
0

Configure Feed

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

core / web / src / lib / components / comment / CommentCard.svelte
7.9 kB 260 lines
1<script lang="ts"> 2 import { resolve } from "$app/paths"; 3 import Pencil from "$icon/pencil"; 4 import Trash2 from "$icon/trash-2"; 5 import { deleteComment } from "$lib/api/comment"; 6 import { getAuth } from "$lib/auth.svelte"; 7 import { type MarkupContext } from "$lib/markup"; 8 import ErrorAlert from "$lib/components/ui/Error.svelte"; 9 import ReactionPicker from "$lib/components/reaction/ReactionPicker.svelte"; 10 import { 11 upsertViewerReaction, 12 withoutViewerReaction, 13 type ReactionGroup, 14 type ReactionKind 15 } from "$lib/components/reaction/reactions"; 16 import Comment from "./Comment.svelte"; 17 import CommentBox from "./CommentBox.svelte"; 18 import CommentEditor from "./CommentEditor.svelte"; 19 import type { CommentThread, CommentView, ThreadInput } from "./comments"; 20 21 interface Props { 22 thread: CommentThread; 23 subjectUri: string; 24 subjectCid?: string; 25 markup: MarkupContext; 26 onsubmitted?: (submitted: ThreadInput) => void; 27 onedited?: (edited: ThreadInput) => void; 28 ondeleted?: (uri: string) => void; 29 } 30 31 let { thread, subjectUri, subjectCid, markup, onsubmitted, onedited, ondeleted }: Props = 32 $props(); 33 34 const auth = getAuth(); 35 const currentUser = $derived(auth?.currentUser ?? null); 36 37 let replying = $state(false); 38 let editingUri = $state<string | null>(null); 39 let deletingUri = $state<string | null>(null); 40 let deleteError = $state<string | null>(null); 41 42 let reactionsByUri = $derived.by(() => { 43 const map: Record<string, ReactionGroup[]> = {}; 44 map[thread.self.uri] = thread.self.reactions ?? []; 45 for (const reply of thread.replies) map[reply.uri] = reply.reactions ?? []; 46 return map; 47 }); 48 49 const setReactions = (uri: string, next: ReactionGroup[]) => { 50 reactionsByUri = { ...reactionsByUri, [uri]: next }; 51 }; 52 53 // kinds the viewer reacted with, mapped to their record's rkey so the picker can toggle 54 const reactedMap = (uri: string): Map<ReactionKind, string> => 55 new Map( 56 (reactionsByUri[uri] ?? []) 57 .filter((g) => g.isReacted && g.viewerRkey) 58 .map((g) => [g.kind, g.viewerRkey!] as [ReactionKind, string]) 59 ); 60 61 const onReacted = (uri: string) => (kind: ReactionKind, rkey: string) => { 62 const viewer = currentUser; 63 if (!viewer) return; 64 setReactions(uri, upsertViewerReaction(reactionsByUri[uri] ?? [], kind, viewer.handle, rkey)); 65 }; 66 67 const onUnreacted = (uri: string) => (kind: ReactionKind) => { 68 const viewer = currentUser; 69 if (!viewer) return; 70 setReactions( 71 uri, 72 (reactionsByUri[uri] ?? []) 73 .map((g) => (g.kind === kind ? withoutViewerReaction(g, viewer.handle) : g)) 74 .filter((g) => g.count > 0) 75 ); 76 }; 77 78 const handleDelete = async (comment: CommentView) => { 79 const agent = auth?.agent; 80 if (!agent || deletingUri) return; 81 if (!confirm("Delete this comment? This cannot be undone.")) return; 82 deletingUri = comment.uri; 83 deleteError = null; 84 try { 85 await deleteComment(agent, comment.rkey); 86 ondeleted?.(comment.uri); 87 } catch (err) { 88 deleteError = err instanceof Error ? err.message : "Failed to delete comment"; 89 } finally { 90 deletingUri = null; 91 } 92 }; 93</script> 94 95{#snippet commentActions(comment: CommentView)} 96 {#if currentUser} 97 <div 98 class="absolute top-4 right-4 flex items-center gap-2 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100" 99 > 100 <ReactionPicker 101 subjectUri={comment.uri} 102 reacted={reactedMap(comment.uri)} 103 onreacted={onReacted(comment.uri)} 104 onunreacted={onUnreacted(comment.uri)} 105 /> 106 {#if currentUser.did === comment.authorDid} 107 <button 108 type="button" 109 aria-label="Edit comment" 110 class="cursor-pointer text-foreground-subtle hover:text-foreground-default" 111 onclick={() => (editingUri = comment.uri)} 112 > 113 <Pencil class="size-3" /> 114 </button> 115 <button 116 type="button" 117 aria-label="Delete comment" 118 disabled={deletingUri === comment.uri} 119 class="cursor-pointer text-foreground-danger hover:text-foreground-danger-strong disabled:opacity-50" 120 onclick={() => handleDelete(comment)} 121 > 122 <Trash2 class="size-3" /> 123 </button> 124 {/if} 125 </div> 126 {/if} 127{/snippet} 128 129{#snippet commentEditor( 130 comment: CommentView, 131 replyUri: string | undefined, 132 replyCid: string | undefined 133)} 134 <CommentEditor 135 {subjectUri} 136 {subjectCid} 137 replyToUri={replyUri} 138 replyToCid={replyCid} 139 authorDid={comment.authorDid} 140 authorHandle={comment.authorHandle} 141 rkey={comment.rkey} 142 createdAt={comment.createdAt} 143 body={comment.body} 144 {markup} 145 rows={4} 146 placeholder="Edit your comment. Markdown is supported." 147 submitLabel="Save" 148 submitIcon={Pencil} 149 autofocus 150 onsubmitted={(edited) => { 151 editingUri = null; 152 onedited?.(edited); 153 }} 154 oncancel={() => (editingUri = null)} 155 /> 156{/snippet} 157 158<div 159 class="overflow-hidden rounded-sm border border-border-default bg-background-canvas bg-background-default/50 drop-shadow-xs" 160> 161 {#snippet selfEditor()} 162 {@render commentEditor(thread.self, undefined, undefined)} 163 {/snippet} 164 <div class="group relative"> 165 <Comment 166 authorHandle={thread.self.authorHandle} 167 authorDid={thread.self.authorDid} 168 createdAt={thread.self.createdAt} 169 body={thread.self.body} 170 bodyHtml={thread.self.bodyHtml} 171 variant="top" 172 deleted={thread.self.deleted} 173 reactions={reactionsByUri[thread.self.uri]} 174 subjectUri={thread.self.uri} 175 onreactionschange={(next) => setReactions(thread.self.uri, next)} 176 editor={editingUri === thread.self.uri ? selfEditor : undefined} 177 /> 178 {#if editingUri !== thread.self.uri && !thread.self.deleted} 179 {@render commentActions(thread.self)} 180 {/if} 181 </div> 182 183 {#if thread.replies.length} 184 <div class="ml-10"> 185 {#each thread.replies as reply, i (reply.uri)} 186 {#snippet replyEditor()} 187 {@render commentEditor(reply, thread.self.uri, thread.self.cid)} 188 {/snippet} 189 <div class="group relative isolate -ml-4"> 190 <!-- thread connector; on the last reply it stops at the avatar's centre (h-8) --> 191 <div 192 class={`pointer-events-none absolute top-0 left-4 -z-10 w-0.5 -translate-x-1/2 bg-border-default ${i === thread.replies.length - 1 ? "h-8" : "bottom-0"}`} 193 ></div> 194 <Comment 195 authorHandle={reply.authorHandle} 196 authorDid={reply.authorDid} 197 createdAt={reply.createdAt} 198 body={reply.body} 199 bodyHtml={reply.bodyHtml} 200 variant="reply" 201 reactions={reactionsByUri[reply.uri]} 202 subjectUri={reply.uri} 203 onreactionschange={(next) => setReactions(reply.uri, next)} 204 editor={editingUri === reply.uri ? replyEditor : undefined} 205 /> 206 {#if editingUri !== reply.uri} 207 {@render commentActions(reply)} 208 {/if} 209 </div> 210 {/each} 211 </div> 212 {/if} 213 214 {#if deleteError} 215 <div class="border-t border-border-default px-6 py-2"> 216 <ErrorAlert label={deleteError} /> 217 </div> 218 {/if} 219 220 {#if thread.self.deleted && !thread.self.cid} 221 <!-- deleted parent with no recoverable strongRef; nothing to reply against --> 222 {:else if replying && currentUser} 223 <div class={thread.replies.length ? "border-t border-border-default" : ""}> 224 <CommentBox 225 variant="thread" 226 {subjectUri} 227 {subjectCid} 228 replyToUri={thread.self.uri} 229 replyToCid={thread.self.cid} 230 authorDid={currentUser.did} 231 authorHandle={currentUser.handle} 232 {markup} 233 autofocus 234 onsubmitted={(submitted) => { 235 replying = false; 236 onsubmitted?.(submitted); 237 }} 238 oncancel={() => (replying = false)} 239 /> 240 </div> 241 {:else} 242 <div 243 class={`flex items-center gap-2 bg-background-default/50 px-6 py-2 ${thread.replies.length ? "border-t border-border-default" : ""}`} 244 > 245 {#if currentUser} 246 <button 247 type="button" 248 class="w-full cursor-text text-left text-foreground-subtle focus:outline-none" 249 onclick={() => (replying = true)} 250 > 251 Leave a reply... 252 </button> 253 {:else} 254 <span class="text-foreground-subtle"> 255 <a href={resolve("/login")} class="underline">Login</a> to leave a reply 256 </span> 257 {/if} 258 </div> 259 {/if} 260</div>