Files
buzz-sheet/components/commission/ticket-chat.tsx
T
gunshiz 95c45841e7
CI / Verify (push) Failing after 1m4s
CI / Build immutable images and deploy (push) Skipped
feat : update i guess
2026-10-03 15:47:47 +07:00

1178 lines
47 KiB
TypeScript

"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";
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 <span aria-hidden="true">{emoji}</span>;
// CDN images are already 64 px assets displayed at 16 px.
// eslint-disable-next-line @next/next/no-img-element
return (
<img
src={`https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/${unified}.png`}
alt=""
width={16}
height={16}
loading="lazy"
decoding="async"
onError={() => setFailed(true)}
/>
);
}
function ReplyPreview({ message }: { message: ChatMessage }) {
return (
<div className="max-w-full rounded-md bg-muted/50 px-2 py-1 text-xs">
<span className="font-medium">{message.authorName}</span>
<p className="truncate text-muted-foreground">
{message.text || (imageUrlsOf(message).length ? "รูปภาพ" : "ข้อความ")}
</p>
</div>
);
}
function CommissionMessageImage({ src, compact = false }: { src: string; compact?: boolean }) {
const imageRef = useRef<HTMLImageElement>(null);
const [state, setState] = useState<"loading" | "loaded" | "error">("loading");
useEffect(() => {
if (imageRef.current?.complete)
setState(imageRef.current.naturalWidth > 0 ? "loaded" : "error");
}, [src]);
return (
<Dialog>
<DialogTrigger
render={
<button
type="button"
disabled={state !== "loaded"}
className={cn("block cursor-zoom-in overflow-hidden rounded-md disabled:cursor-default",
compact ? "relative aspect-square w-full" : "max-w-full")}
aria-label="ขยายรูปภาพที่แนบ"
/>
}
>
{state === "loading" && (
<Skeleton
className={compact ? "absolute inset-0 size-full rounded-none" : "h-40 w-48 max-w-full rounded-lg"}
aria-label="กำลังโหลดรูปภาพ"
/>
)}
{state === "error" && (
<span className={cn("block px-4 py-2.5 text-xs", compact &&
"absolute inset-0 flex items-center justify-center p-1 text-center")}>โหลดรูปภาพไม่สำเร็จ</span>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imageRef}
src={src}
alt="รูปภาพที่แนบในข้อความ"
onLoad={() => 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",
)}
/>
</DialogTrigger>
<DialogContent className="w-fit max-h-[90svh] max-w-[calc(100%-2rem)] p-2 sm:max-w-[calc(100%-2rem)]">
<DialogTitle className="sr-only">รูปภาพที่แนบในข้อความ</DialogTitle>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt="รูปภาพที่แนบในข้อความขนาดใหญ่"
className="max-h-[calc(90svh-1rem)] max-w-full object-contain"
/>
</DialogContent>
</Dialog>
);
}
function CommissionMediaGrid({ urls, pending = false }: { urls: string[]; pending?: boolean }) {
if (!urls.length) return null;
if (urls.length === 1)
return <div className={cn(pending && "opacity-50")}><CommissionMessageImage src={urls[0]} /></div>;
return <div className={cn("grid max-w-full gap-1 overflow-hidden rounded-lg",
urls.length === 2 ? "w-[min(72vw,24rem)] grid-cols-2" : "w-[min(72vw,36rem)] grid-cols-3",
pending && "opacity-50")}>
{urls.map((url) => <CommissionMessageImage key={url} src={url} compact />)}
</div>;
}
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<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const stayAtBottom = useRef(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [text, setText] = useState("");
const [images, setImages] = useState<File[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const imagePreviewsRef = useRef<string[]>([]);
const optimisticImageUrls = useRef(new Set<string>());
const [optimisticMessages, setOptimisticMessages] = useState<
OptimisticMessage[]
>([]);
const nextOptimisticId = useRef(0);
const [reactionFor, setReactionFor] = useState<string | null>(null);
const [replyToId, setReplyToId] = useState<string | null>(null);
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
const [closedAlertOpen, setClosedAlertOpen] = useState(false);
const [statusBusy, setStatusBusy] = useState(false);
const [optimisticReactions, setOptimisticReactions] = useState<
Record<string, OptimisticReaction>
>({});
const pendingReactions = useRef(new Set<string>());
const [deleteFor, setDeleteFor] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [deletedMessageIds, setDeletedMessageIds] = useState<Set<string>>(
() => 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<typeof setTimeout> | undefined;
const refresh = () => {
if (sendingRef.current) deferredRefresh.current = true;
else router.refresh();
};
const receive = (event: Event) => {
const value = (event as MessageEvent<string>).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<HTMLTextAreaElement>) {
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<HTMLFormElement>) {
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<HTMLTextAreaElement>) {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}
return (
<>
{!admin && <CommissionPushEntryPrompt scope="customer" />}
<AlertDialog open={closedAlertOpen} onOpenChange={setClosedAlertOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Ticket นี้ปิดแล้ว</AlertDialogTitle>
<AlertDialogDescription>
ทีมงานปิด Ticket นี้แล้ว คุณยังอ่านบทสนทนาเดิมได้
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setClosedAlertOpen(false)}>
รับทราบ
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Card className="h-full min-h-0 min-w-0 gap-0 py-0">
<CardHeader className="shrink-0 border-b py-3">
<CardTitle>แชต{admin ? "กับลูกค้า" : "กับทีมงาน"}</CardTitle>
<CardAction className="flex flex-wrap items-center gap-2">
<Badge variant={status === "open" ? "outline" : "destructive"}
className={status === "open" ? "border-success/30 bg-success/10 text-success" : undefined}>
{status === "open" ? "เปิดอยู่" : "ปิดแล้ว"}
</Badge>
{admin &&
(status === "open" ? (
<AlertDialog
open={closeConfirmOpen}
onOpenChange={setCloseConfirmOpen}
>
<AlertDialogTrigger
render={
<Button
variant="outline"
size="sm"
disabled={statusBusy}
/>
}
>
ปิด Ticket
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>ปิด Ticket นี้?</AlertDialogTitle>
<AlertDialogDescription>
ลูกค้าจะได้รับแจ้งว่า Ticket ปิดแล้ว
และจะส่งข้อความเพิ่มไม่ได้จนกว่าจะเปิดอีกครั้ง
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
<AlertDialogAction onClick={() => void toggleStatus()}>
ยืนยันการปิด
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Button
variant="outline"
size="sm"
disabled={statusBusy}
onClick={() => void toggleStatus()}
>
เปิดอีกครั้ง
</Button>
))}
</CardAction>
</CardHeader>
{!admin && status === "closed" && (
<Alert className="mx-3 mt-3 w-auto sm:mx-5">
<AlertDescription>
Ticket นี้ปิดแล้ว คุณยังอ่านบทสนทนาเดิมได้
</AlertDescription>
</Alert>
)}
<CardContent
ref={scrollRef}
role="log"
aria-label="ข้อความใน Ticket"
aria-live="polite"
onScroll={(event) => {
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 ? (
<Empty className="h-full">
<EmptyHeader>
<EmptyMedia variant="icon">
<MessageCircle />
</EmptyMedia>
<EmptyTitle>เริ่มบทสนทนา</EmptyTitle>
<EmptyDescription>
ส่งข้อความหรือรูปภาพเพื่อพูดคุยเกี่ยวกับคำขอนี้
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<MessageGroup className="gap-3">
{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 (
<Message key={message.id} align={mine ? "end" : "start"}>
<MessageAvatar className={showAvatar ? undefined : "bg-transparent"}>
{showAvatar && (
<Avatar size="sm">
{message.authorImage && (
<AvatarImage src={message.authorImage} alt="" />
)}
<AvatarFallback>
{message.authorName.trim().charAt(0).toUpperCase() || "?"}
</AvatarFallback>
</Avatar>
)}
</MessageAvatar>
<MessageContent
className={cn(
"w-full gap-0.5",
mine ? "items-end" : "items-start",
)}
>
{showTime && (
<div
className={cn(
"w-full px-1 text-center text-xs font-medium text-muted-foreground",
mine ? "translate-x-5" : "-translate-x-5",
)}
>
<time dateTime={sentAt.toISOString()}>
{`${padTime(sentAt.getDate())}/${padTime(sentAt.getMonth() + 1)}/${sentAt.getFullYear()} ${padTime(sentAt.getHours())}:${padTime(sentAt.getMinutes())}`}
</time>
</div>
)}
{!mine && showAuthor && (
<MessageHeader className="px-1">{message.authorName}</MessageHeader>
)}
<div
className={cn(
"flex max-w-[min(82%,36rem)] items-center gap-1",
mine && "flex-row-reverse",
)}
>
<Bubble
variant={
message.imageUrls.length
? "ghost"
: mine
? "default"
: "secondary"
}
className="min-w-0 max-w-full"
>
<BubbleContent
className={cn(
"max-w-full",
message.imageUrls.length
? "border-0 bg-transparent p-0 shadow-none"
: "px-4 py-2.5 shadow-sm",
mine ? "rounded-br-md" : "rounded-bl-md",
)}
>
{parent && <ReplyPreview message={parent} />}
{message.text && (
<p
className={cn(
"whitespace-pre-wrap",
mine && message.imageUrls.length && "text-foreground",
message.imageUrls.length > 0 && "pb-2",
)}
>
{message.text}
</p>
)}
<CommissionMediaGrid urls={message.imageUrls} />
</BubbleContent>
</Bubble>
<div className="flex shrink-0 items-center gap-0.5 rounded-md bg-card/90 opacity-0 transition-opacity pointer-events-none group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-focus-within/message:pointer-events-auto group-focus-within/message:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
{status === "open" && (
<Popover
open={reactionFor === message.id}
onOpenChange={(open) =>
setReactionFor(open ? message.id : null)
}
>
<PopoverTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="เพิ่มรีแอ็กชัน"
/>
}
>
<SmilePlus />
</PopoverTrigger>
<PopoverContent
align={mine ? "end" : "start"}
className="w-auto p-0"
>
<EmojiPicker
lazyLoadEmojis
onEmojiClick={(value) =>
void react(message.id, value.emoji)
}
/>
</PopoverContent>
</Popover>
)}
{status === "open" && (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="ตอบกลับข้อความ"
onClick={() => {
setReplyToId(message.id);
document
.getElementById("commission-message")
?.focus();
}}
>
<ReplyIcon />
</Button>
)}
{mine && status === "open" && (
<AlertDialog
open={deleteFor === message.id}
onOpenChange={(open) =>
setDeleteFor(open ? message.id : null)
}
>
<AlertDialogTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="ลบข้อความ"
disabled={Boolean(deletingId)}
/>
}
>
<Trash2 />
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
ลบข้อความนี้?
</AlertDialogTitle>
<AlertDialogDescription>
ข้อความและรูปภาพที่แนบจะถูกลบถาวร
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() =>
void deleteMessage(message.id)
}
>
ลบข้อความ
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
{counts.length > 0 && (
<div
className={cn(
"flex flex-wrap gap-1",
mine && "justify-end",
)}
>
{counts.map(({ emoji, count, active, pending }) => (
<Button
key={emoji}
type="button"
size="xs"
variant={active ? "secondary" : "outline"}
disabled={status !== "open" || pending}
aria-label={`${emoji} ${count} คน`}
aria-pressed={active}
onClick={() => react(message.id, emoji)}
>
<span
className={cn(
"inline-flex items-center gap-1",
pending
? "text-muted-foreground"
: "text-foreground",
)}
>
<ReactionEmoji emoji={emoji} /> {count}
</span>
</Button>
))}
</div>
)}
</MessageContent>
</Message>
);
})}
{visibleOptimisticMessages.map((message) => (
<Message key={message.localId} align="end">
<MessageContent className="w-full items-end gap-1">
<Bubble
variant={message.imageUrls.length ? "ghost" : "default"}
className="max-w-[min(82%,36rem)]"
>
<BubbleContent
className={cn(
"max-w-full",
message.imageUrls.length
? "border-0 bg-transparent p-0 shadow-none"
: "px-4 py-2.5 shadow-sm",
)}
>
{message.replyToId &&
visibleMessageById.get(message.replyToId) && (
<ReplyPreview
message={
visibleMessageById.get(message.replyToId)!
}
/>
)}
{message.text && (
<p
className={cn(
"whitespace-pre-wrap",
message.status === "sending" &&
(message.imageUrls.length ? "text-muted-foreground" : "text-primary-foreground/70"),
message.imageUrls.length > 0 && message.status !== "sending" && "text-foreground",
message.imageUrls.length > 0 && "pb-2",
)}
>
{message.text}
</p>
)}
<CommissionMediaGrid urls={message.imageUrls} pending={message.status === "sending"} />
</BubbleContent>
</Bubble>
{message.status === "sending" && <MessageFooter className="w-full justify-end gap-1 px-1">
กำลังส่ง...
</MessageFooter>}
</MessageContent>
</Message>
))}
</MessageGroup>
)}
</CardContent>
<CardFooter className="block max-h-[50%] shrink-0 overflow-y-auto bg-card p-3 sm:p-4">
{typing && status === "open" && (
<p role="status" className="mb-2 text-xs text-muted-foreground">
{typing.name} typing...
</p>
)}
{status === "open" ? (
<form onSubmit={send}>
<FieldGroup className="gap-2">
{replyTarget && (
<div className="flex items-start gap-2 rounded-lg border bg-muted/30 p-2">
<div className="min-w-0 flex-1">
<ReplyPreview message={replyTarget} />
</div>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="ยกเลิกการตอบกลับ"
onClick={() => setReplyToId(null)}
>
<X />
</Button>
</div>
)}
{images.length > 0 && (
<div>
<div className="grid max-w-md grid-cols-5 gap-1.5" aria-label={`รูปภาพที่แนบ ${images.length} รูป`}>
{imagePreviews.map((preview, index) => (
<div key={preview} className="relative aspect-square overflow-hidden rounded-md bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={preview} alt={`ตัวอย่างรูปภาพที่แนบ ${index + 1}`}
className="absolute inset-0 size-full object-cover" />
<Button type="button" variant="secondary" size="icon-xs"
aria-label={`นำรูปภาพที่ ${index + 1} ออก`}
className="absolute top-0.5 right-0.5 shadow-sm"
onClick={() => setAttachments(images.filter((_, position) => position !== index))}>
<X />
</Button>
</div>
))}
</div>
<p className="mt-1 text-xs text-muted-foreground">{images.length}/10 รูป · สูงสุด 10 MiB ต่อรูป</p>
</div>
)}
<Field>
<FieldLabel htmlFor="commission-message" className="sr-only">
ข้อความ
</FieldLabel>
<InputGroup className="min-h-12 rounded-2xl">
<InputGroupAddon
align="inline-start"
className="self-end pb-1.5"
>
<InputGroupButton
type="button"
variant="ghost"
size="icon-sm"
disabled={busy || images.length >= 10}
aria-label="แนบรูปภาพ"
onClick={() => fileRef.current?.click()}
>
<ImagePlus />
</InputGroupButton>
</InputGroupAddon>
<InputGroupTextarea
id="commission-message"
name="text"
value={text}
onChange={(event) => {
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"
/>
<InputGroupAddon
align="inline-end"
className="self-end pb-1.5"
>
<InputGroupButton
type="submit"
variant="default"
size="sm"
aria-label={busy ? "กำลังส่งข้อความ" : "ส่งข้อความ"}
disabled={busy || (!text.trim() && !images.length)}
>
<SendHorizontal data-icon="inline-start" />
<span className="hidden sm:inline">
{busy ? "กำลังส่ง..." : "ส่ง"}
</span>
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<span id="commission-message-hint" className="sr-only">
กด Enter เพื่อส่ง หรือ Shift+Enter เพื่อขึ้นบรรทัดใหม่
</span>
</Field>
<input
ref={fileRef}
id="commission-image"
name="image"
type="file"
aria-label="แนบรูปภาพ"
className="sr-only"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(event) => {
chooseAttachments(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
</FieldGroup>
</form>
) : (
<p className="text-center text-sm text-muted-foreground">
Ticket นี้ปิดแล้ว หากต้องการพูดคุยต่อ ให้ผู้ดูแลเปิด Ticket
อีกครั้ง
</p>
)}
{error && (
<Alert variant="destructive" className="mt-3">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</CardFooter>
</Card>
</>
);
}