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