"use client"; 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 { 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"; type ChatMessage = { id: string; authorId: string; authorName: string; text: 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" }; type OptimisticReaction = { active: boolean; status: "sending" | "sent" }; const emojiChoices = ["👍", "âĪïļ", "😂", "ðŸ˜Ū", "ðŸ˜Ē", "🎉"]; const reactionKey = (messageId: string, emoji: string) => `${messageId}:${emoji}`; 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 }> {state === "loading" && } {state === "error" && āđ‚āļŦāļĨāļ”āļĢāļđāļ›āļ āļēāļžāđ„āļĄāđˆāļŠāļģāđ€āļĢāđ‡āļˆ} {/* eslint-disable-next-line @next/next/no-img-element */} āļĢāļđāļ›āļ āļēāļžāļ—āļĩāđˆāđāļ™āļšāđƒāļ™āļ‚āđ‰āļ­āļ„āļ§āļēāļĄ setState("loaded")} onError={() => setState("error")} className={cn("max-h-72 max-w-full object-contain", state === "loaded" ? "block" : "hidden")} /> āļĢāļđāļ›āļ āļēāļžāļ—āļĩāđˆāđāļ™āļšāđƒāļ™āļ‚āđ‰āļ­āļ„āļ§āļēāļĄ {/* eslint-disable-next-line @next/next/no-img-element */} āļĢāļđāļ›āļ āļēāļžāļ—āļĩāđˆāđāļ™āļšāđƒāļ™āļ‚āđ‰āļ­āļ„āļ§āļēāļĄāļ‚āļ™āļēāļ”āđƒāļŦāļāđˆ ; } export function CommissionTicketChat({ ticketId, userId, status, messages, admin }: { ticketId: 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 [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)); 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 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:")) { 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(); }; }, [ticketId, userId, router]); useEffect(() => { const viewport = scrollRef.current; if (viewport && stayAtBottom.current) viewport.scrollTop = viewport.scrollHeight; }, [messages, optimisticMessages]); function sendTyping(active: boolean) { if (status !== "open") return; 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, status: "sending", }]); stayAtBottom.current = true; try { const data = new FormData(form); data.set("text", text.trim()); if (image) data.set("image", image); else data.delete("image"); form.reset(); setText(""); setAttachment(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); 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)); setReactionFor(null); router.refresh(); } catch { setError("āļĨāļšāļ‚āđ‰āļ­āļ„āļ§āļēāļĄāđ„āļĄāđˆāļŠāļģāđ€āļĢāđ‡āļˆ āļāļĢāļļāļ“āļēāļĨāļ­āļ‡āļ­āļĩāļāļ„āļĢāļąāđ‰āļ‡"); } finally { setDeletingId(null); } } async function toggleStatus() { 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(); router.refresh(); } catch { setError("āđ€āļ›āļĨāļĩāđˆāļĒāļ™āļŠāļ–āļēāļ™āļ° Ticket āđ„āļĄāđˆāļŠāļģāđ€āļĢāđ‡āļˆ āļāļĢāļļāļ“āļēāļĨāļ­āļ‡āļ­āļĩāļāļ„āļĢāļąāđ‰āļ‡"); } } function handleComposerKeyDown(event: KeyboardEvent) { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); event.currentTarget.form?.requestSubmit(); } } return āđāļŠāļ•{admin ? "āļāļąāļšāļĨāļđāļāļ„āđ‰āļē" : "āļāļąāļšāļ—āļĩāļĄāļ‡āļēāļ™"} āļžāļđāļ”āļ„āļļāļĒāđāļĨāļ°āļŠāđˆāļ‡āļĢāļđāļ›āļ āļēāļžāđ€āļāļĩāđˆāļĒāļ§āļāļąāļšāļ„āļģāļ‚āļ­āļ™āļĩāđ‰ {status === "open" ? "āđ€āļ›āļīāļ”āļ­āļĒāļđāđˆ" : "āļ›āļīāļ”āđāļĨāđ‰āļ§"} {admin && } { 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 counts = emojiChoices.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 {!mine && {message.authorName.trim().charAt(0).toUpperCase() || "?"} } {mine ? "āļ„āļļāļ“" : message.authorName} {message.text &&

{message.text}

} {message.imageUrl && }
{counts.length > 0 &&
{counts.map(({ emoji, count, active, pending }) => )}
} {status === "open" && setReactionFor(open ? message.id : null)}> }>
{emojiChoices.map((emoji) => )}
} {mine && setDeleteFor(open ? message.id : null)}> }> āļĨāļšāļ‚āđ‰āļ­āļ„āļ§āļēāļĄāļ™āļĩāđ‰? āļ‚āđ‰āļ­āļ„āļ§āļēāļĄāđāļĨāļ°āļĢāļđāļ›āļ āļēāļžāļ—āļĩāđˆāđāļ™āļšāļˆāļ°āļ–āļđāļāļĨāļšāļ–āļēāļ§āļĢ āļĒāļāđ€āļĨāļīāļ void deleteMessage(message.id)}>āļĨāļšāļ‚āđ‰āļ­āļ„āļ§āļēāļĄ }
; })} {visibleOptimisticMessages.map((message) => āļ„āļļāļ“ {message.text &&

{message.text}

} {message.imageUrl &&
}
{message.status === "sending" ? "āļāļģāļĨāļąāļ‡āļŠāđˆāļ‡..." : "āļŠāđˆāļ‡āđāļĨāđ‰āļ§"}
)}
}
{typing && status === "open" &&

{typing.name} typing...

} {status === "open" ?
{image &&
{/* eslint-disable-next-line @next/next/no-img-element */} {imagePreview && āļ•āļąāļ§āļ­āļĒāđˆāļēāļ‡āļĢāļđāļ›āļ āļēāļžāļ—āļĩāđˆāļˆāļ°āđāļ™āļš}
} āļ‚āđ‰āļ­āļ„āļ§āļēāļĄ 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 āđ€āļžāļ·āđˆāļ­āļ‚āļķāđ‰āļ™āļšāļĢāļĢāļ—āļąāļ”āđƒāļŦāļĄāđˆ { const selected = event.target.files?.[0] ?? null; chooseAttachment(selected); if (selected && (selected.size > 6 * 1024 * 1024 || !["image/png", "image/jpeg", "image/webp"].includes(selected.type))) event.target.value = ""; }} />
:

Ticket āļ™āļĩāđ‰āļ›āļīāļ”āđāļĨāđ‰āļ§ āļŦāļēāļāļ•āđ‰āļ­āļ‡āļāļēāļĢāļžāļđāļ”āļ„āļļāļĒāļ•āđˆāļ­ āđƒāļŦāđ‰āļœāļđāđ‰āļ”āļđāđāļĨāđ€āļ›āļīāļ” Ticket āļ­āļĩāļāļ„āļĢāļąāđ‰āļ‡

} {error && {error}}
; }