fix(desktop): foco de scroll e contagem de não lidas no chat
This commit is contained in:
parent
b5b51b638b
commit
bf94dd9f7a
2 changed files with 187 additions and 34 deletions
|
|
@ -197,6 +197,7 @@ pub async fn fetch_messages(
|
||||||
"machineToken": token,
|
"machineToken": token,
|
||||||
"ticketId": ticket_id,
|
"ticketId": ticket_id,
|
||||||
"action": "list",
|
"action": "list",
|
||||||
|
"limit": 200,
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(ts) = since {
|
if let Some(ts) = since {
|
||||||
|
|
|
||||||
|
|
@ -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 { open as openDialog } from "@tauri-apps/plugin-dialog"
|
||||||
import { openUrl as openExternal } from "@tauri-apps/plugin-opener"
|
import { openUrl as openExternal } from "@tauri-apps/plugin-opener"
|
||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import { listen } from "@tauri-apps/api/event"
|
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 { 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"
|
import { getMachineStoreConfig } from "./machineStore"
|
||||||
|
|
||||||
const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak
|
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
|
// Tipos de arquivo permitidos
|
||||||
const ALLOWED_EXTENSIONS = [
|
const ALLOWED_EXTENSIONS = [
|
||||||
|
|
@ -47,6 +49,27 @@ function formatAttachmentSize(size?: number) {
|
||||||
return `${(kb / 1024).toFixed(1)}MB`
|
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<T>(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({
|
function MessageAttachment({
|
||||||
attachment,
|
attachment,
|
||||||
isAgent,
|
isAgent,
|
||||||
|
|
@ -217,16 +240,44 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
const [unreadCount, setUnreadCount] = useState(0)
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const messageElementsRef = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||||
const hadSessionRef = useRef<boolean>(false)
|
const hadSessionRef = useRef<boolean>(false)
|
||||||
|
|
||||||
// Scroll para o final quando novas mensagens chegam
|
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||||
const scrollToBottom = useCallback(() => {
|
const isAtBottomRef = useRef(true)
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
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(() => {
|
const scrollToBottom = useCallback((behavior: ScrollBehavior) => {
|
||||||
scrollToBottom()
|
messagesEndRef.current?.scrollIntoView({ behavior })
|
||||||
}, [messages, scrollToBottom])
|
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)
|
// Auto-minimizar quando a sessão termina (hasSession muda de true para false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -320,6 +371,26 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
}
|
}
|
||||||
}, [ensureConfig, ticketId, ticketRef])
|
}, [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
|
// Carregar mensagens na montagem / troca de ticket
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
@ -334,6 +405,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
listen<NewMessageEvent>("raven://chat/new-message", (event) => {
|
listen<NewMessageEvent>("raven://chat/new-message", (event) => {
|
||||||
const sessions = event.payload?.sessions ?? []
|
const sessions = event.payload?.sessions ?? []
|
||||||
if (sessions.some((s) => s.ticketId === ticketId)) {
|
if (sessions.some((s) => s.ticketId === ticketId)) {
|
||||||
|
const shouldAutoScroll = !isMinimizedRef.current && isAtBottomRef.current
|
||||||
|
if (shouldAutoScroll) {
|
||||||
|
pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: true }
|
||||||
|
}
|
||||||
loadMessages()
|
loadMessages()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -347,6 +422,59 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
}
|
}
|
||||||
}, [ticketId, loadMessages])
|
}, [ticketId, loadMessages])
|
||||||
|
|
||||||
|
// Atualizar contador em tempo real (inclui decremento quando a máquina marca como lida)
|
||||||
|
useEffect(() => {
|
||||||
|
let unlisten: (() => void) | null = null
|
||||||
|
|
||||||
|
listen<UnreadUpdateEvent>("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)
|
// Recarregar quando a sessao for encerrada (para refletir offline/minimizar corretamente)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unlisten: (() => void) | null = null
|
let unlisten: (() => void) | null = null
|
||||||
|
|
@ -431,26 +559,6 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
return () => window.removeEventListener("resize", handler)
|
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
|
// Selecionar arquivo para anexar
|
||||||
const handleAttach = async () => {
|
const handleAttach = async () => {
|
||||||
if (isUploading || isSending) return
|
if (isUploading || isSending) return
|
||||||
|
|
@ -528,6 +636,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
type: a.type,
|
type: a.type,
|
||||||
})),
|
})),
|
||||||
}])
|
}])
|
||||||
|
pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: false }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Erro ao enviar mensagem:", err)
|
console.error("Erro ao enviar mensagem:", err)
|
||||||
// Restaurar input e anexos em caso de erro
|
// Restaurar input e anexos em caso de erro
|
||||||
|
|
@ -548,14 +657,18 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExpand = async () => {
|
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)
|
setIsMinimized(false)
|
||||||
try {
|
try {
|
||||||
await invoke("set_chat_minimized", { ticketId, minimized: false })
|
await invoke("set_chat_minimized", { ticketId, minimized: false })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Erro ao expandir janela:", 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 = () => {
|
const handleClose = () => {
|
||||||
|
|
@ -681,7 +794,11 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mensagens */}
|
{/* Mensagens */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div
|
||||||
|
ref={messagesContainerRef}
|
||||||
|
onScroll={updateIsAtBottom}
|
||||||
|
className="flex-1 overflow-y-auto p-4"
|
||||||
|
>
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-slate-400">
|
||||||
|
|
@ -698,8 +815,23 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
// Layout igual à web: cliente à esquerda, agente à direita
|
// Layout igual à web: cliente à esquerda, agente à direita
|
||||||
const isAgent = !msg.isFromMachine
|
const isAgent = !msg.isFromMachine
|
||||||
return (
|
return (
|
||||||
|
<div key={msg.id} className="space-y-2">
|
||||||
|
{firstUnreadAgentMessageId === msg.id && unreadCount > 0 && !isAtBottom && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-px flex-1 bg-slate-200" />
|
||||||
|
<span className="text-xs font-medium text-slate-500">Novas mensagens</span>
|
||||||
|
<div className="h-px flex-1 bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
ref={(el) => {
|
||||||
|
if (el) {
|
||||||
|
messageElementsRef.current.set(msg.id, el)
|
||||||
|
} else {
|
||||||
|
messageElementsRef.current.delete(msg.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={`flex gap-2 ${isAgent ? "flex-row-reverse" : "flex-row"}`}
|
className={`flex gap-2 ${isAgent ? "flex-row-reverse" : "flex-row"}`}
|
||||||
>
|
>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
|
|
@ -747,6 +879,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
|
|
@ -756,6 +889,25 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div className="border-t border-slate-200 p-3">
|
<div className="border-t border-slate-200 p-3">
|
||||||
|
{unreadCount > 0 && !isAtBottom && (
|
||||||
|
<div className="mb-2 flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const target = firstUnreadAgentMessageId
|
||||||
|
if (target) {
|
||||||
|
scrollToMessage(target, "smooth")
|
||||||
|
} else {
|
||||||
|
scrollToBottom("smooth")
|
||||||
|
}
|
||||||
|
markUnreadMessagesRead().catch((err) => console.error("Falha ao marcar mensagens como lidas:", err))
|
||||||
|
}}
|
||||||
|
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700 hover:bg-slate-200"
|
||||||
|
>
|
||||||
|
Ver novas mensagens ({unreadCount > 9 ? "9+" : unreadCount})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Anexos pendentes */}
|
{/* Anexos pendentes */}
|
||||||
{pendingAttachments.length > 0 && (
|
{pendingAttachments.length > 0 && (
|
||||||
<div className="mb-2 flex flex-wrap gap-2">
|
<div className="mb-2 flex flex-wrap gap-2">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue