feat(desktop): implementa Convex React subscriptions para chat em tempo real
- Adiciona ConvexMachineProvider para autenticacao via machine token - Cria hooks customizados (useMachineSessions, useMachineMessages, etc) - Refatora ChatWidget e ChatHubWidget para usar useQuery/useMutation - Remove polling e dependencia de Tauri events para mensagens - Adiciona copia local dos arquivos _generated do Convex - Remove componentes obsoletos (ChatSessionItem, ChatSessionList) Beneficios: - Tempo real verdadeiro via WebSocket (sem polling) - Melhor escalabilidade e performance - Codigo mais simples e maintivel - Consistencia de estado entre multiplas janelas 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a6af4aa580
commit
c51b08f127
16 changed files with 1181 additions and 650 deletions
|
|
@ -1,25 +1,26 @@
|
|||
/**
|
||||
* 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 { 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,
|
||||
SessionStartedEvent,
|
||||
UnreadUpdateEvent,
|
||||
} from "./types"
|
||||
import { getMachineStoreConfig } from "./machineStore"
|
||||
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 // Limite de mensagens para evitar memory leak
|
||||
const MAX_MESSAGES_IN_MEMORY = 200
|
||||
const MARK_READ_BATCH_SIZE = 50
|
||||
const SCROLL_BOTTOM_THRESHOLD_PX = 120
|
||||
|
||||
// Tipos de arquivo permitidos
|
||||
const ALLOWED_EXTENSIONS = [
|
||||
"jpg", "jpeg", "png", "gif", "webp",
|
||||
"pdf", "txt", "doc", "docx", "xls", "xlsx",
|
||||
|
|
@ -32,6 +33,13 @@ interface UploadedAttachment {
|
|||
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)) {
|
||||
|
|
@ -57,7 +65,7 @@ function formatAttachmentSize(size?: number) {
|
|||
return `${(kb / 1024).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
function getUnreadAgentMessageIds(messages: ChatMessage[], unreadCount: number): string[] {
|
||||
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--) {
|
||||
|
|
@ -138,7 +146,6 @@ function MessageAttachment({
|
|||
setTimeout(() => setDownloaded(false), 2000)
|
||||
} catch (err) {
|
||||
console.error("Falha ao baixar anexo:", err)
|
||||
// Fallback: abrir no navegador/sistema
|
||||
await handleView()
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
|
|
@ -160,7 +167,7 @@ function MessageAttachment({
|
|||
if (isImage && url) {
|
||||
return (
|
||||
<div className={`group relative overflow-hidden rounded-lg border ${isAgent ? "border-white/10" : "border-slate-200"}`}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- Tauri desktop app, not Next.js */}
|
||||
<img
|
||||
src={url}
|
||||
alt={attachment.name}
|
||||
|
|
@ -234,24 +241,28 @@ interface ChatWidgetProps {
|
|||
}
|
||||
|
||||
export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [ticketInfo, setTicketInfo] = useState<{ ref: number; subject: string; agentName: string } | null>(null)
|
||||
const [hasSession, setHasSession] = useState(false)
|
||||
const [pendingAttachments, setPendingAttachments] = useState<UploadedAttachment[]>([])
|
||||
// Inicializa minimizado porque o Rust abre a janela e minimiza imediatamente
|
||||
const [isMinimized, setIsMinimized] = useState(true)
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
|
||||
// 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<HTMLDivElement>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const messageElementsRef = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||
const prevHasSessionRef = useRef<boolean>(false)
|
||||
const retryDelayMsRef = useRef<number>(1_000)
|
||||
|
||||
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||
const isAtBottomRef = useRef(true)
|
||||
|
|
@ -288,43 +299,39 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
return true
|
||||
}, [updateIsAtBottom])
|
||||
|
||||
// Quando a sessão termina (hasSession muda de true -> false), fechar a janela para não ficar "Offline" preso
|
||||
// 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 sessão:", err)
|
||||
console.error("Erro ao fechar janela ao encerrar sessao:", err)
|
||||
})
|
||||
}
|
||||
prevHasSessionRef.current = hasSession
|
||||
}, [hasSession, ticketId])
|
||||
|
||||
// Ref para acessar isMinimized dentro do callback sem causar resubscription
|
||||
// Ref para acessar isMinimized dentro de callbacks
|
||||
const isMinimizedRef = useRef(isMinimized)
|
||||
useEffect(() => {
|
||||
isMinimizedRef.current = isMinimized
|
||||
}, [isMinimized])
|
||||
|
||||
const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null)
|
||||
|
||||
const ensureConfig = useCallback(async () => {
|
||||
const cfg = configRef.current ?? (await getMachineStoreConfig())
|
||||
configRef.current = cfg
|
||||
return cfg
|
||||
}, [])
|
||||
|
||||
// Cache de URLs de anexos
|
||||
const attachmentUrlCacheRef = useRef<Map<string, string>>(new Map())
|
||||
|
||||
const loadAttachmentUrl = useCallback(async (storageId: string) => {
|
||||
const cached = attachmentUrlCacheRef.current.get(storageId)
|
||||
if (cached) return cached
|
||||
|
||||
const cfg = await ensureConfig()
|
||||
const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/attachments/url`, {
|
||||
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: cfg.token,
|
||||
machineToken,
|
||||
ticketId,
|
||||
storageId,
|
||||
}),
|
||||
|
|
@ -342,148 +349,32 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
|
||||
attachmentUrlCacheRef.current.set(storageId, data.url)
|
||||
return data.url
|
||||
}, [ensureConfig, ticketId])
|
||||
|
||||
const loadMessages = useCallback(async () => {
|
||||
try {
|
||||
const cfg = await ensureConfig()
|
||||
const result = await invoke<ChatMessagesResponse>("fetch_chat_messages", {
|
||||
baseUrl: cfg.apiBaseUrl,
|
||||
token: cfg.token,
|
||||
ticketId,
|
||||
since: null,
|
||||
})
|
||||
|
||||
setHasSession(result.hasSession)
|
||||
setUnreadCount(result.unreadCount ?? 0)
|
||||
setMessages(result.messages.slice(-MAX_MESSAGES_IN_MEMORY))
|
||||
|
||||
if (result.messages.length > 0) {
|
||||
const first = result.messages[0]
|
||||
setTicketInfo((prevInfo) =>
|
||||
prevInfo ?? {
|
||||
ref: ticketRef ?? 0,
|
||||
subject: "",
|
||||
agentName: first.authorName ?? "Suporte",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
setError(null)
|
||||
retryDelayMsRef.current = 1_000
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setError(message || "Erro ao carregar mensagens.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [ensureConfig, ticketId, ticketRef])
|
||||
|
||||
// Auto-retry leve quando houver erro (evita ficar "morto" após falha transiente).
|
||||
useEffect(() => {
|
||||
if (!error) return
|
||||
|
||||
const delayMs = retryDelayMsRef.current
|
||||
const timeout = window.setTimeout(() => {
|
||||
loadMessages()
|
||||
}, delayMs)
|
||||
|
||||
retryDelayMsRef.current = Math.min(retryDelayMsRef.current * 2, 30_000)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}, [error, loadMessages])
|
||||
}, [apiBaseUrl, machineToken, ticketId])
|
||||
|
||||
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", {
|
||||
base_url: cfg.apiBaseUrl,
|
||||
token: cfg.token,
|
||||
ticket_id: ticketId,
|
||||
message_ids: chunk,
|
||||
await markMessagesRead({
|
||||
ticketId: ticketId as Id<"tickets">,
|
||||
messageIds: chunk as Id<"ticketChatMessages">[],
|
||||
})
|
||||
}
|
||||
}, [messages, ticketId, unreadCount, markMessagesRead])
|
||||
|
||||
setUnreadCount(0)
|
||||
}, [ensureConfig, messages, ticketId, unreadCount])
|
||||
|
||||
// Carregar mensagens na montagem / troca de ticket
|
||||
// Auto-scroll quando novas mensagens chegam (se ja estava no bottom)
|
||||
const prevMessagesLengthRef = useRef(messages.length)
|
||||
useEffect(() => {
|
||||
setIsLoading(true)
|
||||
setMessages([])
|
||||
setUnreadCount(0)
|
||||
loadMessages()
|
||||
}, [loadMessages])
|
||||
|
||||
// Recarregar quando o Rust sinalizar novas mensagens para este ticket
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | null = null
|
||||
listen<NewMessageEvent>("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()
|
||||
}
|
||||
})
|
||||
.then((u) => {
|
||||
unlisten = u
|
||||
})
|
||||
.catch((err) => console.error("Falha ao registrar listener new-message:", err))
|
||||
|
||||
return () => {
|
||||
unlisten?.()
|
||||
if (messages.length > prevMessagesLengthRef.current && isAtBottomRef.current && !isMinimizedRef.current) {
|
||||
pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: true }
|
||||
}
|
||||
}, [ticketId, loadMessages])
|
||||
prevMessagesLengthRef.current = messages.length
|
||||
}, [messages.length])
|
||||
|
||||
// Recarregar quando uma nova sessão iniciar (usuário pode estar com o chat aberto em "Offline")
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | null = null
|
||||
listen<SessionStartedEvent>("raven://chat/session-started", (event) => {
|
||||
if (event.payload?.session?.ticketId === ticketId) {
|
||||
loadMessages()
|
||||
}
|
||||
})
|
||||
.then((u) => {
|
||||
unlisten = u
|
||||
})
|
||||
.catch((err) => console.error("Falha ao registrar listener session-started:", err))
|
||||
|
||||
return () => {
|
||||
unlisten?.()
|
||||
}
|
||||
}, [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)
|
||||
// Executar scroll pendente
|
||||
useEffect(() => {
|
||||
if (isMinimized) return
|
||||
|
||||
|
|
@ -517,84 +408,16 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
}
|
||||
}, [isMinimized, messages, markUnreadMessagesRead, scrollToBottom, scrollToMessage])
|
||||
|
||||
// Recarregar quando a sessao for encerrada (para refletir offline/minimizar corretamente)
|
||||
// Sincronizar estado minimizado com tamanho da janela
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | null = null
|
||||
listen<SessionEndedEvent>("raven://chat/session-ended", (event) => {
|
||||
if (event.payload?.ticketId === ticketId) {
|
||||
loadMessages()
|
||||
}
|
||||
})
|
||||
.then((u) => {
|
||||
unlisten = u
|
||||
})
|
||||
.catch((err) => console.error("Falha ao registrar listener session-ended:", err))
|
||||
|
||||
return () => {
|
||||
unlisten?.()
|
||||
}
|
||||
}, [ticketId, loadMessages])
|
||||
|
||||
// Inicializacao via Convex (WS) - NAO depende de isMinimized para evitar resubscriptions
|
||||
/* useEffect(() => {
|
||||
setIsLoading(true)
|
||||
setMessages([])
|
||||
messagesSubRef.current?.()
|
||||
// reset contador ao trocar ticket
|
||||
setUnreadCount(0)
|
||||
|
||||
subscribeMachineMessages(
|
||||
ticketId,
|
||||
(payload) => {
|
||||
setIsLoading(false)
|
||||
setHasSession(payload.hasSession)
|
||||
hadSessionRef.current = hadSessionRef.current || payload.hasSession
|
||||
// Usa o unreadCount do backend (baseado em unreadByMachine da sessao)
|
||||
const backendUnreadCount = (payload as { unreadCount?: number }).unreadCount ?? 0
|
||||
setUnreadCount(backendUnreadCount)
|
||||
setMessages(prev => {
|
||||
const existingIds = new Set(prev.map(m => m.id))
|
||||
const combined = [...prev, ...payload.messages.filter(m => !existingIds.has(m.id))]
|
||||
return combined.slice(-MAX_MESSAGES_IN_MEMORY)
|
||||
})
|
||||
// Atualiza info basica do ticket
|
||||
if (payload.messages.length > 0) {
|
||||
const first = payload.messages[0]
|
||||
setTicketInfo((prevInfo) => prevInfo ?? { ref: 0, subject: "", agentName: first.authorName ?? "Suporte" })
|
||||
}
|
||||
// NAO marca como lidas aqui - deixa o useEffect de expansao fazer isso
|
||||
// Isso evita marcar como lidas antes do usuario expandir o chat
|
||||
},
|
||||
(err) => {
|
||||
setIsLoading(false)
|
||||
setError(err.message || "Erro ao carregar mensagens.")
|
||||
}
|
||||
).then((unsub) => {
|
||||
messagesSubRef.current = unsub
|
||||
})
|
||||
|
||||
return () => {
|
||||
messagesSubRef.current?.()
|
||||
messagesSubRef.current = null
|
||||
}
|
||||
}, [ticketId]) */ // Removido isMinimized - evita memory leak de resubscriptions
|
||||
|
||||
// Sincroniza estado de minimizado com o tamanho da janela (apenas em resizes reais, nao na montagem)
|
||||
// O estado inicial isMinimized=true e definido no useState e nao deve ser sobrescrito na montagem
|
||||
useEffect(() => {
|
||||
// Ignorar todos os eventos de resize nos primeiros 500ms apos a montagem
|
||||
// Isso da tempo ao Tauri de aplicar o tamanho correto da janela
|
||||
// e evita que resizes transitórios durante a criação da janela alterem o estado
|
||||
const mountTime = Date.now()
|
||||
const STABILIZATION_DELAY = 500 // ms para a janela estabilizar
|
||||
const STABILIZATION_DELAY = 500
|
||||
|
||||
const handler = () => {
|
||||
// Ignorar eventos de resize durante o periodo de estabilizacao
|
||||
if (Date.now() - mountTime < STABILIZATION_DELAY) {
|
||||
return
|
||||
}
|
||||
const h = window.innerHeight
|
||||
// thresholds alinhados com set_chat_minimized (52px minimizado, 520px expandido)
|
||||
setIsMinimized(h < 100)
|
||||
}
|
||||
window.addEventListener("resize", handler)
|
||||
|
|
@ -616,16 +439,17 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
|
||||
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 getMachineStoreConfig()
|
||||
if (!apiBaseUrl || !machineToken) {
|
||||
throw new Error("Configuracao nao disponivel")
|
||||
}
|
||||
|
||||
const attachment = await invoke<UploadedAttachment>("upload_chat_file", {
|
||||
baseUrl: config.apiBaseUrl,
|
||||
token: config.token,
|
||||
baseUrl: apiBaseUrl,
|
||||
token: machineToken,
|
||||
filePath,
|
||||
})
|
||||
|
||||
|
|
@ -654,34 +478,19 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
setIsSending(true)
|
||||
|
||||
try {
|
||||
const bodyToSend = messageText
|
||||
const cfg = await ensureConfig()
|
||||
await invoke("send_chat_message", {
|
||||
base_url: cfg.apiBaseUrl,
|
||||
token: cfg.token,
|
||||
ticket_id: ticketId,
|
||||
body: bodyToSend,
|
||||
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
|
||||
})
|
||||
|
||||
// Adicionar mensagem localmente
|
||||
setMessages(prev => [...prev, {
|
||||
id: crypto.randomUUID(),
|
||||
body: bodyToSend,
|
||||
authorName: "Você",
|
||||
isFromMachine: true,
|
||||
createdAt: Date.now(),
|
||||
attachments: attachmentsToSend.map(a => ({
|
||||
storageId: a.storageId,
|
||||
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)
|
||||
// Restaurar input e anexos em caso de erro
|
||||
setInputValue(messageText)
|
||||
setPendingAttachments(attachmentsToSend)
|
||||
} finally {
|
||||
|
|
@ -726,9 +535,8 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
}
|
||||
}
|
||||
|
||||
// Loading
|
||||
if (isLoading) {
|
||||
// Mostrar chip compacto enquanto carrega (compativel com janela minimizada)
|
||||
// pointer-events-none no container para que a area transparente nao seja clicavel
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
|
||||
|
|
@ -739,31 +547,14 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// Mostrar chip compacto de erro (compativel com janela minimizada)
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadMessages()}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg transition hover:bg-red-200/60"
|
||||
title="Tentar novamente"
|
||||
>
|
||||
<X className="size-4" />
|
||||
<span className="text-sm font-medium">Erro no chat</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Quando não há sessão, mostrar versão minimizada com indicador de offline
|
||||
// Sem sessao ativa
|
||||
if (!hasSession) {
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
|
||||
<MessageCircle className="size-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{ticketRef ? `Ticket #${ticketRef}` : ticketInfo?.ref ? `Ticket #${ticketInfo.ref}` : "Chat"}
|
||||
{ticketRef ? `Ticket #${ticketRef}` : "Chat"}
|
||||
</span>
|
||||
<span className="size-2 rounded-full bg-slate-400" />
|
||||
<span className="text-xs text-slate-500">Offline</span>
|
||||
|
|
@ -779,8 +570,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// Versão minimizada (chip compacto igual web)
|
||||
// pointer-events-none no container para que apenas o botao seja clicavel
|
||||
// Minimizado
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
|
||||
|
|
@ -790,11 +580,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
>
|
||||
<MessageCircle className="size-4" />
|
||||
<span className="text-sm font-medium">
|
||||
Ticket #{ticketRef ?? ticketInfo?.ref}
|
||||
Ticket #{ticketRef}
|
||||
</span>
|
||||
<span className="size-2 rounded-full bg-emerald-400" />
|
||||
<ChevronUp className="size-4" />
|
||||
{/* Badge de mensagens não lidas */}
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
|
|
@ -805,9 +594,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// Expandido
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
|
||||
{/* Header - arrastavel */}
|
||||
{/* Header */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3 rounded-t-2xl"
|
||||
|
|
@ -824,11 +614,9 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
Online
|
||||
</span>
|
||||
</div>
|
||||
{(ticketRef || ticketInfo) && (
|
||||
<p className="text-xs text-slate-500">
|
||||
Ticket #{ticketRef ?? ticketInfo?.ref} - {ticketInfo?.agentName ?? "Suporte"}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-500">
|
||||
Ticket #{ticketRef} - Suporte
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
@ -861,14 +649,12 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
Nenhuma mensagem ainda
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
O agente iniciará a conversa em breve
|
||||
O agente iniciara a conversa em breve
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messages.map((msg) => {
|
||||
// No desktop: isFromMachine=true significa mensagem do cliente (maquina)
|
||||
// Layout igual à web: cliente à esquerda, agente à direita
|
||||
const isAgent = !msg.isFromMachine
|
||||
const bodyText = msg.body.trim()
|
||||
const shouldShowBody =
|
||||
|
|
@ -883,62 +669,67 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
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"}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`flex size-7 shrink-0 items-center justify-center rounded-full ${
|
||||
isAgent ? "bg-black text-white" : "bg-slate-200 text-slate-600"
|
||||
}`}
|
||||
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"}`}
|
||||
>
|
||||
{isAgent ? <MessageCircle className="size-3.5" /> : <User className="size-3.5" />}
|
||||
</div>
|
||||
|
||||
{/* Bubble */}
|
||||
<div
|
||||
className={`max-w-[75%] rounded-2xl px-4 py-2 ${
|
||||
isAgent
|
||||
? "rounded-br-md bg-black text-white"
|
||||
: "rounded-bl-md border border-slate-100 bg-white text-slate-900 shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{!isAgent && (
|
||||
<p className="mb-1 text-xs font-medium text-slate-500">
|
||||
{msg.authorName}
|
||||
</p>
|
||||
)}
|
||||
{shouldShowBody && <p className="whitespace-pre-wrap text-sm">{msg.body}</p>}
|
||||
{/* Anexos */}
|
||||
{msg.attachments && msg.attachments.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{msg.attachments.map((att) => (
|
||||
<MessageAttachment
|
||||
key={att.storageId}
|
||||
attachment={att}
|
||||
isAgent={isAgent}
|
||||
loadUrl={loadAttachmentUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
className={`mt-1 text-right text-xs ${
|
||||
isAgent ? "text-white/60" : "text-slate-400"
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`flex size-7 shrink-0 items-center justify-center rounded-full ${
|
||||
isAgent ? "bg-black text-white" : "bg-slate-200 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{formatTime(msg.createdAt)}
|
||||
</p>
|
||||
{isAgent ? <MessageCircle className="size-3.5" /> : <User className="size-3.5" />}
|
||||
</div>
|
||||
|
||||
{/* Bubble */}
|
||||
<div
|
||||
className={`max-w-[75%] rounded-2xl px-4 py-2 ${
|
||||
isAgent
|
||||
? "rounded-br-md bg-black text-white"
|
||||
: "rounded-bl-md border border-slate-100 bg-white text-slate-900 shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{!isAgent && (
|
||||
<p className="mb-1 text-xs font-medium text-slate-500">
|
||||
{msg.authorName}
|
||||
</p>
|
||||
)}
|
||||
{shouldShowBody && <p className="whitespace-pre-wrap text-sm">{msg.body}</p>}
|
||||
{/* Anexos */}
|
||||
{msg.attachments && msg.attachments.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{msg.attachments.map((att) => (
|
||||
<MessageAttachment
|
||||
key={att.storageId}
|
||||
attachment={{
|
||||
storageId: att.storageId as string,
|
||||
name: att.name,
|
||||
size: att.size,
|
||||
type: att.type,
|
||||
}}
|
||||
isAgent={isAgent}
|
||||
loadUrl={loadAttachmentUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
className={`mt-1 text-right text-xs ${
|
||||
isAgent ? "text-white/60" : "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{formatTime(msg.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue