"use client";
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 { CommissionPushEntryPrompt } from "./push-entry-prompt";
type ChatMessage = { id: string; authorId: string; authorName: string; text: string | null;
authorImage: string | null; replyToId: string | null;
imageUrl: string | null; createdAt: Date; reactions: { userId: string; emoji: string }[] };
type OptimisticMessage = { localId: string; serverId: string | null; text: string; imageUrl: string | null;
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}`;
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 {emoji};
// CDN images are already 64 px assets displayed at 16 px.
// eslint-disable-next-line @next/next/no-img-element
return
setFailed(true)} />;
}
function ReplyPreview({ message }: { message: ChatMessage }) {
return
{message.authorName}
{message.text || (message.imageUrl ? "รูปภาพ" : "ข้อความ")}
;
}
function CommissionMessageImage({ src }: { src: string }) {
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 ;
}
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 [image, setImage] = useState(null);
const [imagePreview, setImagePreview] = useState(null);
const imagePreviewRef = useRef(null);
const optimisticImageUrls = useRef(new Set());
const [optimisticMessages, setOptimisticMessages] = useState([]);
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>({});
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));
const visibleMessages = messages.filter((message) => !deletedMessageIds.has(message.id));
const visibleMessageById = new Map(visibleMessages.map((message) => [message.id, message]));
const replyTarget = replyToId ? visibleMessageById.get(replyToId) ?? null : null;
useEffect(() => () => {
if (imagePreviewRef.current) URL.revokeObjectURL(imagePreviewRef.current);
for (const url of optimisticImageUrls.current) URL.revokeObjectURL(url);
}, []);
useEffect(() => {
for (const message of optimisticMessages) {
if (message.serverId && messages.some((saved) => saved.id === message.serverId) &&
message.imageUrl && optimisticImageUrls.current.has(message.imageUrl)) {
URL.revokeObjectURL(message.imageUrl);
optimisticImageUrls.current.delete(message.imageUrl);
}
}
}, [messages, optimisticMessages]);
useEffect(() => {
const originalTitle = document.title;
let managedTitle = ticketTitle;
let unread = 0;
document.title = ticketTitle;
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);
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 setAttachment(file: File | null) {
if (imagePreviewRef.current) URL.revokeObjectURL(imagePreviewRef.current);
const preview = file ? URL.createObjectURL(file) : null;
imagePreviewRef.current = preview;
setImage(file);
setImagePreview(preview);
}
function chooseAttachment(file: File | null) {
if (file && (file.size > 6 * 1024 * 1024 ||
!["image/png", "image/jpeg", "image/webp"].includes(file.type))) {
setError("แนบได้เฉพาะรูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 6 MB");
return;
}
setError("");
setAttachment(file);
}
function handleComposerPaste(event: ClipboardEvent) {
const pastedImage = Array.from(event.clipboardData.items)
.find((item) => item.kind === "file" && item.type.startsWith("image/"))?.getAsFile();
if (!pastedImage) return;
event.preventDefault();
if (fileRef.current) fileRef.current.value = "";
chooseAttachment(pastedImage);
}
async function send(event: FormEvent) {
event.preventDefault();
if (busy || (!text.trim() && !image)) return;
setBusy(true); setError("");
sendingRef.current = true;
const form = event.currentTarget;
const originalText = text;
const originalImage = image;
const localId = String(++nextOptimisticId.current);
const imageUrl = image ? URL.createObjectURL(image) : null;
if (imageUrl) optimisticImageUrls.current.add(imageUrl);
setOptimisticMessages((current) => [...current.filter((message) => !message.serverId ||
!messages.some((saved) => saved.id === message.serverId)), {
localId, serverId: null, text: text.trim(), imageUrl, 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");
if (image) data.set("image", image);
else data.delete("image");
form.reset(); setText(""); setAttachment(null); 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) {
if (imageUrl) { URL.revokeObjectURL(imageUrl); optimisticImageUrls.current.delete(imageUrl); }
setOptimisticMessages((current) => current.filter((message) => message.localId !== localId));
setText(originalText); setAttachment(originalImage); 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 <>{!admin && }
Ticket นี้ปิดแล้ว
ทีมงานปิด Ticket นี้แล้ว คุณยังอ่านบทสนทนาเดิมได้
setClosedAlertOpen(false)}>รับทราบ
แชต{admin ? "กับลูกค้า" : "กับทีมงาน"}
พูดคุยและส่งรูปภาพเกี่ยวกับคำขอนี้
{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) => {
const mine = message.authorId === userId;
const sentAt = new Date(message.createdAt);
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
{message.authorImage && }
{message.authorName.trim().charAt(0).toUpperCase() || "?"}
{mine ? "คุณ" : message.authorName}
{parent && }
{message.text && {message.text}
}
{message.imageUrl && }
{status === "open" &&
setReactionFor(open ? message.id : null)}>
}>
void react(message.id, value.emoji)} />
}
{status === "open" &&
}
{mine &&
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 && {message.text}
}
{message.imageUrl &&
}
{message.status === "sending" ? "กำลังส่ง..." : "ส่งแล้ว"}
)}
}
{typing && status === "open" && {typing.name} typing...
}
{status === "open" ? : Ticket นี้ปิดแล้ว หากต้องการพูดคุยต่อ ให้ผู้ดูแลเปิด Ticket อีกครั้ง
}
{error && {error}}
>;
}