"use client"; import Image from "@/components/ui/resilient-image"; import { useEffect, useRef, useState, type ClipboardEvent, type FormEvent, type KeyboardEvent, } from "react"; import { useRouter } from "next/navigation"; import dynamic from "next/dynamic"; import { ImagePlus, MessageCircle, ReplyIcon, SendHorizontal, SmilePlus, Trash2, X, } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Bubble, BubbleContent } from "@/components/ui/bubble"; import { Button } from "@/components/ui/button"; import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; import { Dialog, DialogContent, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from "@/components/ui/empty"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea, } from "@/components/ui/input-group"; import { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader, } from "@/components/ui/message"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { parseCommissionMessageEvent } from "@/lib/commission/message-event"; import { keepCommissionTitle } from "./keep-title"; type ChatMessage = { id: string; authorId: string; authorName: string; text: string | null; authorImage: string | null; replyToId: string | null; imageUrls: string[]; createdAt: Date; reactions: { userId: string; emoji: string }[]; }; type OptimisticMessage = { localId: string; serverId: string | null; text: string; imageUrls: string[]; replyToId: string | null; status: "sending" | "sent"; }; type OptimisticReaction = { active: boolean; status: "sending" | "sent" }; const EmojiPicker = dynamic(() => import("emoji-picker-react"), { ssr: false }); const reactionKey = (messageId: string, emoji: string) => `${messageId}:${emoji}`; const padTime = (value: number) => String(value).padStart(2, "0"); const imageUrlsOf = (message: { imageUrls?: string[]; imageUrl?: string | null }) => Array.isArray(message.imageUrls) ? message.imageUrls : message.imageUrl ? [message.imageUrl] : []; function ReactionEmoji({ emoji }: { emoji: string }) { const [failed, setFailed] = useState(false); const unified = Array.from(emoji, (character) => character.codePointAt(0)!.toString(16), ).join("-"); if (failed) return ; // CDN images are already 64 px assets displayed at 16 px. return ( setFailed(true)} /> ); } function ReplyPreview({ message }: { message: ChatMessage }) { return (
{message.authorName}

{message.text || (imageUrlsOf(message).length ? "รูปภาพ" : "ข้อความ")}

); } function CommissionMessageImage({ src, compact = false }: { src: string; compact?: boolean }) { const imageRef = useRef(null); const [state, setState] = useState<"loading" | "loaded" | "error">("loading"); useEffect(() => { if (imageRef.current?.complete) setState(imageRef.current.naturalWidth > 0 ? "loaded" : "error"); }, [src]); return ( } > {state === "loading" && ( )} {state === "error" && ( โหลดรูปภาพไม่สำเร็จ )} รูปภาพที่แนบในข้อความ setState("loaded")} onError={() => setState("error")} className={cn( compact ? "absolute inset-0 size-full object-cover" : "max-h-72 max-w-full object-contain", state === "loaded" ? "block" : "hidden", )} /> รูปภาพที่แนบในข้อความ รูปภาพที่แนบในข้อความขนาดใหญ่ ); } function CommissionMediaGrid({ urls, pending = false }: { urls: string[]; pending?: boolean }) { if (!urls.length) return null; if (urls.length === 1) return
; return
{urls.map((url) => )}
; } export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, messages, admin, }: { ticketId: string; ticketTitle: string; userId: string; status: string; messages: ChatMessage[]; admin: boolean; }) { const router = useRouter(); const scrollRef = useRef(null); const fileRef = useRef(null); const stayAtBottom = useRef(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [text, setText] = useState(""); const [images, setImages] = useState([]); const [imagePreviews, setImagePreviews] = useState([]); const imagePreviewsRef = useRef([]); const optimisticImageUrls = useRef(new Set()); const [optimisticMessages, setOptimisticMessages] = useState< OptimisticMessage[] >([]); const nextOptimisticId = useRef(0); const [reactionFor, setReactionFor] = useState(null); const [replyToId, setReplyToId] = useState(null); const [closeConfirmOpen, setCloseConfirmOpen] = useState(false); const [closedAlertOpen, setClosedAlertOpen] = useState(false); const [statusBusy, setStatusBusy] = useState(false); const [optimisticReactions, setOptimisticReactions] = useState< Record >({}); const pendingReactions = useRef(new Set()); const [deleteFor, setDeleteFor] = useState(null); const [deletingId, setDeletingId] = useState(null); const [deletedMessageIds, setDeletedMessageIds] = useState>( () => new Set(), ); const [typing, setTyping] = useState<{ userId: string; name: string } | null>( null, ); const lastTypingSent = useRef(0); const typingActive = useRef(false); const sendingRef = useRef(false); const deferredRefresh = useRef(false); const visibleOptimisticMessages = optimisticMessages.filter( (message) => !message.serverId || !messages.some((saved) => saved.id === message.serverId), ).map((message) => ({ ...message, imageUrls: imageUrlsOf(message) })); const visibleMessages = messages.filter( (message) => !deletedMessageIds.has(message.id), ).map((message) => ({ ...message, imageUrls: imageUrlsOf(message) })); const visibleMessageById = new Map( visibleMessages.map((message) => [message.id, message]), ); const replyTarget = replyToId ? (visibleMessageById.get(replyToId) ?? null) : null; useEffect( () => () => { for (const url of imagePreviewsRef.current) URL.revokeObjectURL(url); for (const url of optimisticImageUrls.current) URL.revokeObjectURL(url); }, [], ); useEffect(() => { for (const message of optimisticMessages) { const urls = imageUrlsOf(message); if ( message.serverId && messages.some((saved) => saved.id === message.serverId) && urls.some((url) => optimisticImageUrls.current.has(url)) ) { for (const url of urls) { if (optimisticImageUrls.current.delete(url)) URL.revokeObjectURL(url); } } } }, [messages, optimisticMessages]); useEffect(() => { const originalTitle = document.title; let managedTitle = ticketTitle; let unread = 0; const stopKeepingTitle = keepCommissionTitle(() => managedTitle); const markRead = () => { if (document.hidden || !document.hasFocus()) return; unread = 0; managedTitle = ticketTitle; document.title = ticketTitle; }; document.addEventListener("visibilitychange", markRead); window.addEventListener("focus", markRead); const source = new EventSource( `/api/commission/events?ticketId=${ticketId}`, ); let typingTimeout: ReturnType | undefined; const refresh = () => { if (sendingRef.current) deferredRefresh.current = true; else router.refresh(); }; const receive = (event: Event) => { const value = (event as MessageEvent).data; if (!value.startsWith("typing:")) { if (value === "status:closed" && !admin) setClosedAlertOpen(true); const message = parseCommissionMessageEvent(value); if ( message && message.authorId !== userId && (document.hidden || !document.hasFocus()) ) { unread += 1; managedTitle = `(${unread}) ${ticketTitle}`; document.title = managedTitle; } refresh(); return; } try { const update = JSON.parse(value.slice(7)) as { userId: string; name: string; active: boolean; }; if ( update.userId === userId || typeof update.name !== "string" || typeof update.active !== "boolean" ) return; if (typingTimeout) clearTimeout(typingTimeout); if (update.active) { setTyping({ userId: update.userId, name: update.name }); typingTimeout = setTimeout(() => setTyping(null), 6000); } else setTyping((current) => current?.userId === update.userId ? null : current, ); } catch { /* Ignore malformed transient signals. */ } }; source.addEventListener("changed", receive); source.addEventListener("open", refresh); return () => { if (typingTimeout) clearTimeout(typingTimeout); source.removeEventListener("changed", receive); source.removeEventListener("open", refresh); source.close(); document.removeEventListener("visibilitychange", markRead); window.removeEventListener("focus", markRead); stopKeepingTitle(); if (document.title === managedTitle) document.title = originalTitle; }; }, [ticketId, ticketTitle, userId, admin, router]); useEffect(() => { const viewport = scrollRef.current; if (viewport && stayAtBottom.current) viewport.scrollTop = viewport.scrollHeight; }, [messages, optimisticMessages]); useEffect(() => { const viewport = scrollRef.current; if (!viewport) return; const observer = new ResizeObserver(() => { if (stayAtBottom.current) viewport.scrollTop = viewport.scrollHeight; }); observer.observe(viewport); return () => observer.disconnect(); }, []); function sendTyping(active: boolean) { if (status !== "open") return; // eslint-disable-next-line react-hooks/purity -- This function runs only from input event handlers. const now = Date.now(); if (active && typingActive.current && now - lastTypingSent.current < 4000) return; if (!active && !typingActive.current) return; typingActive.current = active; if (active) lastTypingSent.current = now; void fetch(`/api/commission/tickets/${ticketId}/typing`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ active }), }).catch(() => undefined); } function setAttachments(files: File[]) { for (const url of imagePreviewsRef.current) URL.revokeObjectURL(url); const previews = files.map((file) => URL.createObjectURL(file)); imagePreviewsRef.current = previews; setImages(files); setImagePreviews(previews); } function chooseAttachments(files: File[]) { if (images.length + files.length > 10) { setError("แนบรูปภาพได้ไม่เกิน 10 รูปต่อข้อความ"); return; } if (files.some((file) => file.size === 0 || file.size > 10 * 1024 * 1024 || !["image/png", "image/jpeg", "image/webp"].includes(file.type))) { setError("แนบได้เฉพาะรูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 10 MiB ต่อรูป"); return; } setError(""); setAttachments([...images, ...files]); } function handleComposerPaste(event: ClipboardEvent) { const pastedImages = Array.from(event.clipboardData.items) .filter((item) => item.kind === "file" && item.type.startsWith("image/")) .map((item) => item.getAsFile()).filter((file): file is File => file !== null); if (!pastedImages.length) return; event.preventDefault(); if (fileRef.current) fileRef.current.value = ""; chooseAttachments(pastedImages); } async function send(event: FormEvent) { event.preventDefault(); if (busy || (!text.trim() && !images.length)) return; setBusy(true); setError(""); sendingRef.current = true; const form = event.currentTarget; const originalText = text; const originalImages = images; const localId = String(++nextOptimisticId.current); const imageUrls = images.map((file) => URL.createObjectURL(file)); for (const url of imageUrls) optimisticImageUrls.current.add(url); setOptimisticMessages((current) => [ ...current.filter( (message) => !message.serverId || !messages.some((saved) => saved.id === message.serverId), ), { localId, serverId: null, text: text.trim(), imageUrls, replyToId: replyTarget?.id ?? null, status: "sending", }, ]); stayAtBottom.current = true; try { const data = new FormData(form); data.set("text", text.trim()); if (replyTarget) data.set("replyToId", replyTarget.id); else data.delete("replyToId"); data.delete("image"); for (const image of images) data.append("image", image); form.reset(); setText(""); setAttachments([]); setReplyToId(null); sendTyping(false); const response = await fetch( `/api/commission/tickets/${ticketId}/messages`, { method: "POST", body: data }, ); if (!response.ok) throw new Error("ส่งข้อความไม่สำเร็จ กรุณาลองอีกครั้ง"); const result = (await response.json()) as { messageId: string }; setOptimisticMessages((current) => current.map((message) => message.localId === localId ? { ...message, serverId: result.messageId, status: "sent" } : message, ), ); stayAtBottom.current = true; sendingRef.current = false; deferredRefresh.current = false; router.refresh(); } catch (cause) { for (const url of imageUrls) { URL.revokeObjectURL(url); optimisticImageUrls.current.delete(url); } setOptimisticMessages((current) => current.filter((message) => message.localId !== localId), ); setText(originalText); setAttachments(originalImages); setReplyToId(replyTarget?.id ?? null); setError(cause instanceof Error ? cause.message : "ส่งข้อความไม่สำเร็จ"); sendingRef.current = false; if (deferredRefresh.current) { deferredRefresh.current = false; router.refresh(); } } finally { setBusy(false); } } async function react(messageId: string, emoji: string) { setReactionFor(null); const key = reactionKey(messageId, emoji); if (pendingReactions.current.has(key)) return; const message = messages.find((item) => item.id === messageId); if (!message) return; const savedActive = message.reactions.some( (item) => item.emoji === emoji && item.userId === userId, ); const previous = optimisticReactions[key]; const currentActive = previous?.status === "sent" && previous.active === savedActive ? savedActive : (previous?.active ?? savedActive); pendingReactions.current.add(key); setError(""); setOptimisticReactions((current) => ({ ...current, [key]: { active: !currentActive, status: "sending" }, })); try { const response = await fetch( `/api/commission/tickets/${ticketId}/reactions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messageId, emoji }), }, ); if (!response.ok) throw new Error(); const result = (await response.json()) as { active: boolean }; setOptimisticReactions((current) => ({ ...current, [key]: { active: result.active, status: "sent" }, })); router.refresh(); } catch { setOptimisticReactions((current) => { const next = { ...current }; delete next[key]; return next; }); setError("เปลี่ยนรีแอ็กชันไม่สำเร็จ กรุณาลองอีกครั้ง"); } finally { pendingReactions.current.delete(key); } } async function deleteMessage(messageId: string) { if (deletingId) return; setDeleteFor(null); setDeletingId(messageId); setError(""); try { const response = await fetch( `/api/commission/tickets/${ticketId}/messages/${messageId}`, { method: "DELETE" }, ); if (!response.ok) throw new Error(); setDeletedMessageIds((current) => new Set(current).add(messageId)); if (replyToId === messageId) setReplyToId(null); setReactionFor(null); router.refresh(); } catch { setError("ลบข้อความไม่สำเร็จ กรุณาลองอีกครั้ง"); } finally { setDeletingId(null); } } async function toggleStatus() { if (statusBusy) return; setStatusBusy(true); try { const response = await fetch( `/api/commission/tickets/${ticketId}/status`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: status === "open" ? "closed" : "open", }), }, ); if (!response.ok) throw new Error(); setCloseConfirmOpen(false); router.refresh(); } catch { setError("เปลี่ยนสถานะ Ticket ไม่สำเร็จ กรุณาลองอีกครั้ง"); } finally { setStatusBusy(false); } } function handleComposerKeyDown(event: KeyboardEvent) { if ( event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing ) { event.preventDefault(); event.currentTarget.form?.requestSubmit(); } } return ( <> Ticket นี้ปิดแล้ว ทีมงานปิด Ticket นี้แล้ว คุณยังอ่านบทสนทนาเดิมได้ setClosedAlertOpen(false)}> รับทราบ แชต{admin ? "กับลูกค้า" : "กับทีมงาน"} {!admin && สถานะนี้บอกว่าส่งข้อความได้หรือไม่ สอบถามความคืบหน้างานได้ในแชต} {admin ? (status === "open" ? "เปิดอยู่" : "ปิดแล้ว") : (status === "open" ? "แชตเปิดอยู่" : "แชตปิดแล้ว")} {admin && (status === "open" ? ( } > ปิด Ticket ปิด Ticket นี้? ลูกค้าจะได้รับแจ้งว่า Ticket ปิดแล้ว และจะส่งข้อความเพิ่มไม่ได้จนกว่าจะเปิดอีกครั้ง ยกเลิก void toggleStatus()}> ยืนยันการปิด ) : ( ))} {!admin && status === "closed" && ( Ticket นี้ปิดแล้ว คุณยังอ่านบทสนทนาเดิมได้ )} { const viewport = event.currentTarget; stayAtBottom.current = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80; }} className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain bg-muted/20 px-3 py-5 sm:px-5" > {visibleMessages.length === 0 && visibleOptimisticMessages.length === 0 ? ( เริ่มบทสนทนา ส่งข้อความหรือรูปภาพเพื่อพูดคุยเกี่ยวกับคำขอนี้ ) : ( {visibleMessages.map((message, index) => { const mine = message.authorId === userId; const sentAt = new Date(message.createdAt); const previous = visibleMessages[index - 1]; const showAuthor = !previous || previous.authorId !== message.authorId; const next = visibleMessages[index + 1]; const showAvatar = !next || next.authorId !== message.authorId; const previousAt = previous ? new Date(previous.createdAt) : null; const showTime = !previousAt || sentAt.getFullYear() !== previousAt.getFullYear() || sentAt.getMonth() !== previousAt.getMonth() || sentAt.getDate() !== previousAt.getDate() || sentAt.getHours() !== previousAt.getHours(); const parent = message.replyToId ? visibleMessageById.get(message.replyToId) : null; const emojiValues = new Set([ ...message.reactions.map((reaction) => reaction.emoji), ...Object.keys(optimisticReactions) .filter((key) => key.startsWith(`${message.id}:`)) .map((key) => key.slice(message.id.length + 1)), ]); const counts = [...emojiValues] .map((emoji) => { const savedCount = message.reactions.filter( (reaction) => reaction.emoji === emoji, ).length; const savedActive = message.reactions.some( (reaction) => reaction.emoji === emoji && reaction.userId === userId, ); const update = optimisticReactions[reactionKey(message.id, emoji)]; const visibleUpdate = update && (update.status === "sending" || update.active !== savedActive) ? update : null; const active = visibleUpdate?.active ?? savedActive; const count = savedCount + (visibleUpdate && active !== savedActive ? active ? 1 : -1 : 0); return { emoji, count, active, pending: update?.status === "sending", }; }) .filter(({ count }) => count > 0); return ( {showAvatar && ( {message.authorImage && ( )} {message.authorName.trim().charAt(0).toUpperCase() || "?"} )} {showTime && (
)} {!mine && showAuthor && ( {message.authorName} )}
{parent && } {message.text && (

0 && "pb-2", )} > {message.text}

)}
{status === "open" && ( setReactionFor(open ? message.id : null) } > } > void react(message.id, value.emoji) } /> )} {status === "open" && ( )} {mine && status === "open" && ( setDeleteFor(open ? message.id : null) } > } > ลบข้อความนี้? ข้อความและรูปภาพที่แนบจะถูกลบถาวร ยกเลิก void deleteMessage(message.id) } > ลบข้อความ )}
{counts.length > 0 && (
{counts.map(({ emoji, count, active, pending }) => ( ))}
)}
); })} {visibleOptimisticMessages.map((message) => ( {message.replyToId && visibleMessageById.get(message.replyToId) && ( )} {message.text && (

0 && message.status !== "sending" && "text-foreground", message.imageUrls.length > 0 && "pb-2", )} > {message.text}

)}
{message.status === "sending" && กำลังส่ง... }
))}
)}
{typing && status === "open" && (

{typing.name} typing...

)} {status === "open" ? (
{replyTarget && (
)} {images.length > 0 && (
{imagePreviews.map((preview, index) => (
{`ตัวอย่างรูปภาพที่แนบ
))}

{images.length}/10 รูป - สูงสุด 10 MiB ต่อรูป

)} ข้อความ = 10} aria-label="แนบรูปภาพ" onClick={() => fileRef.current?.click()} > { const next = event.target.value; setText(next); sendTyping(Boolean(next.trim())); }} onBlur={() => sendTyping(false)} onKeyDown={handleComposerKeyDown} onPaste={handleComposerPaste} placeholder="พิมพ์ข้อความ..." maxLength={4000} disabled={busy} aria-describedby="commission-message-hint" className="max-h-32 min-h-11 min-w-0 px-1.5 py-2.5" /> {busy ? "กำลังส่ง..." : "ส่ง"} กด Enter เพื่อส่ง หรือ Shift+Enter เพื่อขึ้นบรรทัดใหม่ { chooseAttachments(Array.from(event.target.files ?? [])); event.target.value = ""; }} />
) : (

Ticket นี้ปิดแล้ว หากต้องการพูดคุยต่อ ให้ผู้ดูแลเปิด Ticket อีกครั้ง

)} {error && ( {error} )}
); }