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 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>