/** * ChatWidget - Componente de chat em tempo real usando Convex subscriptions * * Arquitetura: * - Usa useQuery do Convex React para subscriptions reativas (tempo real verdadeiro) * - Usa useMutation do Convex React para enviar mensagens * - Mantém Tauri apenas para: upload de arquivos, gerenciamento de janela * - Sem polling - todas as atualizacoes sao push-based via WebSocket */ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { open as openDialog } from "@tauri-apps/plugin-dialog" import { openUrl as openExternal } from "@tauri-apps/plugin-opener" import { invoke } from "@tauri-apps/api/core" import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check } from "lucide-react" import type { Id } from "@convex/_generated/dataModel" import { useMachineMessages, usePostMachineMessage, useMarkMachineMessagesRead, type MachineMessage } from "./useConvexMachineQueries" import { useConvexMachine } from "./ConvexMachineProvider" const MAX_MESSAGES_IN_MEMORY = 200 const MARK_READ_BATCH_SIZE = 50 const SCROLL_BOTTOM_THRESHOLD_PX = 120 const ALLOWED_EXTENSIONS = [ "jpg", "jpeg", "png", "gif", "webp", "pdf", "txt", "doc", "docx", "xls", "xlsx", ] interface UploadedAttachment { storageId: string name: string size?: number type?: string } interface ChatAttachment { storageId: string name: string size?: number type?: string } function getFileIcon(fileName: string) { const ext = fileName.toLowerCase().split(".").pop() ?? "" if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) { return } if (["pdf", "doc", "docx", "txt"].includes(ext)) { return } return } function isImageAttachment(attachment: ChatAttachment) { if (attachment.type?.startsWith("image/")) return true const ext = attachment.name.toLowerCase().split(".").pop() ?? "" return ["jpg", "jpeg", "png", "gif", "webp"].includes(ext) } function formatAttachmentSize(size?: number) { if (!size) return null if (size < 1024) return `${size}B` const kb = size / 1024 if (kb < 1024) return `${Math.round(kb)}KB` return `${(kb / 1024).toFixed(1)}MB` } function getUnreadAgentMessageIds(messages: MachineMessage[], unreadCount: number): string[] { if (unreadCount <= 0 || messages.length === 0) return [] const ids: string[] = [] for (let i = messages.length - 1; i >= 0 && ids.length < unreadCount; i--) { const msg = messages[i] if (!msg.isFromMachine) { ids.push(msg.id) } } return ids.reverse() } function chunkArray(items: T[], size: number): T[][] { if (size <= 0) return [items] const result: T[][] = [] for (let i = 0; i < items.length; i += size) { result.push(items.slice(i, i + size)) } return result } function MessageAttachment({ attachment, isAgent, loadUrl, }: { attachment: ChatAttachment isAgent: boolean loadUrl: (storageId: string) => Promise }) { const [url, setUrl] = useState(null) const [loading, setLoading] = useState(true) const [downloading, setDownloading] = useState(false) const [downloaded, setDownloaded] = useState(false) useEffect(() => { let cancelled = false setLoading(true) loadUrl(attachment.storageId) .then((resolved) => { if (!cancelled) setUrl(resolved) }) .catch((err) => { console.error("Falha ao carregar URL do anexo:", err) }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [attachment.storageId, loadUrl]) const handleView = async () => { if (!url) return try { await openExternal(url) } catch (err) { console.error("Falha ao abrir anexo:", err) } } const handleDownload = async () => { if (!url || downloading) return setDownloading(true) try { const response = await fetch(url) const blob = await response.blob() const downloadUrl = URL.createObjectURL(blob) const a = document.createElement("a") a.href = downloadUrl a.download = attachment.name document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(downloadUrl) setDownloaded(true) setTimeout(() => setDownloaded(false), 2000) } catch (err) { console.error("Falha ao baixar anexo:", err) await handleView() } finally { setDownloading(false) } } const sizeLabel = formatAttachmentSize(attachment.size) const isImage = isImageAttachment(attachment) if (loading) { return (
Carregando anexo...
) } if (isImage && url) { return (
{/* eslint-disable-next-line @next/next/no-img-element -- Tauri desktop app, not Next.js */} {attachment.name}
) } return (
{getFileIcon(attachment.name)} {sizeLabel && ({sizeLabel})}
) } interface ChatWidgetProps { ticketId: string ticketRef?: number } export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { const [inputValue, setInputValue] = useState("") const [isSending, setIsSending] = useState(false) const [isUploading, setIsUploading] = useState(false) const [pendingAttachments, setPendingAttachments] = useState([]) // Inicializa baseado na altura real da janela (< 100px = minimizado) const [isMinimized, setIsMinimized] = useState(() => window.innerHeight < 100) // Convex hooks const { apiBaseUrl, machineToken } = useConvexMachine() const { messages: convexMessages, hasSession, unreadCount, isLoading } = useMachineMessages( ticketId as Id<"tickets">, { limit: MAX_MESSAGES_IN_MEMORY } ) const postMessage = usePostMachineMessage() const markMessagesRead = useMarkMachineMessagesRead() // Limitar mensagens em memoria const messages = useMemo(() => convexMessages.slice(-MAX_MESSAGES_IN_MEMORY), [convexMessages]) const messagesEndRef = useRef(null) const messagesContainerRef = useRef(null) const messageElementsRef = useRef>(new Map()) const prevHasSessionRef = useRef(false) const [isAtBottom, setIsAtBottom] = useState(true) const isAtBottomRef = useRef(true) const pendingScrollActionRef = useRef< | { type: "bottom"; behavior: ScrollBehavior; markRead: boolean } | { type: "message"; messageId: string; behavior: ScrollBehavior; markRead: boolean } | null >(null) const unreadAgentMessageIds = useMemo(() => getUnreadAgentMessageIds(messages, unreadCount), [messages, unreadCount]) const firstUnreadAgentMessageId = unreadAgentMessageIds[0] ?? null const updateIsAtBottom = useCallback(() => { const el = messagesContainerRef.current if (!el) return const distance = el.scrollHeight - el.scrollTop - el.clientHeight const atBottom = distance <= SCROLL_BOTTOM_THRESHOLD_PX if (isAtBottomRef.current !== atBottom) { isAtBottomRef.current = atBottom setIsAtBottom(atBottom) } }, []) const scrollToBottom = useCallback((behavior: ScrollBehavior) => { messagesEndRef.current?.scrollIntoView({ behavior }) requestAnimationFrame(() => updateIsAtBottom()) }, [updateIsAtBottom]) const scrollToMessage = useCallback((messageId: string, behavior: ScrollBehavior) => { const el = messageElementsRef.current.get(messageId) if (!el) return false el.scrollIntoView({ behavior, block: "center" }) requestAnimationFrame(() => updateIsAtBottom()) return true }, [updateIsAtBottom]) // Fechar janela quando sessao termina useEffect(() => { const prevHasSession = prevHasSessionRef.current if (prevHasSession && !hasSession) { invoke("close_chat_window", { ticket_id: ticketId }).catch((err) => { console.error("Erro ao fechar janela ao encerrar sessao:", err) }) } prevHasSessionRef.current = hasSession }, [hasSession, ticketId]) // Ref para acessar isMinimized dentro de callbacks const isMinimizedRef = useRef(isMinimized) useEffect(() => { isMinimizedRef.current = isMinimized }, [isMinimized]) // Cache de URLs de anexos const attachmentUrlCacheRef = useRef>(new Map()) const loadAttachmentUrl = useCallback(async (storageId: string) => { const cached = attachmentUrlCacheRef.current.get(storageId) if (cached) return cached if (!apiBaseUrl || !machineToken) { throw new Error("Configuracao nao disponivel") } const response = await fetch(`${apiBaseUrl}/api/machines/chat/attachments/url`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ machineToken, ticketId, storageId, }), }) if (!response.ok) { const text = await response.text().catch(() => "") throw new Error(text || `Falha ao obter URL do anexo (${response.status})`) } const data = (await response.json()) as { url?: string } if (!data.url) { throw new Error("Resposta invalida ao obter URL do anexo") } attachmentUrlCacheRef.current.set(storageId, data.url) return data.url }, [apiBaseUrl, machineToken, ticketId]) const markUnreadMessagesRead = useCallback(async () => { if (unreadCount <= 0) return const ids = getUnreadAgentMessageIds(messages, unreadCount) if (ids.length === 0) return const chunks = chunkArray(ids, MARK_READ_BATCH_SIZE) for (const chunk of chunks) { await markMessagesRead({ ticketId: ticketId as Id<"tickets">, messageIds: chunk as Id<"ticketChatMessages">[], }) } }, [messages, ticketId, unreadCount, markMessagesRead]) // Auto-scroll quando novas mensagens chegam (se ja estava no bottom) const prevMessagesLengthRef = useRef(messages.length) useEffect(() => { if (messages.length > prevMessagesLengthRef.current && isAtBottomRef.current && !isMinimizedRef.current) { pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: true } } prevMessagesLengthRef.current = messages.length }, [messages.length]) // Executar scroll pendente useEffect(() => { if (isMinimized) return const action = pendingScrollActionRef.current if (!action) return if (action.type === "bottom") { if (!messagesEndRef.current) return pendingScrollActionRef.current = null scrollToBottom(action.behavior) if (action.markRead) { markUnreadMessagesRead().catch((err) => console.error("Falha ao marcar mensagens como lidas:", err)) } return } const ok = scrollToMessage(action.messageId, action.behavior) if (!ok) { if (!messagesEndRef.current) return pendingScrollActionRef.current = null scrollToBottom(action.behavior) if (action.markRead) { markUnreadMessagesRead().catch((err) => console.error("Falha ao marcar mensagens como lidas:", err)) } return } pendingScrollActionRef.current = null if (action.markRead) { markUnreadMessagesRead().catch((err) => console.error("Falha ao marcar mensagens como lidas:", err)) } }, [isMinimized, messages, markUnreadMessagesRead, scrollToBottom, scrollToMessage]) // Sincronizar estado minimizado com tamanho da janela useEffect(() => { const mountTime = Date.now() const STABILIZATION_DELAY = 500 const handler = () => { if (Date.now() - mountTime < STABILIZATION_DELAY) { return } const h = window.innerHeight setIsMinimized(h < 100) } window.addEventListener("resize", handler) return () => window.removeEventListener("resize", handler) }, []) // Selecionar arquivo para anexar const handleAttach = async () => { if (isUploading || isSending) return try { const selected = await openDialog({ multiple: false, filters: [{ name: "Arquivos permitidos", extensions: ALLOWED_EXTENSIONS, }], }) if (!selected) return const filePath = typeof selected === "string" ? selected : (selected as { path: string }).path setIsUploading(true) if (!apiBaseUrl || !machineToken) { throw new Error("Configuracao nao disponivel") } const attachment = await invoke("upload_chat_file", { baseUrl: apiBaseUrl, token: machineToken, filePath, }) setPendingAttachments(prev => [...prev, attachment]) } catch (err) { console.error("Erro ao anexar arquivo:", err) alert(typeof err === "string" ? err : "Erro ao anexar arquivo") } finally { setIsUploading(false) } } // Remover anexo pendente const handleRemoveAttachment = (storageId: string) => { setPendingAttachments(prev => prev.filter(a => a.storageId !== storageId)) } // Enviar mensagem const handleSend = async () => { if ((!inputValue.trim() && pendingAttachments.length === 0) || isSending) return const messageText = inputValue.trim() const attachmentsToSend = [...pendingAttachments] setInputValue("") setPendingAttachments([]) setIsSending(true) try { await postMessage({ ticketId: ticketId as Id<"tickets">, body: messageText, attachments: attachmentsToSend.length > 0 ? attachmentsToSend.map(a => ({ storageId: a.storageId as Id<"_storage">, name: a.name, size: a.size, type: a.type, })) : undefined, }) pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: false } } catch (err) { console.error("Erro ao enviar mensagem:", err) setInputValue(messageText) setPendingAttachments(attachmentsToSend) } finally { setIsSending(false) } } const handleMinimize = async () => { setIsMinimized(true) try { await invoke("set_chat_minimized", { ticket_id: ticketId, minimized: true }) } catch (err) { console.error("Erro ao minimizar janela:", err) } } const handleExpand = async () => { if (firstUnreadAgentMessageId) { pendingScrollActionRef.current = { type: "message", messageId: firstUnreadAgentMessageId, behavior: "auto", markRead: unreadCount > 0 } } else { pendingScrollActionRef.current = { type: "bottom", behavior: "auto", markRead: false } } setIsMinimized(false) try { await invoke("set_chat_minimized", { ticket_id: ticketId, minimized: false }) } catch (err) { console.error("Erro ao expandir janela:", err) } } const handleClose = () => { invoke("close_chat_window", { ticket_id: ticketId }).catch((err) => { console.error("Erro ao fechar janela de chat:", err) }) } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() handleSend() } } // Loading if (isLoading) { return (
Carregando...
) } // Sem sessao ativa if (!hasSession) { return (
{ticketRef ? `Ticket #${ticketRef}` : "Chat"} Offline
) } // Minimizado if (isMinimized) { return (
) } // Expandido return (
{/* Header */}

Chat

Online

Ticket #{ticketRef} - Suporte

{/* Mensagens */}
{messages.length === 0 ? (

Nenhuma mensagem ainda

O agente iniciara a conversa em breve

) : (
{messages.map((msg) => { const isAgent = !msg.isFromMachine const bodyText = msg.body.trim() const shouldShowBody = bodyText.length > 0 && !(bodyText === "[Anexo]" && (msg.attachments?.length ?? 0) > 0) return (
{firstUnreadAgentMessageId === msg.id && unreadCount > 0 && !isAtBottom && (
Novas mensagens
)}
{ if (el) { messageElementsRef.current.set(msg.id, el) } else { messageElementsRef.current.delete(msg.id) } }} className={`flex gap-2 ${isAgent ? "flex-row-reverse" : "flex-row"}`} > {/* Avatar */}
{isAgent ? : }
{/* Bubble */}
{!isAgent && (

{msg.authorName}

)} {shouldShowBody &&

{msg.body}

} {/* Anexos */} {msg.attachments && msg.attachments.length > 0 && (
{msg.attachments.map((att) => ( ))}
)}

{formatTime(msg.createdAt)}

) })}
)}
{/* Input */}
{unreadCount > 0 && !isAtBottom && (
)} {/* Anexos pendentes */} {pendingAttachments.length > 0 && (
{pendingAttachments.map((att) => (
{getFileIcon(att.name)} {att.name}
))}
)}