"use client"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type ReactNode, } from "react"; import { BanIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, EllipsisVerticalIcon, HeartIcon, PencilIcon, ShieldCheckIcon, ThumbsDownIcon, ThumbsUpIcon, Trash2Icon, } from "lucide-react"; import { toast } from "sonner"; import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent, } from "@/components/ui/accordion"; import { cn } from "@/lib/utils"; import { SimpleTooltip } from "@/components/tooltip"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { CommentSkeleton } from "./comment-skeleton"; import { Separator } from "@/components/ui/separator"; import { TableCell, TableRow } from "@/components/ui/table"; import type { CommentHistoryItem, CommentImage, CommentItem, CommentViewer, } from "@/lib/comments/types"; import { CommentImageView } from "./comment-image"; import { CommentComposer } from "./comment-composer"; import { commentRequest, commentSignInHref } from "./client"; import { useCommentFeed } from "./use-comment-feed"; // A single minute clock for all timestamps, with a stable SSR snapshot. let clockTime = 0; let clockTimer: ReturnType | undefined; const clockListeners = new Set<() => void>(); function subscribeClock(listener: () => void) { clockListeners.add(listener); clockTime = Date.now(); if (!clockTimer) clockTimer = setInterval(() => { clockTime = Date.now(); for (const notify of clockListeners) notify(); }, 60_000); return () => { clockListeners.delete(listener); if (!clockListeners.size) { clearInterval(clockTimer); clockTimer = undefined; } }; } const clockSnapshot = () => clockTime; const serverClockSnapshot = () => 0; const timestampFormatter = new Intl.DateTimeFormat("th-TH", { calendar: "gregory", timeZone: "Asia/Bangkok", day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }); function CommentTimestamp({ value }: { value: string }) { const now = useSyncExternalStore( subscribeClock, clockSnapshot, serverClockSnapshot, ); function relativeTime() { const seconds = Math.round((new Date(value).getTime() - now) / 1000); const absolute = Math.abs(seconds); if (absolute < 60) return "เมื่อสักครู่"; const formatter = new Intl.RelativeTimeFormat("th-TH", { numeric: "auto" }); const units: [number, Intl.RelativeTimeFormatUnit][] = [ [31536000, "year"], [2592000, "month"], [86400, "day"], [3600, "hour"], [60, "minute"], ]; const [size, unit] = units.find(([size]) => absolute >= size)!; return formatter.format(Math.round(seconds / size), unit); } return ( ); } export function CommentImages({ images }: { images: CommentImage[] }) { const [selectedIndex, setSelectedIndex] = useState(null); const selected = selectedIndex === null ? null : images[selectedIndex]; return images.length ? ( <>
{images.map((image, index) => ( ))}
{ if (!open) setSelectedIndex(null); }} > { if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); setSelectedIndex((current) => Math.max( 0, Math.min( images.length - 1, (current ?? 0) + (event.key === "ArrowLeft" ? -1 : 1), ), ), ); } }} > {"รูปภาพประกอบความคิดเห็น"} {"รูปที่"} {(selectedIndex ?? 0) + 1} {"จาก"} {images.length} {selected && ( <> {images.length > 1 && (
)} )}
) : null; } function CommentText({ text }: { text: string }) { return (

{text.split(/(https?:\/\/[^\s<>]+)/g).map((part, i) => /^https?:\/\//.test(part) ? ( {part} ) : ( part ), )}

); } function RevisionHistory({ id }: { id: string }) { const [items, setItems] = useState([]); const [cursor, setCursor] = useState(null); const [error, setError] = useState(""); const [busy, setBusy] = useState(true); const load = useCallback( async (next?: string) => { setBusy(true); try { const result = await commentRequest<{ items: CommentHistoryItem[]; nextCursor: string | null; }>(`/api/comments/${id}/history${next ? `?cursor=${next}` : ""}`); setItems((current) => next ? [...current, ...result.items] : result.items, ); setCursor(result.nextCursor); setError(""); } catch (cause) { setError( cause instanceof Error ? cause.message : "ไม่สามารถโหลดประวัติการแก้ไขได้", ); } finally { setBusy(false); } }, [id], ); useEffect(() => { void Promise.resolve().then(() => load()); }, [load]); return (
{error && ( {error} )} {items.map((revision) => (

{"เวอร์ชัน"} {revision.version} ·{" "} {new Date(revision.createdAt).toLocaleString("th-TH")}

))} {busy && } {cursor && ( )} {error && ( )}
); } export function CommentCard({ item, viewer, onChange, revision = 0, inbox = false, inboxReply = false, moderationDetails = false, replies = true, defaultExpanded = false, focusReplyId, highlighted = false, overview, }: { item: CommentItem; viewer: CommentViewer | null; onChange: () => void | Promise; revision?: number; inbox?: boolean; inboxReply?: boolean; moderationDetails?: boolean; replies?: boolean; defaultExpanded?: boolean; focusReplyId?: string; highlighted?: boolean; overview?: ReactNode; }) { const router = useRouter(); const fullWidthMobile = inbox || moderationDetails; const [mode, setMode] = useState<"reply" | "edit" | null>(null); const [expanded, setExpanded] = useState(defaultExpanded); const [hasOpenedReplies, setHasOpenedReplies] = useState(defaultExpanded); function expandReplies(open: boolean) { setExpanded(open); if (open) setHasOpenedReplies(true); } const replyToggleRef = useRef(null); const [history, setHistory] = useState(false); const [deleting, setDeleting] = useState(false); const [banning, setBanning] = useState(false); const [pending, setPending] = useState(false); const [reactionOverride, setReactionOverride] = useState<{ reaction: number; likes: number; hearted: boolean; } | null>(null); const optimistic = reactionOverride ?? { reaction: item.reaction, likes: item.likes, hearted: item.hearted, }; const heartedBy = item.heartedBy ?? (viewer?.admin && !item.hearted ? viewer : null); const heartLabel = `${heartedBy?.name || "ผู้ดูแล"} ถูกใจความคิดเห็นนี้`; const heartAvatar = ( {heartedBy?.name?.slice(0, 2) || "ผู"} ); const reactionQueue = useRef>(Promise.resolve()); const reactionSequence = useRef(0); const queuedReactions = useRef(0); const baseReaction = useRef({ reaction: item.reaction, likes: item.likes, hearted: item.hearted, }); const desired = useRef({ reaction: item.reaction, likes: item.likes, hearted: item.hearted, }); const confirmedReaction = useRef({ reaction: item.reaction, likes: item.likes, hearted: item.hearted, }); useEffect(() => { baseReaction.current = { reaction: item.reaction, likes: item.likes, hearted: item.hearted, }; if ( !queuedReactions.current && (!reactionOverride || (item.reaction === reactionOverride.reaction && item.hearted === reactionOverride.hearted)) ) { desired.current = baseReaction.current; confirmedReaction.current = baseReaction.current; if (reactionOverride) void Promise.resolve().then(() => setReactionOverride((current) => current === reactionOverride ? null : current, ), ); } }, [item.reaction, item.likes, item.hearted, reactionOverride]); async function mutate( action: "reaction" | "heart" | "ban" | "delete", value?: number | boolean, ) { if (!viewer) return router.push(commentSignInHref()); if (action === "reaction" || action === "heart") { const sequence = ++reactionSequence.current; const next = action === "heart" ? { ...desired.current, hearted: Boolean(value) } : { ...desired.current, reaction: Number(value), likes: Math.max( 0, desired.current.likes + (value === 1 ? 1 : 0) - (desired.current.reaction === 1 ? 1 : 0), ), }; desired.current = next; setReactionOverride(next); queuedReactions.current++; const save = reactionQueue.current.then(async () => { await commentRequest(`/api/comments/${item.id}/${action}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify( action === "reaction" ? { value } : { hearted: value }, ), }); confirmedReaction.current = action === "heart" ? { ...confirmedReaction.current, hearted: Boolean(value) } : { ...confirmedReaction.current, reaction: Number(value), likes: Math.max( 0, confirmedReaction.current.likes + (value === 1 ? 1 : 0) - (confirmedReaction.current.reaction === 1 ? 1 : 0), ), }; await onChange(); }); reactionQueue.current = save.catch(() => undefined); try { await save; } catch (cause) { if (sequence === reactionSequence.current) { desired.current = confirmedReaction.current; setReactionOverride(confirmedReaction.current); } toast.error( cause instanceof Error ? cause.message : "ไม่สามารถบันทึกได้ กรุณาลองใหม่", ); } finally { queuedReactions.current--; if (!queuedReactions.current && sequence === reactionSequence.current) { const saved = confirmedReaction.current; desired.current = saved; setReactionOverride( baseReaction.current.reaction === saved.reaction && baseReaction.current.hearted === saved.hearted ? null : saved, ); } } return; } setPending(true); try { await commentRequest( `/api/comments/${item.id}${action === "delete" ? "" : `/${action}`}`, { method: action === "delete" ? "DELETE" : "PUT", ...(action === "delete" ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ banned: value }), }), }, ); setDeleting(false); setBanning(false); await onChange(); } catch (cause) { toast.error( cause instanceof Error ? cause.message : "ไม่สามารถอัปเดตความคิดเห็นได้", ); } finally { setPending(false); } } function compose(next: "reply" | "edit") { if (!viewer) return router.push(commentSignInHref()); setMode(next); } const canReadHistory = (!item.deleted && !item.hidden) || Boolean(viewer?.admin); const optionsMenu = (item.canEdit || viewer?.admin) && ( } > {item.canEdit && ( compose("edit")}> {"แก้ไข"} )} {(item.canEdit || viewer?.admin) && ( setDeleting(true)} > {"ลบ"} )} {viewer?.admin && ( item.authorBanned ? void mutate("ban", false) : setBanning(true) } > {item.authorBanned ? : } {item.authorBanned ? "ยกเลิกแบนผู้เขียน" : "แบนผู้เขียน"} )} ); const Container = inbox ? TableRow : "article"; const Content = inbox ? TableCell : "div"; return (
{item.authorName.slice(0, 2)}
{item.authorName} {item.authorAdmin && ( {"ผู้ดูแล"} )} {inbox && item.rootId && ( {"ตอบกลับ"} )} {(inbox || moderationDetails) && item.hidden && ( {item.ownHidden ? "ซ่อนอยู่" : "การสนทนาถูกซ่อน"} )} {(inbox || moderationDetails) && item.deleted && ( {"ลบแล้ว"} )} {item.version > 1 && canReadHistory && ( )}
{/* Inbox replies reach across the 280px content column and 24px cell padding. */} {!inbox && optionsMenu && (
{optionsMenu}
)}
{item.replyToName && (

{"ตอบกลับ"} {item.replyToName}

)} {mode === "edit" ? ( setMode(null)} onSaved={() => { setMode(null); onChange(); }} /> ) : ( <> {(item.deleted || item.hidden) && !viewer?.admin ? (

{item.deleted ? "ความคิดเห็นถูกลบแล้ว" : "ความคิดเห็นถูกซ่อนโดยผู้ดูแล"}

) : ( )}
{item.canInteract && ( )} {viewer?.admin ? ( ) : ( optimistic.hearted && !item.deleted && !item.hidden && ( heartAvatar ) )}
)} {mode === "reply" && ( setMode(null)} onSaved={() => { setMode(null); expandReplies(true); onChange(); }} /> )} {replies && !item.rootId && (item.replyCount > 0 || expanded) && ( expandReplies(value.includes("replies")) } > {`การตอบกลับ ${item.replyCount.toLocaleString("th-TH")} รายการ`} {hasOpenedReplies && (
)}
)}
{inbox && (
{optionsMenu} {overview}
)} {"ประวัติการแก้ไข"} {"ดูข้อความและรูปภาพในแต่ละเวอร์ชันของความคิดเห็น"} {history && } {"ลบความคิดเห็นนี้?"} { "ความคิดเห็นนี้ คำตอบกลับทุกระดับ ประวัติการแก้ไข และรูปภาพจะถูกลบอย่างถาวร ไม่สามารถกู้คืนได้" }
{ if (!pending) setBanning(open); }} > {"แบน"} {item.authorName}? { "ผู้เขียนจะถูกออกจากระบบและไม่สามารถเข้าสู่ระบบ แสดงความคิดเห็น หรือใช้งานบัญชีได้ จนกว่าผู้ดูแลจะยกเลิกแบน ความคิดเห็นเดิมจะยังอยู่" } {"ยกเลิก"} void mutate("ban", true)} > {"แบนผู้เขียน"}
); } function CommentReplies({ rootId, revision, onChange, focusReplyId, moderationDetails = false, inbox = false, }: { focusReplyId?: string; rootId: string; viewer: CommentViewer | null; revision: number; onChange: () => void | Promise; moderationDetails?: boolean; inbox?: boolean; }) { const feed = useCommentFeed(`/api/comments/${rootId}/replies?view=thread`); const { refresh, busy, error, page, more } = feed; const previousRevision = useRef(revision); useEffect(() => { if (previousRevision.current !== revision) { previousRevision.current = revision; void refresh({ silent: true }); } }, [revision, refresh]); const focused = useRef(null); useEffect(() => { if (!focusReplyId || focused.current === focusReplyId || busy || error) return; if (page.items.some((item) => item.id === focusReplyId)) { focused.current = focusReplyId; requestAnimationFrame(() => { const reply = document.getElementById(`comment-${focusReplyId}`); reply?.scrollIntoView({ block: "center" }); reply?.focus({ preventScroll: true }); }); } else if (page.nextCursor) void more(); }, [focusReplyId, busy, error, page, more]); return (
{feed.error && ( {feed.error} )} { await refresh({ silent: true }); await onChange(); }} /> {feed.busy && } {feed.page.nextCursor && ( )} {feed.error && ( )}
); } function CommentReplyTree({ items, viewer, onChange, focusReplyId, moderationDetails = false, inbox = false, }: { focusReplyId?: string; items: CommentItem[]; viewer: CommentViewer | null; onChange: () => void | Promise; moderationDetails?: boolean; inbox?: boolean; }) { const loaded = new Set(items.map((item) => item.id)); const groups = new Map(); for (const item of items) { const parent = item.replyToId && item.replyToId !== item.rootId && loaded.has(item.replyToId) ? item.replyToId : null; const siblings = groups.get(parent) ?? []; siblings.push(item); groups.set(parent, siblings); } function branch(parent: string | null): React.ReactNode { const siblings = groups.get(parent) ?? []; return siblings.map((reply, index) => (
{groups.has(reply.id) && (
{branch(reply.id)}
)}
)); } return
{branch(null)}
; }