"use client"; /** * PostCommentsDrawer — the comments sheet for a post. * * Right-side on desktop, bottom on mobile (vaul `direction`), with a FULLY * TRANSPARENT overlay so the post stays visible behind it while reading the * thread. Anyone signed in can comment; the composer gates anonymous users * through the auth modal. * * Threading is one level deep: top-level comments each own a single flat * thread of replies. Replying to a reply still lands in that one thread, * addressed (visually) to whoever was answered. The post author can pin one * comment (shown first here and in the card footer) and delete any comment; * a comment's author can delete their own (a thread root takes its replies * with it — behind a confirm). Likes show a count only. * * This file owns the drawer shell, the comment list + pagination, the * composer (with emoji picker), and the optimistic mutation wiring. The row * (CommentRow), its kebab menu (CommentMenu), and the report dialog * (ReportCommentModal) live in sibling files. */ import { useCallback, useEffect, useRef, useState } from "react"; import { Loader2, Send, X } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { comments as commentsApi, SocialError, type Comment, type Post, } from "@/lib/social"; import { useAuth } from "@/components/auth/auth-provider"; import { useAuthGate } from "@/components/auth/auth-gate"; import { useMediaQuery } from "@/components/selection-toolbar/shared"; import { Drawer, DrawerContent } from "@/components/ui/drawer"; import { ConfirmModal } from "@/components/ui/confirm-modal"; import { EmojiPicker } from "@/components/ui/emoji-picker"; import { MentionTextarea } from "@/components/social/mention-textarea"; import { CommentRow } from "./comment-row"; import { ReportCommentModal } from "./comment-report-modal"; const PAGE = 20; export function PostCommentsDrawer({ open, post, onClose, onPinnedChange, onCountChange, }: { open: boolean; post: Post; onClose: () => void; /** Notifies the parent (the card footer) when the pinned comment changes, * so the footer's pinned line stays in sync without a refetch. */ onPinnedChange?: (pinned: Comment | null) => void; /** Notifies the parent of a net change to the post's comment count (+1 on a * new comment/reply, −N when a comment/thread is deleted) so the footer * count stays live. */ onCountChange?: (delta: number) => void; }) { const { user } = useAuth(); const { requireAuth } = useAuthGate(); const isMobile = useMediaQuery("(max-width: 767px)"); const direction = isMobile ? "bottom" : "right"; const isPostAuthor = !!user && user.id === post.user_id; const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [hasMore, setHasMore] = useState(false); const [posting, setPosting] = useState(false); const [draft, setDraft] = useState(""); // The comment being replied to (null = a new top-level comment). const [replyTo, setReplyTo] = useState<{ id: string; handle: string } | null>( null, ); const [toDelete, setToDelete] = useState(null); const [toReport, setToReport] = useState(null); const inputRef = useRef(null); // Load the first page whenever the drawer opens for a post. useEffect(() => { if (!open) return; let cancelled = false; setLoading(true); setItems([]); setReplyTo(null); commentsApi .list(post.id, { limit: PAGE, offset: 0 }) .then((rows) => { if (cancelled) return; setItems(rows ?? []); setHasMore((rows?.length ?? 0) === PAGE); }) .catch(() => {}) .finally(() => !cancelled && setLoading(false)); return () => { cancelled = true; }; }, [open, post.id]); const loadMore = useCallback(async () => { try { const rows = await commentsApi.list(post.id, { limit: PAGE, offset: items.length, }); setItems((prev) => [...prev, ...(rows ?? [])]); setHasMore((rows?.length ?? 0) === PAGE); } catch { /* leave the list as-is on error */ } }, [post.id, items.length]); // Replace a comment in place (after a like toggle / reply-count bump). const patch = useCallback((id: string, fn: (c: Comment) => Comment) => { setItems((prev) => prev.map((c) => (c.id === id ? fn(c) : c))); }, []); async function submit() { const body = draft.trim(); if (!body || posting) return; if (!(await requireAuth())) return; setPosting(true); try { const created = await commentsApi.create(post.id, body, replyTo?.id); setDraft(""); onCountChange?.(1); if (created.parent_id) { // A reply — bump the root's count and append into its open thread. bumpReply(created.parent_id, created); setReplyTo(null); } else { // Top-level — drop it in just below any pinned comment. setItems((prev) => { const pinnedFirst = prev[0]?.pinned_at ? 1 : 0; return [ ...prev.slice(0, pinnedFirst), created, ...prev.slice(pinnedFirst), ]; }); } } catch (err) { toast.error( err instanceof SocialError ? err.message : "Could not post comment", { duration: 3000 }, ); } finally { setPosting(false); } } // Append a freshly-created reply into its root thread's open reply list (if // loaded) and bump the visible reply count. const repliesRef = useRef void>>({}); function bumpReply(rootId: string, reply: Comment) { patch(rootId, (c) => ({ ...c, reply_count: c.reply_count + 1 })); repliesRef.current[rootId]?.(reply); } function startReply(target: Comment) { // Reply chip addresses whoever was tapped; the thread is the root. setReplyTo({ id: target.id, handle: target.author_handle }); inputRef.current?.focus(); } // Insert an emoji at the caret (or append if the textarea isn't focused), // then restore the caret just after it so typing can continue. function insertEmoji(emoji: string) { const el = inputRef.current; const start = el?.selectionStart ?? draft.length; const end = el?.selectionEnd ?? draft.length; const next = draft.slice(0, start) + emoji + draft.slice(end); setDraft(next); requestAnimationFrame(() => { const node = inputRef.current; if (!node) return; node.focus(); const caret = start + emoji.length; node.setSelectionRange(caret, caret); }); } async function pinToggle(c: Comment) { const pinning = !c.pinned_at; try { if (pinning) await commentsApi.pin(c.id); else await commentsApi.unpin(c.id); // Reload the first page so pinned-first ordering is authoritative. const rows = await commentsApi.list(post.id, { limit: PAGE, offset: 0 }); setItems(rows ?? []); setHasMore((rows?.length ?? 0) === PAGE); onPinnedChange?.( pinning ? { ...c, pinned_at: new Date().toISOString() } : null, ); toast(pinning ? "Comment pinned" : "Comment unpinned", { duration: 1800 }); } catch (err) { toast.error( err instanceof SocialError ? err.message : "Could not update pin", { duration: 3000 }, ); } } async function confirmDelete() { if (!toDelete) return; const c = toDelete; await commentsApi.delete(c.id); // A thread root takes its replies with it; a reply removes just itself. onCountChange?.(-(c.parent_id ? 1 : 1 + c.reply_count)); if (c.parent_id) { // A reply — drop the root's count; the thread list prunes itself. patch(c.parent_id, (p) => ({ ...p, reply_count: Math.max(0, p.reply_count - 1), })); repliesRef.current[c.parent_id]?.({ ...c, body: "__deleted__" }); } else { setItems((prev) => prev.filter((x) => x.id !== c.id)); if (c.pinned_at) onPinnedChange?.(null); } } return ( <> { if (!o) onClose(); }} >

Comments

{loading ? (
) : items.length === 0 ? (

No comments yet. Be the first to comment.

) : (
    {items.map((c) => ( patch(c.id, (x) => ({ ...x, likes_count: likes, viewer_has_liked: liked, })) } onPinToggle={() => pinToggle(c)} onRequestDelete={setToDelete} onRequestReport={setToReport} requireAuth={requireAuth} registerThread={(fn) => { if (fn) repliesRef.current[c.id] = fn; else delete repliesRef.current[c.id]; }} /> ))} {hasMore ? (
  • ) : null}
)}
{/* Composer */}
{replyTo ? (
Replying to{" "} @{replyTo.handle}
) : null}
setToDelete(null)} title="Delete this comment?" message={ toDelete && !toDelete.parent_id && toDelete.reply_count > 0 ? "Its replies will be deleted too. This can't be undone." : "This can't be undone." } confirmLabel="Delete" onConfirm={confirmDelete} /> setToReport(null)} /> ); }