import { useCallback, useEffect, useRef, useState } from "react" import { invoke } from "@tauri-apps/api/core" import { listen } from "@tauri-apps/api/event" import { Store } from "@tauri-apps/plugin-store" import { appLocalDataDir, join } from "@tauri-apps/api/path" import { open } from "@tauri-apps/plugin-dialog" import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2 } from "lucide-react" import type { ChatMessage, ChatMessagesResponse, SendMessageResponse } from "./types" const STORE_FILENAME = "machine-agent.json" const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak // Tipos de arquivo permitidos const ALLOWED_EXTENSIONS = [ "jpg", "jpeg", "png", "gif", "webp", "pdf", "txt", "doc", "docx", "xls", "xlsx", ] interface UploadedAttachment { 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 } interface ChatWidgetProps { ticketId: string } export function ChatWidget({ ticketId }: ChatWidgetProps) { const [messages, setMessages] = useState([]) const [inputValue, setInputValue] = useState("") const [isLoading, setIsLoading] = useState(true) const [isSending, setIsSending] = useState(false) const [isUploading, setIsUploading] = useState(false) const [error, setError] = useState(null) const [ticketInfo, setTicketInfo] = useState<{ ref: number; subject: string; agentName: string } | null>(null) const [hasSession, setHasSession] = useState(false) const [pendingAttachments, setPendingAttachments] = useState([]) const [isMinimized, setIsMinimized] = useState(false) const [unreadCount, setUnreadCount] = useState(0) const messagesEndRef = useRef(null) const lastFetchRef = useRef(0) const pollIntervalRef = useRef | null>(null) const hadSessionRef = useRef(false) // Scroll para o final quando novas mensagens chegam const scrollToBottom = useCallback(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) }, []) useEffect(() => { scrollToBottom() }, [messages, scrollToBottom]) // Auto-minimizar quando a sessão termina (hasSession muda de true para false) useEffect(() => { if (hadSessionRef.current && !hasSession) { setIsMinimized(true) // Redimensionar janela para modo minimizado invoke("set_chat_minimized", { ticketId, minimized: true }).catch(err => { console.error("Erro ao minimizar janela automaticamente:", err) }) } hadSessionRef.current = hasSession }, [hasSession, ticketId]) // Carregar configuracao do store const loadConfig = useCallback(async () => { try { const appData = await appLocalDataDir() const storePath = await join(appData, STORE_FILENAME) const store = await Store.load(storePath) const token = await store.get("token") const config = await store.get<{ apiBaseUrl: string }>("config") if (!token || !config?.apiBaseUrl) { setError("Máquina não registrada") setIsLoading(false) return null } return { token, baseUrl: config.apiBaseUrl } } catch (err) { setError("Erro ao carregar configuracao") setIsLoading(false) return null } }, []) // Buscar mensagens const fetchMessages = useCallback(async (baseUrl: string, token: string, since?: number) => { try { const response = await invoke("fetch_chat_messages", { baseUrl, token, ticketId, since: since ?? null, }) setHasSession(response.hasSession) if (response.messages.length > 0) { if (since) { // Adicionar apenas novas mensagens (com limite para evitar memory leak) setMessages(prev => { const existingIds = new Set(prev.map(m => m.id)) const newMsgs = response.messages.filter(m => !existingIds.has(m.id)) const combined = [...prev, ...newMsgs] // Manter apenas as últimas MAX_MESSAGES_IN_MEMORY mensagens return combined.slice(-MAX_MESSAGES_IN_MEMORY) }) } else { // Primeira carga (já limitada) setMessages(response.messages.slice(-MAX_MESSAGES_IN_MEMORY)) } lastFetchRef.current = Math.max(...response.messages.map(m => m.createdAt)) } return response } catch (err) { console.error("Erro ao buscar mensagens:", err) return null } }, [ticketId]) // Buscar info da sessao const fetchSessionInfo = useCallback(async (baseUrl: string, token: string) => { try { const sessions = await invoke>("fetch_chat_sessions", { baseUrl, token }) const session = sessions.find(s => s.ticketId === ticketId) if (session) { setTicketInfo({ ref: session.ticketRef, subject: session.ticketSubject, agentName: session.agentName, }) } } catch (err) { console.error("Erro ao buscar sessao:", err) } }, [ticketId]) // Inicializacao useEffect(() => { let mounted = true const init = async () => { const config = await loadConfig() if (!config || !mounted) return const { baseUrl, token } = config // Buscar sessao e mensagens iniciais await Promise.all([ fetchSessionInfo(baseUrl, token), fetchMessages(baseUrl, token), ]) if (!mounted) return setIsLoading(false) // Iniciar polling (2 segundos para maior responsividade) pollIntervalRef.current = setInterval(async () => { await fetchMessages(baseUrl, token, lastFetchRef.current) }, 2000) } init() // Listener para eventos de nova mensagem do Tauri const unlistenNewMessage = listen<{ ticketId: string; message: ChatMessage }>( "raven://chat/new-message", (event) => { if (event.payload.ticketId === ticketId) { setMessages(prev => { if (prev.some(m => m.id === event.payload.message.id)) { return prev } const combined = [...prev, event.payload.message] // Manter apenas as últimas MAX_MESSAGES_IN_MEMORY mensagens return combined.slice(-MAX_MESSAGES_IN_MEMORY) }) } } ) // Listener para atualização de mensagens não lidas const unlistenUnread = listen<{ totalUnread: number; sessions: Array<{ ticketId: string; unreadCount: number }> }>( "raven://chat/unread-update", (event) => { // Encontrar o unread count para este ticket const session = event.payload.sessions?.find(s => s.ticketId === ticketId) if (session) { setUnreadCount(session.unreadCount ?? 0) } } ) return () => { mounted = false if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current) } unlistenNewMessage.then(unlisten => unlisten()) unlistenUnread.then(unlisten => unlisten()) } }, [ticketId, loadConfig, fetchMessages, fetchSessionInfo]) // Selecionar arquivo para anexar const handleAttach = async () => { if (isUploading || isSending) return try { const selected = await open({ multiple: false, filters: [{ name: "Arquivos permitidos", extensions: ALLOWED_EXTENSIONS, }], }) if (!selected) return // O retorno pode ser string (path único) ou objeto com path const filePath = typeof selected === "string" ? selected : (selected as { path: string }).path setIsUploading(true) const config = await loadConfig() if (!config) { setIsUploading(false) return } const attachment = await invoke("upload_chat_file", { baseUrl: config.baseUrl, token: config.token, 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 { const config = await loadConfig() if (!config) { setIsSending(false) setInputValue(messageText) setPendingAttachments(attachmentsToSend) return } const response = await invoke("send_chat_message", { baseUrl: config.baseUrl, token: config.token, ticketId, body: messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : ""), attachments: attachmentsToSend.length > 0 ? attachmentsToSend : null, }) // Adicionar mensagem localmente setMessages(prev => [...prev, { id: response.messageId, body: messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : ""), authorName: "Você", isFromMachine: true, createdAt: response.createdAt, attachments: attachmentsToSend.map(a => ({ storageId: a.storageId, name: a.name, size: a.size, type: a.type, })), }]) lastFetchRef.current = response.createdAt } catch (err) { console.error("Erro ao enviar mensagem:", err) // Restaurar input e anexos em caso de erro setInputValue(messageText) setPendingAttachments(attachmentsToSend) } finally { setIsSending(false) } } const handleMinimize = async () => { setIsMinimized(true) try { await invoke("set_chat_minimized", { ticketId, minimized: true }) } catch (err) { console.error("Erro ao minimizar janela:", err) } } const handleExpand = async () => { setIsMinimized(false) try { await invoke("set_chat_minimized", { ticketId, minimized: false }) } catch (err) { console.error("Erro ao expandir janela:", err) } } const handleClose = () => { invoke("close_chat_window", { ticketId }) } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() handleSend() } } if (isLoading) { return (

Carregando chat...

) } if (error) { return (

{error}

) } // Quando não há sessão, mostrar versão minimizada com indicador de offline if (!hasSession) { return (
{ticketInfo ? `Chat #${ticketInfo.ref}` : "Chat"} Offline
) } // Versão minimizada (chip compacto igual web) if (isMinimized) { return (
) } return (
{/* Header - arrastavel */}

Chat

Online
{ticketInfo && (

#{ticketInfo.ref} - {ticketInfo.agentName ?? "Suporte"}

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

Nenhuma mensagem ainda

O agente iniciará a conversa em breve

) : (
{messages.map((msg) => { // No desktop: isFromMachine=true significa mensagem do cliente (maquina) // Layout igual à web: cliente à esquerda, agente à direita const isAgent = !msg.isFromMachine return (
{/* Avatar */}
{isAgent ? : }
{/* Bubble */}
{!isAgent && (

{msg.authorName}

)}

{msg.body}

{/* Anexos */} {msg.attachments && msg.attachments.length > 0 && (
{msg.attachments.map((att) => (
{getFileIcon(att.name)} {att.name} {att.size && ( ({Math.round(att.size / 1024)}KB) )}
))}
)}

{formatTime(msg.createdAt)}

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