"use client"; /** * CommentThread — threaded comments anchored to a page / block / database * record (docs/api/comments.md). * * The in-file `useComments` hook owns the thread list (TanStack Query) and * exposes create / reply / delete / resolve / reopen / react. Anchors: * - page: { pageId } * - block: { pageId, blockId } * - record: { pageId, recordId } (record's 1:1 page) * * Composer uses (@-mention autocomplete over workspace * members); the server re-parses `@Name` tokens into mentions on write. */ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2, CornerDownRight, Loader2, MessageSquare, RotateCcw, Send, SmilePlus, Trash2, XCircle } from "lucide-react"; import { cn, formatRelativeTime } from "@/lib/utils"; import { apiFetch } from "@/lib/api"; import { toast } from "@/stores/ui-store"; import { UserAvatar } from "@/components/database/cell-renderer"; import { MentionInput } from "@/components/comments/mention-input"; import type { MentionMember } from "@/components/mentions/mention-model"; // --------------------------------------------------------------------------- // Wire types (docs/api/comments.md) // --------------------------------------------------------------------------- export interface CommentAuthor { id: string; name: string; image?: string | null; } export interface ReplyDto { id: string; commentId: string; body: string; author: CommentAuthor; createdAt: string; updatedAt: string; } export interface ReactionDto { emoji: string; count: number; userIds: string[]; } export interface CommentThreadDto { id: string; pageId: string; blockId: string | null; recordId?: string | null; body: string; status: "open" | "resolved"; author: CommentAuthor; createdAt: string; resolvedAt?: string | null; resolvedBy?: string | null; replyCount: number; replies?: ReplyDto[]; reactions?: ReactionDto[]; } export interface CommentAnchor { pageId: string; blockId?: string | null; recordId?: string | null; } function unwrap(body: unknown): T { const b = body as { data?: T } | null; if (b && typeof b === "object" && "data" in (b as Record)) { return (b as { data: T }).data; } return body as T; } const listKey = (a: CommentAnchor) => ["comments", a.pageId, a.blockId ?? null, a.recordId ?? null] as const; // --------------------------------------------------------------------------- // useComments (in-file hook) // --------------------------------------------------------------------------- export function useComments(anchor: CommentAnchor, canComment: boolean) { const qc = useQueryClient(); const key = listKey(anchor); const queryStr = new URLSearchParams(); if (anchor.blockId) queryStr.set("blockId", anchor.blockId); if (anchor.recordId) queryStr.set("recordId", anchor.recordId); queryStr.set("include", "replies,reactions"); const query = useQuery({ queryKey: key, queryFn: async (): Promise => unwrap(await apiFetch(`/pages/${anchor.pageId}/comments?${queryStr.toString()}`)) }); const invalidate = () => qc.invalidateQueries({ queryKey: ["comments", anchor.pageId] }); const create = useMutation({ mutationFn: async (body: string): Promise => { const payload: Record = { pageId: anchor.pageId, body }; if (anchor.blockId) payload.blockId = anchor.blockId; if (anchor.recordId) payload.recordId = anchor.recordId; return unwrap(await apiFetch("/comments", { method: "POST", body: JSON.stringify(payload) })); }, onSuccess: invalidate, onError: (e) => toast("error", "Comment failed", (e as Error).message) }); const reply = useMutation({ mutationFn: async (vars: { commentId: string; body: string }): Promise => unwrap( await apiFetch(`/comments/${vars.commentId}/replies`, { method: "POST", body: JSON.stringify({ body: vars.body }) }) ), onSuccess: invalidate, onError: (e) => toast("error", "Reply failed", (e as Error).message) }); const remove = useMutation({ mutationFn: async (commentId: string): Promise => unwrap(await apiFetch(`/comments/${commentId}`, { method: "DELETE" })), onSuccess: invalidate, onError: (e) => toast("error", "Delete failed", (e as Error).message) }); const resolve = useMutation({ mutationFn: async (commentId: string): Promise => unwrap(await apiFetch(`/comments/${commentId}/resolve`, { method: "POST" })), onSuccess: invalidate }); const reopen = useMutation({ mutationFn: async (commentId: string): Promise => unwrap(await apiFetch(`/comments/${commentId}/reopen`, { method: "POST" })), onSuccess: invalidate }); const react = useMutation({ mutationFn: async (vars: { commentId: string; emoji: string }): Promise => unwrap( await apiFetch(`/comments/${vars.commentId}/reactions`, { method: "POST", body: JSON.stringify({ emoji: vars.emoji }) }) ), onSuccess: invalidate }); return { threads: query.data ?? [], isLoading: query.isLoading, isRefetching: query.isRefetching, refetch: query.refetch, canComment, create, reply, remove, resolve, reopen, react }; } // --------------------------------------------------------------------------- // Thread UI // --------------------------------------------------------------------------- const QUICK_REACTIONS = ["👍", "🎉", "❤️", "👀", "❓"]; export interface CommentThreadProps { anchor: CommentAnchor; /** Workspace members for @-mentions. */ members: MentionMember[]; /** Whether the current user may comment (COMMENT level). */ canComment?: boolean; /** Id of the current user (controls edit/delete + own-reaction state). */ currentUserId?: string | null; /** * Enables the full R3 grouped mention popup (People/Teams/Pages/Databases). * When omitted, the popup is members-only (People) — backward compatible. */ workspaceId?: string; className?: string; } export function CommentThread({ anchor, members, canComment = true, currentUserId, workspaceId, className }: CommentThreadProps) { const api = useComments(anchor, canComment); const [draft, setDraft] = useState(""); const [replyTo, setReplyTo] = useState(null); const [replyDraft, setReplyDraft] = useState(""); const threads = useMemo( () => [...api.threads].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)), [api.threads] ); const submit = () => { if (!draft.trim()) return; api.create.mutate(draft.trim(), { onSuccess: () => setDraft(""), }); }; return (
{/* header */}
Comments {threads.length} thread{threads.length === 1 ? "" : "s"}
{/* composer */} {canComment ? (
) : (

You have view-only access — commenting is disabled.

)} {/* threads */} {api.isLoading &&

Loading comments…

} {!api.isLoading && threads.length === 0 && (

No comments yet.

)}
    {threads.map((t) => (
  • {/* thread header */}
    {t.author.name} {formatRelativeTime(t.createdAt)} {canComment && ( <> )} {canComment && ( )}
    {/* body */}

    {t.body}

    {/* reactions */} {(t.reactions?.length ?? 0) > 0 && (
    {t.reactions!.map((r) => ( {r.emoji} {r.count} ))}
    )} {/* replies */}
      {(t.replies ?? []).map((r) => (
    • {r.author.name} {formatRelativeTime(r.createdAt)}

      {r.body}

    • ))}
    {/* reply composer */} {canComment && ( replyTo === t.id ? (
    { if (!replyDraft.trim()) return; api.reply.mutate({ commentId: t.id, body: replyDraft.trim() }, { onSuccess: () => { setReplyDraft(""); setReplyTo(null); } }); }} onEscape={() => setReplyTo(null)} />
    ) : ( ) )}
  • ))}
); }