|
|
|
@@ -2,8 +2,9 @@
|
|
|
|
|
|
|
|
|
|
import { useEffect, useRef, useState, type ClipboardEvent, type FormEvent, type KeyboardEvent } from "react";
|
|
|
|
|
import { useRouter } from "next/navigation";
|
|
|
|
|
import { ImagePlus, MessageCircle, SendHorizontal, SmilePlus, Trash2, X } from "lucide-react";
|
|
|
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
|
|
|
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";
|
|
|
|
@@ -23,13 +24,31 @@ 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;
|
|
|
|
|
status: "sending" | "sent" };
|
|
|
|
|
replyToId: string | null; status: "sending" | "sent" };
|
|
|
|
|
type OptimisticReaction = { active: boolean; status: "sending" | "sent" };
|
|
|
|
|
const emojiChoices = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
|
|
|
|
|
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 <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 || (message.imageUrl ? "รูปภาพ" : "ข้อความ")}</p>
|
|
|
|
|
</div>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CommissionMessageImage({ src }: { src: string }) {
|
|
|
|
|
const imageRef = useRef<HTMLImageElement>(null);
|
|
|
|
|
const [state, setState] = useState<"loading" | "loaded" | "error">("loading");
|
|
|
|
@@ -74,6 +93,10 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
|
|
|
|
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);
|
|
|
|
@@ -87,6 +110,8 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
|
|
|
|
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);
|
|
|
|
@@ -125,6 +150,7 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
|
|
|
|
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;
|
|
|
|
@@ -154,15 +180,26 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
|
|
|
|
window.removeEventListener("focus", markRead);
|
|
|
|
|
if (document.title === managedTitle) document.title = originalTitle;
|
|
|
|
|
};
|
|
|
|
|
}, [ticketId, ticketTitle, userId, router]);
|
|
|
|
|
}, [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;
|
|
|
|
@@ -213,15 +250,17 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
|
|
|
|
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, status: "sending",
|
|
|
|
|
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); sendTyping(false);
|
|
|
|
|
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 };
|
|
|
|
@@ -232,7 +271,7 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
|
|
|
|
} catch (cause) {
|
|
|
|
|
if (imageUrl) { URL.revokeObjectURL(imageUrl); optimisticImageUrls.current.delete(imageUrl); }
|
|
|
|
|
setOptimisticMessages((current) => current.filter((message) => message.localId !== localId));
|
|
|
|
|
setText(originalText); setAttachment(originalImage);
|
|
|
|
|
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(); }
|
|
|
|
@@ -277,6 +316,7 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
|
|
|
|
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("ลบข้อความไม่สำเร็จ กรุณาลองอีกครั้ง"); }
|
|
|
|
@@ -284,12 +324,16 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>) {
|
|
|
|
@@ -299,17 +343,38 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return <>{!admin && <CommissionPushEntryPrompt scope="customer" />}<Card className="h-svh min-w-0 gap-0 py-0 lg:h-[min(85svh,900px)]">
|
|
|
|
|
<CardHeader className="border-b py-3">
|
|
|
|
|
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>
|
|
|
|
|
<CardDescription>พูดคุยและส่งรูปภาพเกี่ยวกับคำขอนี้</CardDescription>
|
|
|
|
|
<CardAction className="flex flex-wrap items-center gap-2">
|
|
|
|
|
<Badge variant={status === "open" ? "secondary" : "outline"}>{status === "open" ? "เปิดอยู่" : "ปิดแล้ว"}</Badge>
|
|
|
|
|
{admin && <Button variant="outline" size="sm" onClick={toggleStatus}>
|
|
|
|
|
{status === "open" ? "ปิด Ticket" : "เปิดอีกครั้ง"}
|
|
|
|
|
</Button>}
|
|
|
|
|
{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; }}
|
|
|
|
@@ -319,11 +384,17 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
|
|
|
|
<EmptyTitle>เริ่มบทสนทนา</EmptyTitle>
|
|
|
|
|
<EmptyDescription>ส่งข้อความหรือรูปภาพเพื่อพูดคุยเกี่ยวกับคำขอนี้</EmptyDescription>
|
|
|
|
|
</EmptyHeader>
|
|
|
|
|
</Empty> : <MessageGroup className="gap-5">
|
|
|
|
|
</Empty> : <MessageGroup className="gap-3">
|
|
|
|
|
{visibleMessages.map((message) => {
|
|
|
|
|
const mine = message.authorId === userId;
|
|
|
|
|
const sentAt = new Date(message.createdAt);
|
|
|
|
|
const counts = emojiChoices.map((emoji) => {
|
|
|
|
|
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)];
|
|
|
|
@@ -333,42 +404,35 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
|
|
|
|
return { emoji, count, active, pending: update?.status === "sending" };
|
|
|
|
|
}).filter(({ count }) => count > 0);
|
|
|
|
|
return <Message key={message.id} align={mine ? "end" : "start"}>
|
|
|
|
|
{!mine && <MessageAvatar><Avatar size="sm"><AvatarFallback>
|
|
|
|
|
<MessageAvatar><Avatar size="sm">
|
|
|
|
|
{message.authorImage && <AvatarImage src={message.authorImage} alt="" />}
|
|
|
|
|
<AvatarFallback>
|
|
|
|
|
{message.authorName.trim().charAt(0).toUpperCase() || "?"}
|
|
|
|
|
</AvatarFallback></Avatar></MessageAvatar>}
|
|
|
|
|
<MessageContent className={cn("w-auto max-w-[min(82%,36rem)] gap-1", mine ? "items-end" : "items-start")}>
|
|
|
|
|
</AvatarFallback></Avatar></MessageAvatar>
|
|
|
|
|
<MessageContent className={cn("w-full gap-0.5", mine ? "items-end" : "items-start")}>
|
|
|
|
|
<MessageHeader className="px-1">{mine ? "คุณ" : message.authorName}</MessageHeader>
|
|
|
|
|
<Bubble variant={message.imageUrl ? "ghost" : mine ? "muted" : "secondary"} className="max-w-full">
|
|
|
|
|
<div className={cn("flex max-w-[min(82%,36rem)] items-center gap-1", mine && "flex-row-reverse")}>
|
|
|
|
|
<Bubble variant={message.imageUrl ? "ghost" : mine ? "muted" : "secondary"} className="min-w-0 max-w-full">
|
|
|
|
|
<BubbleContent className={cn("max-w-full", message.imageUrl ? "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 && "text-foreground", message.imageUrl && "pb-2")}>{message.text}</p>}
|
|
|
|
|
{message.imageUrl && <CommissionMessageImage src={message.imageUrl} />}
|
|
|
|
|
</BubbleContent>
|
|
|
|
|
</Bubble>
|
|
|
|
|
{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={pending ? "text-muted-foreground" : "text-foreground"}>
|
|
|
|
|
{emoji} {count}
|
|
|
|
|
</span></Button>)}
|
|
|
|
|
</div>}
|
|
|
|
|
<MessageFooter className="gap-1 px-1">
|
|
|
|
|
<time dateTime={sentAt.toISOString()} title={sentAt.toLocaleString("th-TH")}>
|
|
|
|
|
{sentAt.toLocaleTimeString("th-TH", { hour: "2-digit", minute: "2-digit" })}
|
|
|
|
|
</time>
|
|
|
|
|
<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">
|
|
|
|
|
<div className="flex gap-1" aria-label="เลือกรีแอ็กชัน">
|
|
|
|
|
{emojiChoices.map((emoji) => <Button key={emoji} type="button" variant="ghost" size="icon-sm"
|
|
|
|
|
aria-label={`ตอบสนอง ${emoji}`} disabled={optimisticReactions[reactionKey(message.id, emoji)]?.status === "sending"}
|
|
|
|
|
onClick={() => react(message.id, emoji)}>{emoji}</Button>)}
|
|
|
|
|
</div>
|
|
|
|
|
<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 && <AlertDialog open={deleteFor === message.id}
|
|
|
|
|
onOpenChange={(open) => setDeleteFor(open ? message.id : null)}>
|
|
|
|
|
<AlertDialogTrigger render={<Button type="button" variant="ghost" size="icon-xs"
|
|
|
|
@@ -384,29 +448,54 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
|
|
|
|
</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>}
|
|
|
|
|
<MessageFooter className={cn("w-full justify-center gap-1 px-1 group-data-[align=end]/message:justify-center",
|
|
|
|
|
mine ? "translate-x-5" : "-translate-x-5")}>
|
|
|
|
|
<time dateTime={sentAt.toISOString()} title={sentAt.toLocaleString("th-TH")}>
|
|
|
|
|
{sentAt.toLocaleTimeString("th-TH", { hour: "2-digit", minute: "2-digit" })}
|
|
|
|
|
</time>
|
|
|
|
|
</MessageFooter>
|
|
|
|
|
</MessageContent>
|
|
|
|
|
</Message>;
|
|
|
|
|
})}
|
|
|
|
|
{visibleOptimisticMessages.map((message) => <Message key={message.localId} align="end">
|
|
|
|
|
<MessageContent className="w-auto max-w-[min(82%,36rem)] items-end gap-1">
|
|
|
|
|
<MessageContent className="w-full items-end gap-1">
|
|
|
|
|
<MessageHeader className="px-1">คุณ</MessageHeader>
|
|
|
|
|
<Bubble variant={message.imageUrl ? "ghost" : "muted"} className="max-w-full">
|
|
|
|
|
<Bubble variant={message.imageUrl ? "ghost" : "muted"} className="max-w-[min(82%,36rem)]">
|
|
|
|
|
<BubbleContent className={cn("max-w-full", message.imageUrl ? "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" ? "text-muted-foreground" : "text-foreground", message.imageUrl && "pb-2")}>{message.text}</p>}
|
|
|
|
|
{message.imageUrl && <div className={cn(message.status === "sending" && "opacity-50")}>
|
|
|
|
|
<CommissionMessageImage src={message.imageUrl} />
|
|
|
|
|
</div>}
|
|
|
|
|
</BubbleContent>
|
|
|
|
|
</Bubble>
|
|
|
|
|
<MessageFooter className="gap-1 px-1">{message.status === "sending" ? "กำลังส่ง..." : "ส่งแล้ว"}</MessageFooter>
|
|
|
|
|
<MessageFooter className="w-full justify-center gap-1 px-1 group-data-[align=end]/message:justify-center">
|
|
|
|
|
{message.status === "sending" ? "กำลังส่ง..." : "ส่งแล้ว"}
|
|
|
|
|
</MessageFooter>
|
|
|
|
|
</MessageContent>
|
|
|
|
|
</Message>)}
|
|
|
|
|
</MessageGroup>}
|
|
|
|
|
</CardContent>
|
|
|
|
|
<CardFooter className="block bg-card p-3 sm:p-4">
|
|
|
|
|
<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>}
|
|
|
|
|
{image && <div className="relative w-fit rounded-lg border bg-muted/50 p-2">
|
|
|
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
|
|
|
{imagePreview && <img src={imagePreview} alt="ตัวอย่างรูปภาพที่จะแนบ" className="size-28 rounded-md bg-muted object-contain" />}
|
|
|
|
|