This repository has no description
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 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>