"use client"; import { CheckCheckIcon, EyeIcon, MessageCircleMoreIcon } from "lucide-react"; import Image from "next/image"; import { useCallback, useEffect, useLayoutEffect, useRef, } from "react"; import { ImMessageHTML } from "@/components/im-message-html"; import { useImageLightbox } from "@/components/image-lightbox"; import { ProjectDialog } from "@/components/project-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { type AdminConversation, type AdminConversationDetail, type AdminMessage, } from "@/lib/api/admin"; import { parseMessageAssetPayload, renderIMMessageHTML, } from "@/lib/im-message"; import { formatDateTime } from "@/lib/utils"; import { useI18n } from "@/i18n/provider"; type ConversationDetailDialogProps = { open: boolean; loading: boolean; saving: boolean; item: AdminConversation | null; detail: AdminConversationDetail | null; messages?: AdminMessage[] | null; /** Whether older messages are available through cursor pagination. */ messagesHasMore?: boolean; loadingMoreMessages?: boolean; onLoadMoreMessages?: () => void | Promise; onOpenChange: (open: boolean) => void; onOpenAssign: () => void; onDispatch: () => Promise; onOpenTransfer: () => void; onRead: () => Promise; onOpenClose: () => void; }; function getStatusMeta( status: number, t: (key: string, values?: Record) => string ) { switch (status) { case 1: return { label: t("conversation.filterAiServing"), variant: "secondary" as const }; case 2: return { label: t("conversation.filterPending"), variant: "outline" as const }; case 3: return { label: t("conversation.filterActive"), variant: "secondary" as const }; case 4: return { label: t("conversation.filterClosed"), variant: "outline" as const }; default: return { label: t("conversationMonitor.unknown"), variant: "outline" as const }; } } function getServiceModeLabel( mode: number, t: (key: string, values?: Record) => string ) { switch (mode) { case 1: return t("conversationMonitor.serviceAi"); case 2: return t("conversationMonitor.serviceHuman"); case 3: return t("conversationMonitor.serviceAiFirst"); default: return t("conversationMonitor.serviceUndefined"); } } function getSenderLabel( message: AdminMessage, t: (key: string, values?: Record) => string ) { switch (message.senderType) { case "agent": return message.senderName || t("conversationMonitor.senderAgent"); case "customer": return message.senderName || t("conversationMonitor.senderCustomer"); case "ai": return "AI"; case "system": return t("conversationMonitor.senderSystem"); default: return message.senderType; } } function getMessageContent(message: AdminMessage) { return message.content || message.payload || "-"; } function getImageMessageUrl(message: AdminMessage) { return parseMessageAssetPayload(message.payload)?.url || ""; } function getParticipantIdentity( participant: NonNullable[number], ) { return participant.participantId || participant.externalParticipantId || "-"; } function getMessageLayout(message: AdminMessage) { if (message.senderType === "customer") { return { rowClassName: "justify-start", bubbleClassName: "border-border/70 bg-muted/60 text-foreground shadow-sm", htmlClassName: "[&_a]:text-foreground [&_a]:underline [&_img]:rounded-md", recalledBubbleClassName: "border-dashed border-border/70 bg-muted/40 text-muted-foreground", recalledHtmlClassName: "text-muted-foreground [&_p]:text-muted-foreground", metaClassName: "text-left", }; } if (message.senderType === "system") { return { rowClassName: "justify-center", bubbleClassName: "border-dashed border-border bg-muted/60 text-muted-foreground", htmlClassName: "[&_a]:text-foreground [&_a]:underline [&_img]:rounded-md", recalledBubbleClassName: "border-dashed border-border/70 bg-muted/40 text-muted-foreground", recalledHtmlClassName: "text-muted-foreground [&_p]:text-muted-foreground", metaClassName: "text-center", }; } if (message.senderType === "ai") { return { rowClassName: "justify-end", bubbleClassName: "border-primary/15 bg-primary/5 text-foreground shadow-sm", htmlClassName: "[&_a]:text-foreground [&_a]:underline [&_img]:rounded-md", recalledBubbleClassName: "border-dashed border-emerald-200 bg-emerald-50 text-emerald-800", recalledHtmlClassName: "text-emerald-800 [&_p]:text-emerald-800", metaClassName: "text-right", }; } return { rowClassName: "justify-end", bubbleClassName: "border-transparent bg-emerald-600 text-white shadow-sm", htmlClassName: "[&_p]:text-white [&_a]:text-white [&_a]:underline [&_img]:rounded-md", recalledBubbleClassName: "border-dashed border-emerald-200 bg-emerald-50 text-emerald-800", recalledHtmlClassName: "text-emerald-800 [&_p]:text-emerald-800", metaClassName: "text-right", }; } export function ConversationDetailDialog({ open, loading, saving, item, detail, messages: rawMessages, messagesHasMore = false, loadingMoreMessages = false, onLoadMoreMessages, onOpenChange, onOpenAssign, onDispatch, onOpenTransfer, onRead, onOpenClose, }: ConversationDetailDialogProps) { const t = useI18n(); const messages = Array.isArray(rawMessages) ? rawMessages : []; const currentConversation = detail ?? item; const isClosedConversation = currentConversation?.status === 4; const isPendingConversation = currentConversation?.status === 2; const statusMeta = currentConversation ? getStatusMeta(currentConversation.status, t) : null; const messageBottomRef = useRef(null); const messagesScrollRootRef = useRef(null); const loadMoreSentinelRef = useRef(null); const pendingScrollAnchorRef = useRef<{ scrollHeight: number; scrollTop: number; } | null>(null); const prevLoadingMoreRef = useRef(false); const { open: openImageLightbox, close: closeImageLightbox } = useImageLightbox(); const getMessagesViewport = useCallback((): HTMLElement | null => { return ( messagesScrollRootRef.current?.querySelector( '[data-slot="scroll-area-viewport"]', ) ?? null ); }, []); useEffect(() => { if (!open) { closeImageLightbox(); return; } if (loading) { return; } const bottom = messageBottomRef.current; if (!bottom) { return; } bottom.scrollIntoView({ block: "end", behavior: "smooth" }); }, [open, loading, closeImageLightbox]); useLayoutEffect(() => { const wasLoading = prevLoadingMoreRef.current; prevLoadingMoreRef.current = loadingMoreMessages; if (wasLoading && !loadingMoreMessages && pendingScrollAnchorRef.current) { const vp = getMessagesViewport(); const anchor = pendingScrollAnchorRef.current; pendingScrollAnchorRef.current = null; if (vp && anchor) { const delta = vp.scrollHeight - anchor.scrollHeight; vp.scrollTop = anchor.scrollTop + delta; } } }, [loadingMoreMessages, messages, getMessagesViewport]); useEffect(() => { if (!open || loading || !messagesHasMore || !onLoadMoreMessages) { return; } const root = getMessagesViewport(); const sentinel = loadMoreSentinelRef.current; if (!root || !sentinel) { return; } const observer = new IntersectionObserver( (entries) => { const hit = entries.some((e) => e.isIntersecting); if (!hit || loadingMoreMessages) { return; } const vp = getMessagesViewport(); if (vp) { pendingScrollAnchorRef.current = { scrollHeight: vp.scrollHeight, scrollTop: vp.scrollTop, }; } void onLoadMoreMessages(); }, { root, rootMargin: "120px 0px 0px 0px", threshold: 0 }, ); observer.observe(sentinel); return () => observer.disconnect(); }, [ open, loading, messagesHasMore, loadingMoreMessages, messages.length, onLoadMoreMessages, getMessagesViewport, ]); return ( {currentConversation?.customerName || t("conversationMonitor.detailFallbackTitle")}
{statusMeta ? ( {statusMeta.label} ) : null}
} size="xl" // allowFullscreen defaultFullscreen bodyScrollable={false} bodyClassName="flex min-h-0 flex-1 flex-col overflow-hidden p-0" contentClassName="h-[calc(100vh-40px)] max-h-[calc(100vh-40px)]" footer={
{currentConversation ? t("conversationMonitor.lastActivePrefix", { time: formatDateTime(currentConversation.lastMessageAt), }) : t("conversationMonitor.noConversationInfo")}
{!isClosedConversation ? ( ) : null}
} > {loading ? (
{t("conversationMonitor.loadingDetail")}
) : currentConversation ? (
{messagesHasMore ? (
{loadingMoreMessages ? t("conversationMonitor.loadingOlder") : t("conversationMonitor.loadOlderHint")}
) : null} {messages.length ? ( messages.map((message) => { const layout = getMessageLayout(message); const isRecalled = Boolean(message.recalledAt) || message.sendStatus === 6; const isHtmlMessage = !isRecalled && (message.messageType === "html" || message.messageType === "attachment"); const isImageMessage = !isRecalled && message.messageType === "image"; return (
{getSenderLabel(message, t)} · {formatDateTime(message.sentAt)} {isRecalled ? ( <> · {t("conversationMonitor.messageRecalled")} ) : null}
{isRecalled ? (
{t("conversationMonitor.messageRecalledBody")}
) : isHtmlMessage ? ( ) : isImageMessage ? ( ) : (
{getMessageContent(message)}
)}
{t("conversationMonitor.readStatus", { agent: message.agentRead ? t("conversationMonitor.read") : t("conversationMonitor.unread"), customer: message.customerRead ? t("conversationMonitor.read") : t("conversationMonitor.unread"), })}
); }) ) : (
{t("conversationMonitor.emptyMessages")}
)}
) : (
{t("conversationMonitor.emptyDetail")}
)}
); } type InfoItemProps = { label: string; value: string; fullWidth?: boolean; }; function InfoItem({ label, value, fullWidth = false }: InfoItemProps) { return (

{label}

{value || "-"}

); } type MessageImageProps = { src: string; alt: string; onPreview: (src: string, alt?: string) => void; }; function MessageImage({ src, alt, onPreview }: MessageImageProps) { const t = useI18n(); if (!src) { return (
{alt || t("conversationMonitor.imageFallback")}
); } return ( ); }