1"use client";
2
3
16import { useMemo, useState } from "react";
17import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
18import { CheckCircle2, CornerDownRight, Loader2, MessageSquare, RotateCcw, Send, SmilePlus, Trash2, XCircle } from "lucide-react";
19import { cn, formatRelativeTime } from "@/lib/utils";
20import { apiFetch } from "@/lib/api";
21import { toast } from "@/stores/ui-store";
22import { UserAvatar } from "@/components/database/cell-renderer";
23import { MentionInput } from "@/components/comments/mention-input";
24import type { MentionMember } from "@/components/mentions/mention-model";
25
26
27
28
29
30export interface CommentAuthor {
31 id: string;
32 name: string;
33 image?: string | null;
34}
35export interface ReplyDto {
36 id: string;
37 commentId: string;
38 body: string;
39 author: CommentAuthor;
40 createdAt: string;
41 updatedAt: string;
42}
43export interface ReactionDto {
44 emoji: string;
45 count: number;
46 userIds: string[];
47}
48export interface CommentThreadDto {
49 id: string;
50 pageId: string;
51 blockId: string | null;
52 recordId?: string | null;
53 body: string;
54 status: "open" | "resolved";
55 author: CommentAuthor;
56 createdAt: string;
57 resolvedAt?: string | null;
58 resolvedBy?: string | null;
59 replyCount: number;
60 replies?: ReplyDto[];
61 reactions?: ReactionDto[];
62}
63
64export interface CommentAnchor {
65 pageId: string;
66 blockId?: string | null;
67 recordId?: string | null;
68}
69
70function unwrap<T>(body: unknown): T {
71 const b = body as { data?: T } | null;
72 if (b && typeof b === "object" && "data" in (b as Record<string, unknown>)) {
73 return (b as { data: T }).data;
74 }
75 return body as T;
76}
77
78const listKey = (a: CommentAnchor) => ["comments", a.pageId, a.blockId ?? null, a.recordId ?? null] as const;
79
80
81
82
83
84export function useComments(anchor: CommentAnchor, canComment: boolean) {
85 const qc = useQueryClient();
86 const key = listKey(anchor);
87 const queryStr = new URLSearchParams();
88 if (anchor.blockId) queryStr.set("blockId", anchor.blockId);
89 if (anchor.recordId) queryStr.set("recordId", anchor.recordId);
90 queryStr.set("include", "replies,reactions");
91
92 const query = useQuery({
93 queryKey: key,
94 queryFn: async (): Promise<CommentThreadDto[]> =>
95 unwrap(await apiFetch(`/pages/${anchor.pageId}/comments?${queryStr.toString()}`))
96 });
97
98 const invalidate = () => qc.invalidateQueries({ queryKey: ["comments", anchor.pageId] });
99
100 const create = useMutation({
101 mutationFn: async (body: string): Promise<CommentThreadDto> => {
102 const payload: Record<string, unknown> = { pageId: anchor.pageId, body };
103 if (anchor.blockId) payload.blockId = anchor.blockId;
104 if (anchor.recordId) payload.recordId = anchor.recordId;
105 return unwrap(await apiFetch("/comments", { method: "POST", body: JSON.stringify(payload) }));
106 },
107 onSuccess: invalidate,
108 onError: (e) => toast("error", "Comment failed", (e as Error).message)
109 });
110
111 const reply = useMutation({
112 mutationFn: async (vars: { commentId: string; body: string }): Promise<ReplyDto> =>
113 unwrap(
114 await apiFetch(`/comments/${vars.commentId}/replies`, {
115 method: "POST",
116 body: JSON.stringify({ body: vars.body })
117 })
118 ),
119 onSuccess: invalidate,
120 onError: (e) => toast("error", "Reply failed", (e as Error).message)
121 });
122
123 const remove = useMutation({
124 mutationFn: async (commentId: string): Promise<void> =>
125 unwrap(await apiFetch(`/comments/${commentId}`, { method: "DELETE" })),
126 onSuccess: invalidate,
127 onError: (e) => toast("error", "Delete failed", (e as Error).message)
128 });
129
130 const resolve = useMutation({
131 mutationFn: async (commentId: string): Promise<void> =>
132 unwrap(await apiFetch(`/comments/${commentId}/resolve`, { method: "POST" })),
133 onSuccess: invalidate
134 });
135
136 const reopen = useMutation({
137 mutationFn: async (commentId: string): Promise<void> =>
138 unwrap(await apiFetch(`/comments/${commentId}/reopen`, { method: "POST" })),
139 onSuccess: invalidate
140 });
141
142 const react = useMutation({
143 mutationFn: async (vars: { commentId: string; emoji: string }): Promise<ReactionDto[]> =>
144 unwrap(
145 await apiFetch(`/comments/${vars.commentId}/reactions`, {
146 method: "POST",
147 body: JSON.stringify({ emoji: vars.emoji })
148 })
149 ),
150 onSuccess: invalidate
151 });
152
153 return {
154 threads: query.data ?? [],
155 isLoading: query.isLoading,
156 isRefetching: query.isRefetching,
157 refetch: query.refetch,
158 canComment,
159 create,
160 reply,
161 remove,
162 resolve,
163 reopen,
164 react
165 };
166}
167
168
169
170
171
172const QUICK_REACTIONS = ["👍", "🎉", "❤️", "👀", "❓"];
173
174export interface CommentThreadProps {
175 anchor: CommentAnchor;
176
177 members: MentionMember[];
178
179 canComment?: boolean;
180
181 currentUserId?: string | null;
182
186 workspaceId?: string;
187 className?: string;
188}
189
190export function CommentThread({
191 anchor,
192 members,
193 canComment = true,
194 currentUserId,
195 workspaceId,
196 className
197}: CommentThreadProps) {
198 const api = useComments(anchor, canComment);
199 const [draft, setDraft] = useState("");
200 const [replyTo, setReplyTo] = useState<string | null>(null);
201 const [replyDraft, setReplyDraft] = useState("");
202
203 const threads = useMemo(
204 () => [...api.threads].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)),
205 [api.threads]
206 );
207
208 const submit = () => {
209 if (!draft.trim()) return;
210 api.create.mutate(draft.trim(), {
211 onSuccess: () => setDraft(""),
212 });
213 };
214
215 return (
216 <div className={cn("space-y-4", className)}>
217 {}
218 <div className="flex items-center gap-2 text-sm font-medium">
219 <MessageSquare className="size-4" aria-hidden />
220 Comments
221 <span className="text-xs font-normal text-muted-foreground">
222 {threads.length} thread{threads.length === 1 ? "" : "s"}
223 </span>
224 </div>
225
226 {}
227 {canComment ? (
228 <div className="flex items-start gap-2">
229 <MentionInput
230 value={draft}
231 onChange={setDraft}
232 members={members}
233 workspaceId={workspaceId}
234 currentUserId={currentUserId}
235 testId="comment-composer"
236 placeholder="Add a comment… ( @ to mention )"
237 onSubmit={submit}
238 />
239 <button
240 type="button"
241 data-testid="comment-submit"
242 className="mt-1 inline-flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
243 onClick={submit}
244 disabled={!draft.trim() || api.create.isPending}
245 aria-label="Send comment"
246 >
247 {api.create.isPending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
248 </button>
249 </div>
250 ) : (
251 <p className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
252 You have view-only access — commenting is disabled.
253 </p>
254 )}
255
256 {}
257 {api.isLoading && <p className="text-xs text-muted-foreground">Loading comments…</p>}
258 {!api.isLoading && threads.length === 0 && (
259 <p className="text-xs text-muted-foreground">No comments yet.</p>
260 )}
261
262 <ul className="space-y-3">
263 {threads.map((t) => (
264 <li key={t.id} className={cn("rounded-lg border border-border p-3", t.status === "resolved" && "opacity-70")}>
265 {}
266 <div className="flex items-center gap-2">
267 <UserAvatar user={{ id: t.author.id, name: t.author.name, avatarUrl: t.author.image ?? null }} size={22} />
268 <span className="text-sm font-medium">{t.author.name}</span>
269 <span className="text-xs text-muted-foreground">{formatRelativeTime(t.createdAt)}</span>
270 <span className="ml-auto flex items-center gap-0.5">
271 {canComment && (
272 <>
273 <button
274 type="button"
275 className="rounded p-1 text-muted-foreground hover:bg-muted"
276 title={t.status === "resolved" ? "Reopen" : "Resolve"}
277 onClick={() =>
278 (t.status === "resolved" ? api.reopen : api.resolve).mutate(t.id, {
279 onSuccess: () => toast("success", t.status === "resolved" ? "Thread reopened" : "Thread resolved")
280 })
281 }
282 aria-label={t.status === "resolved" ? "Reopen thread" : "Resolve thread"}
283 >
284 {t.status === "resolved" ? <RotateCcw className="size-3.5" /> : <CheckCircle2 className="size-3.5" />}
285 </button>
286 <button
287 type="button"
288 className="rounded p-1 text-muted-foreground hover:bg-muted"
289 title="Delete thread"
290 onClick={() =>
291 api.remove.mutate(t.id, {
292 onSuccess: () => toast("success", "Thread deleted")
293 })
294 }
295 aria-label="Delete thread"
296 >
297 <Trash2 className="size-3.5" />
298 </button>
299 </>
300 )}
301 {canComment && (
302 <button
303 type="button"
304 className="rounded p-1 text-muted-foreground hover:bg-muted"
305 title="React"
306 onClick={() => api.react.mutate({ commentId: t.id, emoji: QUICK_REACTIONS[0] })}
307 aria-label="Add reaction"
308 >
309 <SmilePlus className="size-3.5" />
310 </button>
311 )}
312 </span>
313 </div>
314
315 {}
316 <p className="mt-1.5 whitespace-pre-wrap pl-7 text-sm">{t.body}</p>
317
318 {}
319 {(t.reactions?.length ?? 0) > 0 && (
320 <div className="mt-2 flex flex-wrap gap-1 pl-7">
321 {t.reactions!.map((r) => (
322 <span
323 key={r.emoji}
324 className={cn(
325 "inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-xs",
326 currentUserId && r.userIds.includes(currentUserId) ? "border-primary/40 bg-primary/10" : "border-border"
327 )}
328 title={r.userIds.length}
329 >
330 {r.emoji} {r.count}
331 </span>
332 ))}
333 </div>
334 )}
335
336 {}
337 <ul className="mt-2 space-y-2 border-l border-border pl-3">
338 {(t.replies ?? []).map((r) => (
339 <li key={r.id} className="flex items-start gap-2">
340 <UserAvatar user={{ id: r.author.id, name: r.author.name, avatarUrl: (r.author as { image?: string | null }).image ?? null }} size={18} />
341 <div className="min-w-0">
342 <div className="flex items-center gap-1.5">
343 <span className="text-xs font-medium">{r.author.name}</span>
344 <span className="text-[11px] text-muted-foreground">{formatRelativeTime(r.createdAt)}</span>
345 </div>
346 <p className="whitespace-pre-wrap text-sm">{r.body}</p>
347 </div>
348 </li>
349 ))}
350 </ul>
351
352 {}
353 {canComment && (
354 replyTo === t.id ? (
355 <div className="mt-2 flex items-start gap-2 pl-7">
356 <MentionInput
357 value={replyDraft}
358 onChange={setReplyDraft}
359 members={members}
360 workspaceId={workspaceId}
361 currentUserId={currentUserId}
362 testId="comment-reply-input"
363 placeholder="Reply…"
364 autoFocus
365 onSubmit={() => {
366 if (!replyDraft.trim()) return;
367 api.reply.mutate({ commentId: t.id, body: replyDraft.trim() }, { onSuccess: () => { setReplyDraft(""); setReplyTo(null); } });
368 }}
369 onEscape={() => setReplyTo(null)}
370 />
371 <button
372 type="button"
373 data-testid="comment-reply-submit"
374 className="mt-1 rounded-md bg-primary p-1.5 text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
375 onClick={() => {
376 if (!replyDraft.trim()) return;
377 api.reply.mutate({ commentId: t.id, body: replyDraft.trim() }, { onSuccess: () => { setReplyDraft(""); setReplyTo(null); } });
378 }}
379 disabled={!replyDraft.trim() || api.reply.isPending}
380 aria-label="Send reply"
381 >
382 <Send className="size-3.5" />
383 </button>
384 <button
385 type="button"
386 className="mt-1 rounded-md p-1.5 text-muted-foreground hover:bg-muted"
387 onClick={() => { setReplyTo(null); setReplyDraft(""); }}
388 aria-label="Cancel reply"
389 >
390 <XCircle className="size-4" />
391 </button>
392 </div>
393 ) : (
394 <button
395 type="button"
396 className="mt-2 inline-flex items-center gap-1 pl-7 text-xs text-muted-foreground hover:text-foreground"
397 onClick={() => setReplyTo(t.id)}
398 >
399 <CornerDownRight className="size-3.5" /> Reply
400 </button>
401 )
402 )}
403 </li>
404 ))}
405 </ul>
406 </div>
407 );
408}
409