891 lines
31 KiB
TypeScript
891 lines
31 KiB
TypeScript
/**
|
|
* 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, MessagesSquare } from "lucide-react"
|
|
import type { Id } from "@convex/_generated/dataModel"
|
|
import { useMachineMessages, useMachineSessions, 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 <ImageIcon className="size-4" />
|
|
}
|
|
if (["pdf", "doc", "docx", "txt"].includes(ext)) {
|
|
return <FileText className="size-4" />
|
|
}
|
|
return <File className="size-4" />
|
|
}
|
|
|
|
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<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({
|
|
attachment,
|
|
isAgent,
|
|
loadUrl,
|
|
}: {
|
|
attachment: ChatAttachment
|
|
isAgent: boolean
|
|
loadUrl: (storageId: string) => Promise<string>
|
|
}) {
|
|
const [url, setUrl] = useState<string | null>(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 (
|
|
<div className={`flex items-center gap-2 rounded-lg p-2 text-xs ${isAgent ? "bg-white/10" : "bg-slate-100"}`}>
|
|
<Loader2 className="size-4 animate-spin" />
|
|
<span className="truncate">Carregando anexo...</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 -- Tauri desktop app, not Next.js */}
|
|
<img
|
|
src={url}
|
|
alt={attachment.name}
|
|
className="size-24 cursor-pointer object-cover"
|
|
onClick={handleView}
|
|
/>
|
|
<div className="absolute inset-0 flex items-center justify-center gap-1 bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
|
|
<button
|
|
onClick={handleView}
|
|
className="flex size-7 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
|
|
aria-label="Visualizar anexo"
|
|
>
|
|
<Eye className="size-4 text-white" />
|
|
</button>
|
|
<button
|
|
onClick={handleDownload}
|
|
disabled={downloading}
|
|
className="flex size-7 items-center justify-center rounded-full bg-white/20 hover:bg-white/30 disabled:opacity-60"
|
|
aria-label="Baixar anexo"
|
|
>
|
|
{downloading ? (
|
|
<Loader2 className="size-4 animate-spin text-white" />
|
|
) : downloaded ? (
|
|
<Check className="size-4 text-emerald-300" />
|
|
) : (
|
|
<Download className="size-4 text-white" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={`flex items-center gap-2 rounded-lg p-2 text-xs ${isAgent ? "bg-white/10" : "bg-slate-100"}`}>
|
|
{getFileIcon(attachment.name)}
|
|
<button
|
|
onClick={handleView}
|
|
className="flex-1 truncate text-left hover:underline"
|
|
aria-label={`Visualizar anexo ${attachment.name}`}
|
|
>
|
|
{attachment.name}
|
|
</button>
|
|
{sizeLabel && <span className="text-xs opacity-60">({sizeLabel})</span>}
|
|
<div className="ml-1 flex items-center gap-1">
|
|
<button
|
|
onClick={handleView}
|
|
className={`flex size-7 items-center justify-center rounded-md ${isAgent ? "hover:bg-white/10" : "hover:bg-slate-200"}`}
|
|
aria-label="Visualizar anexo"
|
|
>
|
|
<Eye className="size-4" />
|
|
</button>
|
|
<button
|
|
onClick={handleDownload}
|
|
disabled={downloading}
|
|
className={`flex size-7 items-center justify-center rounded-md disabled:opacity-60 ${isAgent ? "hover:bg-white/10" : "hover:bg-slate-200"}`}
|
|
aria-label="Baixar anexo"
|
|
>
|
|
{downloading ? (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
) : downloaded ? (
|
|
<Check className="size-4 text-emerald-500" />
|
|
) : (
|
|
<Download className="size-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<UploadedAttachment[]>([])
|
|
// Inicializa baseado na altura real da janela (< 100px = minimizado)
|
|
const [isMinimized, setIsMinimized] = useState(() => window.innerHeight < 100)
|
|
|
|
// Convex hooks
|
|
const { apiBaseUrl, machineToken } = useConvexMachine()
|
|
const { sessions: machineSessions = [] } = useMachineSessions()
|
|
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 [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 autoReadInFlightRef = useRef(false)
|
|
const lastAutoReadCountRef = useRef<number | null>(null)
|
|
|
|
const unreadAgentMessageIds = useMemo(() => getUnreadAgentMessageIds(messages, unreadCount), [messages, unreadCount])
|
|
const firstUnreadAgentMessageId = unreadAgentMessageIds[0] ?? null
|
|
|
|
const otherUnreadCount = useMemo(() => {
|
|
if (machineSessions.length <= 1) return 0
|
|
return machineSessions.reduce((sum, session) => {
|
|
return sum + (session.ticketId === ticketId ? 0 : session.unreadCount)
|
|
}, 0)
|
|
}, [machineSessions, ticketId])
|
|
|
|
const handleOpenHub = useCallback(async () => {
|
|
try {
|
|
await invoke("open_hub_window")
|
|
await invoke("set_hub_minimized", { minimized: false })
|
|
} catch (err) {
|
|
console.error("Erro ao abrir hub:", err)
|
|
}
|
|
}, [])
|
|
|
|
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", { 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<Map<string, string>>(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 false
|
|
const ids = getUnreadAgentMessageIds(messages, unreadCount)
|
|
if (ids.length === 0) return false
|
|
|
|
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">[],
|
|
})
|
|
}
|
|
return true
|
|
}, [messages, ticketId, unreadCount, markMessagesRead])
|
|
|
|
const maybeAutoMarkRead = useCallback(async () => {
|
|
if (autoReadInFlightRef.current) return
|
|
if (!hasSession || unreadCount <= 0) return
|
|
if (isMinimizedRef.current || !isAtBottomRef.current) return
|
|
if (lastAutoReadCountRef.current === unreadCount) return
|
|
|
|
autoReadInFlightRef.current = true
|
|
try {
|
|
const didMark = await markUnreadMessagesRead()
|
|
if (didMark) {
|
|
lastAutoReadCountRef.current = unreadCount
|
|
}
|
|
} finally {
|
|
autoReadInFlightRef.current = false
|
|
}
|
|
}, [hasSession, unreadCount, markUnreadMessagesRead])
|
|
|
|
// 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])
|
|
|
|
useEffect(() => {
|
|
if (unreadCount === 0) {
|
|
lastAutoReadCountRef.current = null
|
|
return
|
|
}
|
|
maybeAutoMarkRead().catch((err) => console.error("Falha ao auto-marcar mensagens:", err))
|
|
}, [isMinimized, isAtBottom, unreadCount, maybeAutoMarkRead])
|
|
|
|
// 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<UploadedAttachment>("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", { 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("open_chat_window", { ticketId, ticketRef: ticketRef ?? 0 })
|
|
} catch (err) {
|
|
console.error("Erro ao expandir janela:", err)
|
|
}
|
|
}
|
|
|
|
const handleClose = () => {
|
|
invoke("close_chat_window", { 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 (
|
|
<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">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
<span className="text-sm font-medium">Carregando...</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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}` : "Chat"}
|
|
</span>
|
|
<span className="size-2 rounded-full bg-slate-400" />
|
|
<span className="text-xs text-slate-500">Offline</span>
|
|
<button
|
|
onClick={handleClose}
|
|
className="ml-1 rounded-full p-1 text-slate-600 hover:bg-slate-300/60"
|
|
aria-label="Fechar chat"
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Minimizado
|
|
if (isMinimized) {
|
|
return (
|
|
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
|
|
<button
|
|
onClick={handleExpand}
|
|
className="pointer-events-auto relative flex items-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow-lg hover:bg-black/90"
|
|
>
|
|
<MessageCircle className="size-4" />
|
|
<span className="text-sm font-medium">
|
|
Ticket #{ticketRef}
|
|
</span>
|
|
<span className="size-2 rounded-full bg-emerald-400" />
|
|
<ChevronUp className="size-4" />
|
|
{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}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Expandido
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
|
|
{/* 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"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
|
|
<MessageCircle className="size-5" />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-sm font-semibold text-slate-900">Chat</p>
|
|
<span className="flex items-center gap-1.5 text-xs text-emerald-600">
|
|
<span className="size-2 rounded-full bg-emerald-500 animate-pulse" />
|
|
Online
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-slate-500">
|
|
Ticket #{ticketRef} - Suporte
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{machineSessions.length > 1 && (
|
|
<button
|
|
onClick={handleOpenHub}
|
|
className="relative rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
|
aria-label="Abrir lista de chats"
|
|
>
|
|
<MessagesSquare className="size-4" />
|
|
{otherUnreadCount > 0 && (
|
|
<span className="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
|
|
{otherUnreadCount > 9 ? "9+" : otherUnreadCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={handleMinimize}
|
|
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
|
aria-label="Minimizar chat"
|
|
>
|
|
<Minimize2 className="size-4" />
|
|
</button>
|
|
<button
|
|
onClick={handleClose}
|
|
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
|
aria-label="Fechar chat"
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mensagens */}
|
|
<div
|
|
ref={messagesContainerRef}
|
|
onScroll={updateIsAtBottom}
|
|
className="flex-1 overflow-y-auto p-4"
|
|
>
|
|
{messages.length === 0 ? (
|
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
|
<p className="text-sm text-slate-400">
|
|
Nenhuma mensagem ainda
|
|
</p>
|
|
<p className="mt-1 text-xs text-slate-400">
|
|
O agente iniciara a conversa em breve
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{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 (
|
|
<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
|
|
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"
|
|
}`}
|
|
>
|
|
{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 ref={messagesEndRef} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<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 */}
|
|
{pendingAttachments.length > 0 && (
|
|
<div className="mb-2 flex flex-wrap gap-2">
|
|
{pendingAttachments.map((att) => (
|
|
<div
|
|
key={att.storageId}
|
|
className="flex items-center gap-1 rounded-lg bg-slate-100 px-2 py-1 text-xs"
|
|
>
|
|
{getFileIcon(att.name)}
|
|
<span className="max-w-[100px] truncate">{att.name}</span>
|
|
<button
|
|
onClick={() => handleRemoveAttachment(att.storageId)}
|
|
className="ml-1 rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
|
aria-label={`Remover anexo ${att.name}`}
|
|
>
|
|
<X className="size-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex items-end gap-2">
|
|
<textarea
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Digite sua mensagem..."
|
|
className="max-h-24 min-h-[36px] flex-1 resize-none rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-slate-400 focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|
rows={1}
|
|
/>
|
|
<button
|
|
onClick={handleAttach}
|
|
disabled={isUploading || isSending}
|
|
className="flex size-9 items-center justify-center rounded-lg text-slate-500 transition hover:bg-slate-100 hover:text-slate-700 disabled:opacity-50"
|
|
aria-label="Anexar arquivo"
|
|
>
|
|
{isUploading ? (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
) : (
|
|
<Paperclip className="size-4" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={(!inputValue.trim() && pendingAttachments.length === 0) || isSending}
|
|
className="flex size-9 items-center justify-center rounded-lg bg-black text-white transition hover:bg-black/90 disabled:opacity-50"
|
|
aria-label="Enviar mensagem"
|
|
>
|
|
{isSending ? (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
) : (
|
|
<Send className="size-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function formatTime(timestamp: number): string {
|
|
const date = new Date(timestamp)
|
|
return date.toLocaleTimeString("pt-BR", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})
|
|
}
|