diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index 47d5435..d2e52f3 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -197,6 +197,7 @@ pub async fn fetch_messages( "machineToken": token, "ticketId": ticket_id, "action": "list", + "limit": 200, }); if let Some(ts) = since { diff --git a/apps/desktop/src/chat/ChatWidget.tsx b/apps/desktop/src/chat/ChatWidget.tsx index 27a412d..af70455 100644 --- a/apps/desktop/src/chat/ChatWidget.tsx +++ b/apps/desktop/src/chat/ChatWidget.tsx @@ -1,13 +1,15 @@ -import { useCallback, useEffect, useRef, useState } from "react" +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 { listen } from "@tauri-apps/api/event" import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check } from "lucide-react" -import type { ChatAttachment, ChatMessage, ChatMessagesResponse, NewMessageEvent, SessionEndedEvent } from "./types" +import type { ChatAttachment, ChatMessage, ChatMessagesResponse, NewMessageEvent, SessionEndedEvent, UnreadUpdateEvent } from "./types" import { getMachineStoreConfig } from "./machineStore" const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak +const MARK_READ_BATCH_SIZE = 50 +const SCROLL_BOTTOM_THRESHOLD_PX = 120 // Tipos de arquivo permitidos const ALLOWED_EXTENSIONS = [ @@ -47,6 +49,27 @@ function formatAttachmentSize(size?: number) { return `${(kb / 1024).toFixed(1)}MB` } +function getUnreadAgentMessageIds(messages: ChatMessage[], 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, @@ -217,16 +240,44 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { const [unreadCount, setUnreadCount] = useState(0) const messagesEndRef = useRef(null) + const messagesContainerRef = useRef(null) + const messageElementsRef = useRef>(new Map()) const hadSessionRef = useRef(false) - // Scroll para o final quando novas mensagens chegam - const scrollToBottom = useCallback(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + 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) + } }, []) - useEffect(() => { - scrollToBottom() - }, [messages, scrollToBottom]) + 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]) // Auto-minimizar quando a sessão termina (hasSession muda de true para false) useEffect(() => { @@ -320,6 +371,26 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { } }, [ensureConfig, ticketId, ticketRef]) + const markUnreadMessagesRead = useCallback(async () => { + if (unreadCount <= 0) return + const ids = getUnreadAgentMessageIds(messages, unreadCount) + if (ids.length === 0) return + + const cfg = await ensureConfig() + const chunks = chunkArray(ids, MARK_READ_BATCH_SIZE) + + for (const chunk of chunks) { + await invoke("mark_chat_messages_read", { + baseUrl: cfg.apiBaseUrl, + token: cfg.token, + ticketId, + messageIds: chunk, + }) + } + + setUnreadCount(0) + }, [ensureConfig, messages, ticketId, unreadCount]) + // Carregar mensagens na montagem / troca de ticket useEffect(() => { setIsLoading(true) @@ -334,6 +405,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { listen("raven://chat/new-message", (event) => { const sessions = event.payload?.sessions ?? [] if (sessions.some((s) => s.ticketId === ticketId)) { + const shouldAutoScroll = !isMinimizedRef.current && isAtBottomRef.current + if (shouldAutoScroll) { + pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: true } + } loadMessages() } }) @@ -347,6 +422,59 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { } }, [ticketId, loadMessages]) + // Atualizar contador em tempo real (inclui decremento quando a máquina marca como lida) + useEffect(() => { + let unlisten: (() => void) | null = null + + listen("raven://chat/unread-update", (event) => { + const sessions = event.payload?.sessions ?? [] + const session = sessions.find((s) => s.ticketId === ticketId) + setUnreadCount(session?.unreadCount ?? 0) + }) + .then((u) => { + unlisten = u + }) + .catch((err) => console.error("Falha ao registrar listener unread-update:", err)) + + return () => { + unlisten?.() + } + }, [ticketId]) + + // Executar scroll pendente (após expandir ou após novas mensagens) + 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]) + // Recarregar quando a sessao for encerrada (para refletir offline/minimizar corretamente) useEffect(() => { let unlisten: (() => void) | null = null @@ -431,26 +559,6 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { return () => window.removeEventListener("resize", handler) }, []) - // Quando expandir, marcar mensagens como lidas (o backend zera unreadByMachine e a reatividade atualiza) - useEffect(() => { - if (isMinimized) return - if (unreadCount === 0) return - const unreadIds = messages.filter(m => !m.isFromMachine).map(m => m.id as string) - if (unreadIds.length > 0) { - ensureConfig() - .then((cfg) => - invoke("mark_chat_messages_read", { - baseUrl: cfg.apiBaseUrl, - token: cfg.token, - ticketId, - messageIds: unreadIds, - }) - ) - .catch((err) => console.error("mark read falhou", err)) - } - // Nao setamos unreadCount aqui - o backend vai zerar unreadByMachine e a subscription vai atualizar - }, [isMinimized, messages, ticketId, unreadCount, ensureConfig]) - // Selecionar arquivo para anexar const handleAttach = async () => { if (isUploading || isSending) return @@ -526,8 +634,9 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { name: a.name, size: a.size, type: a.type, - })), - }]) + })), + }]) + pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: false } } catch (err) { console.error("Erro ao enviar mensagem:", err) // Restaurar input e anexos em caso de erro @@ -548,14 +657,18 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { } 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", { ticketId, minimized: false }) } catch (err) { console.error("Erro ao expandir janela:", err) } - // Marca mensagens como lidas ao expandir (o useEffect ja cuida disso quando isMinimized muda) - // O backend zera unreadByMachine e a subscription atualiza automaticamente } const handleClose = () => { @@ -681,7 +794,11 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { {/* Mensagens */} -
+
{messages.length === 0 ? (

@@ -698,8 +815,23 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { // Layout igual à web: cliente à esquerda, agente à direita const isAgent = !msg.isFromMachine 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 */} @@ -747,6 +879,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {

+
) })}
@@ -756,6 +889,25 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { {/* Input */}
+ {unreadCount > 0 && !isAtBottom && ( +
+ +
+ )} {/* Anexos pendentes */} {pendingAttachments.length > 0 && (