"use client"; /** * CommentRow — one comment (top-level or reply) in the comments drawer. * * Top-level rows own a lazily-loaded, flat reply thread (one level deep) and * register a splice hook so the drawer can drop a freshly-posted reply in; * replies render the "Replying to @handle" prefix. Like is optimistic; the * kebab (Pin / Delete / Report) lives in CommentMenu; deletes/reports bubble * up to the drawer's modals via onRequest* callbacks. */ import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { HiHeart, HiOutlineHeart } from "react-icons/hi2"; import { Pin } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { comments as commentsApi, SocialError, type Comment } from "@/lib/social"; import { UserAvatar } from "@/components/ui/user-avatar"; import { renderMentions } from "@/components/social/mention-text"; import { timeAgo } from "@/lib/time"; import { CommentMenu } from "./comment-menu"; export function CommentRow({ comment, viewerId, isPostAuthor, onReply, onLikeChange, onPinToggle, onRequestDelete, onRequestReport, requireAuth, registerThread, isReply = false, }: { comment: Comment; viewerId?: string; isPostAuthor: boolean; onReply: (c: Comment) => void; onLikeChange: (likes: number, liked: boolean) => void; onPinToggle: () => void; onRequestDelete: (c: Comment) => void; onRequestReport: (c: Comment) => void; requireAuth: () => Promise; /** Top-level rows register a callback so a newly-posted reply can be spliced * into their open thread. Replies pass nothing. */ registerThread?: (fn: ((c: Comment) => void) | null) => void; isReply?: boolean; }) { const [replies, setReplies] = useState([]); const [showReplies, setShowReplies] = useState(false); const [repliesLoaded, setRepliesLoaded] = useState(false); const likeBusy = useRef(false); const canDelete = viewerId === comment.user_id || isPostAuthor; const canReport = !!viewerId && viewerId !== comment.user_id; const pinnable = isPostAuthor && !isReply; // Expose a splice hook so the drawer can append a reply created from the // composer. Replies created while collapsed still get the count bump; they // show once expanded (re-fetched) or appended if already open. useEffect(() => { if (isReply || !registerThread) return; registerThread((c: Comment) => { if (c.body === "__deleted__") { setReplies((prev) => prev.filter((x) => x.id !== c.id)); return; } setRepliesLoaded(true); setShowReplies(true); setReplies((prev) => prev.some((x) => x.id === c.id) ? prev : [...prev, c], ); }); return () => registerThread(null); }, [isReply, registerThread]); async function toggleReplies() { if (!showReplies && !repliesLoaded) { try { const rows = await commentsApi.replies(comment.id, { limit: 50 }); setReplies(rows ?? []); setRepliesLoaded(true); } catch { /* ignore */ } } setShowReplies((v) => !v); } async function likeToggle() { if (likeBusy.current) return; if (!(await requireAuth())) return; likeBusy.current = true; const next = !comment.viewer_has_liked; // optimistic onLikeChange(Math.max(0, comment.likes_count + (next ? 1 : -1)), next); try { const res = next ? await commentsApi.like(comment.id) : await commentsApi.unlike(comment.id); onLikeChange(res.likes_count, next); } catch (err) { onLikeChange(comment.likes_count, comment.viewer_has_liked); toast.error( err instanceof SocialError ? err.message : "Could not update like", { duration: 3000 }, ); } finally { likeBusy.current = false; } } return (
  • {comment.author_name} {!isReply && comment.pinned_at ? ( Pinned ) : null} {timeAgo(comment.created_at)}
    onRequestDelete(comment)} onReport={() => onRequestReport(comment)} />

    {comment.reply_to_handle ? ( @{comment.reply_to_handle}{" "} ) : null} {renderMentions(comment.body)}

    {/* Inline action row */}
    {/* Replies */} {!isReply && comment.reply_count > 0 ? ( ) : null} {!isReply && showReplies ? (
      {replies.map((r) => ( setReplies((prev) => prev.map((x) => x.id === r.id ? { ...x, likes_count: likes, viewer_has_liked: liked } : x, ), ) } onPinToggle={() => {}} onRequestDelete={onRequestDelete} onRequestReport={onRequestReport} requireAuth={requireAuth} isReply /> ))}
    ) : null}
  • ); }