Ajusta auto-minimização do chat web e unifica realtime no desktop
This commit is contained in:
parent
c65e37e232
commit
3d45fe3b04
10 changed files with 279 additions and 635 deletions
|
|
@ -83,6 +83,8 @@ pub struct ChatSessionSummary {
|
||||||
pub struct ChatMessagesResponse {
|
pub struct ChatMessagesResponse {
|
||||||
pub messages: Vec<ChatMessage>,
|
pub messages: Vec<ChatMessage>,
|
||||||
pub has_session: bool,
|
pub has_session: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub unread_count: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -259,6 +261,36 @@ pub async fn send_message(
|
||||||
.map_err(|e| format!("Falha ao parsear resposta de send: {e}"))
|
.map_err(|e| format!("Falha ao parsear resposta de send: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn mark_messages_read(
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
ticket_id: &str,
|
||||||
|
message_ids: &[String],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let url = format!("{}/api/machines/chat/read", base_url);
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"machineToken": token,
|
||||||
|
"ticketId": ticket_id,
|
||||||
|
"messageIds": message_ids,
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = CHAT_CLIENT
|
||||||
|
.post(&url)
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Falha na requisicao de mark read: {e}"))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("Mark read falhou: status={}, body={}", status, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// UPLOAD DE ARQUIVOS
|
// UPLOAD DE ARQUIVOS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -835,4 +867,3 @@ pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bo
|
||||||
crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized);
|
crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,19 @@ async fn send_chat_message(
|
||||||
chat::send_message(&base_url, &token, &ticket_id, &body, attachments).await
|
chat::send_message(&base_url, &token, &ticket_id, &body, attachments).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn mark_chat_messages_read(
|
||||||
|
base_url: String,
|
||||||
|
token: String,
|
||||||
|
ticket_id: String,
|
||||||
|
message_ids: Vec<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if message_ids.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
chat::mark_messages_read(&base_url, &token, &ticket_id, &message_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn upload_chat_file(
|
async fn upload_chat_file(
|
||||||
base_url: String,
|
base_url: String,
|
||||||
|
|
@ -501,6 +514,7 @@ pub fn run() {
|
||||||
fetch_chat_sessions,
|
fetch_chat_sessions,
|
||||||
fetch_chat_messages,
|
fetch_chat_messages,
|
||||||
send_chat_message,
|
send_chat_message,
|
||||||
|
mark_chat_messages_read,
|
||||||
upload_chat_file,
|
upload_chat_file,
|
||||||
open_chat_window,
|
open_chat_window,
|
||||||
close_chat_window,
|
close_chat_window,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { open } from "@tauri-apps/plugin-dialog"
|
import { open } from "@tauri-apps/plugin-dialog"
|
||||||
import { invoke } from "@tauri-apps/api/core"
|
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 } from "lucide-react"
|
import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2 } from "lucide-react"
|
||||||
import type { ChatMessage } from "./types"
|
import type { ChatMessage, ChatMessagesResponse, NewMessageEvent } from "./types"
|
||||||
import {
|
import { getMachineStoreConfig } from "./machineStore"
|
||||||
subscribeMachineMessages,
|
|
||||||
sendMachineMessage,
|
|
||||||
markMachineMessagesRead,
|
|
||||||
getMachineStoreConfig,
|
|
||||||
} from "./convexMachineClient"
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -56,7 +52,6 @@ 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 messagesSubRef = useRef<(() => void) | null>(null)
|
|
||||||
const hadSessionRef = useRef<boolean>(false)
|
const hadSessionRef = useRef<boolean>(false)
|
||||||
|
|
||||||
// Scroll para o final quando novas mensagens chegam
|
// Scroll para o final quando novas mensagens chegam
|
||||||
|
|
@ -86,8 +81,78 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
isMinimizedRef.current = isMinimized
|
isMinimizedRef.current = isMinimized
|
||||||
}, [isMinimized])
|
}, [isMinimized])
|
||||||
|
|
||||||
// Inicializacao via Convex (WS) - NAO depende de isMinimized para evitar resubscriptions
|
const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null)
|
||||||
|
|
||||||
|
const ensureConfig = useCallback(async () => {
|
||||||
|
const cfg = configRef.current ?? (await getMachineStoreConfig())
|
||||||
|
configRef.current = cfg
|
||||||
|
return cfg
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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)
|
||||||
|
hadSessionRef.current = hadSessionRef.current || 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)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
setError(message || "Erro ao carregar mensagens.")
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [ensureConfig, ticketId, ticketRef])
|
||||||
|
|
||||||
|
// Carregar mensagens na montagem / troca de ticket
|
||||||
useEffect(() => {
|
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)) {
|
||||||
|
loadMessages()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((u) => {
|
||||||
|
unlisten = u
|
||||||
|
})
|
||||||
|
.catch((err) => console.error("Falha ao registrar listener new-message:", err))
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten?.()
|
||||||
|
}
|
||||||
|
}, [ticketId, loadMessages])
|
||||||
|
|
||||||
|
// Inicializacao via Convex (WS) - NAO depende de isMinimized para evitar resubscriptions
|
||||||
|
/* useEffect(() => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setMessages([])
|
setMessages([])
|
||||||
messagesSubRef.current?.()
|
messagesSubRef.current?.()
|
||||||
|
|
@ -128,7 +193,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
messagesSubRef.current?.()
|
messagesSubRef.current?.()
|
||||||
messagesSubRef.current = null
|
messagesSubRef.current = null
|
||||||
}
|
}
|
||||||
}, [ticketId]) // Removido isMinimized - evita memory leak de resubscriptions
|
}, [ticketId]) */ // Removido isMinimized - evita memory leak de resubscriptions
|
||||||
|
|
||||||
// Sincroniza estado de minimizado com o tamanho da janela (apenas em resizes reais, nao na montagem)
|
// 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
|
// O estado inicial isMinimized=true e definido no useState e nao deve ser sobrescrito na montagem
|
||||||
|
|
@ -158,10 +223,19 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
if (unreadCount === 0) return
|
if (unreadCount === 0) return
|
||||||
const unreadIds = messages.filter(m => !m.isFromMachine).map(m => m.id as string)
|
const unreadIds = messages.filter(m => !m.isFromMachine).map(m => m.id as string)
|
||||||
if (unreadIds.length > 0) {
|
if (unreadIds.length > 0) {
|
||||||
markMachineMessagesRead(ticketId, unreadIds).catch(err => console.error("mark read falhou", err))
|
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
|
// Nao setamos unreadCount aqui - o backend vai zerar unreadByMachine e a subscription vai atualizar
|
||||||
}, [isMinimized, messages, ticketId, unreadCount])
|
}, [isMinimized, messages, ticketId, unreadCount, ensureConfig])
|
||||||
|
|
||||||
// Selecionar arquivo para anexar
|
// Selecionar arquivo para anexar
|
||||||
const handleAttach = async () => {
|
const handleAttach = async () => {
|
||||||
|
|
@ -217,7 +291,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bodyToSend = messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : "")
|
const bodyToSend = messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : "")
|
||||||
await sendMachineMessage({
|
const cfg = await ensureConfig()
|
||||||
|
await invoke("send_chat_message", {
|
||||||
|
baseUrl: cfg.apiBaseUrl,
|
||||||
|
token: cfg.token,
|
||||||
ticketId,
|
ticketId,
|
||||||
body: bodyToSend,
|
body: bodyToSend,
|
||||||
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
|
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
|
||||||
|
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
import { ConvexClient } from "convex/browser"
|
|
||||||
import type { FunctionReference } from "convex/server"
|
|
||||||
import { Store } from "@tauri-apps/plugin-store"
|
|
||||||
import { appLocalDataDir, join } from "@tauri-apps/api/path"
|
|
||||||
import type { ChatMessage } from "./types"
|
|
||||||
|
|
||||||
const STORE_FILENAME = "machine-agent.json"
|
|
||||||
const DEFAULT_CONVEX_URL =
|
|
||||||
import.meta.env.VITE_CONVEX_URL?.trim() ||
|
|
||||||
"https://convex.esdrasrenan.com.br"
|
|
||||||
|
|
||||||
type MachineStoreConfig = {
|
|
||||||
apiBaseUrl?: string
|
|
||||||
appUrl?: string
|
|
||||||
convexUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type MachineStoreData = {
|
|
||||||
token?: string
|
|
||||||
config?: MachineStoreConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
type ClientCache = {
|
|
||||||
client: ConvexClient
|
|
||||||
token: string
|
|
||||||
convexUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
let cached: ClientCache | null = null
|
|
||||||
|
|
||||||
type MachineUpdatePayload = {
|
|
||||||
hasActiveSessions: boolean
|
|
||||||
sessions: Array<{ ticketId: string; ticketRef: number; unreadCount: number; lastActivityAt: number }>
|
|
||||||
totalUnread: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nomes das functions no Convex (formato module:function)
|
|
||||||
const FN_CHECK_UPDATES = "liveChat:checkMachineUpdates" as const
|
|
||||||
const FN_LIST_MESSAGES = "liveChat:listMachineMessages" as const
|
|
||||||
const FN_POST_MESSAGE = "liveChat:postMachineMessage" as const
|
|
||||||
const FN_MARK_READ = "liveChat:markMachineMessagesRead" as const
|
|
||||||
const FN_UPLOAD_URL = "liveChat:generateMachineUploadUrl" as const
|
|
||||||
|
|
||||||
async function loadStore(): Promise<MachineStoreData> {
|
|
||||||
const appData = await appLocalDataDir()
|
|
||||||
const storePath = await join(appData, STORE_FILENAME)
|
|
||||||
const store = await Store.load(storePath)
|
|
||||||
const token = await store.get<string>("token")
|
|
||||||
const config = await store.get<MachineStoreConfig>("config")
|
|
||||||
return { token: token ?? undefined, config: config ?? undefined }
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveConvexUrl(config?: MachineStoreConfig): string {
|
|
||||||
const fromConfig = config?.convexUrl?.trim()
|
|
||||||
if (fromConfig) return fromConfig.replace(/\/+$/, "")
|
|
||||||
return DEFAULT_CONVEX_URL
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveApiBaseUrl(config?: MachineStoreConfig): string {
|
|
||||||
const fromConfig = config?.apiBaseUrl?.trim()
|
|
||||||
if (fromConfig) return fromConfig.replace(/\/+$/, "")
|
|
||||||
return "https://tickets.esdrasrenan.com.br"
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMachineStoreConfig() {
|
|
||||||
const data = await loadStore()
|
|
||||||
if (!data.token) {
|
|
||||||
throw new Error("Token de máquina não encontrado no store")
|
|
||||||
}
|
|
||||||
const apiBaseUrl = resolveApiBaseUrl(data.config)
|
|
||||||
const appUrl = data.config?.appUrl?.trim() || apiBaseUrl
|
|
||||||
return { token: data.token, apiBaseUrl, appUrl, convexUrl: resolveConvexUrl(data.config) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureClient(): Promise<ClientCache> {
|
|
||||||
const data = await loadStore()
|
|
||||||
if (!data.token) {
|
|
||||||
throw new Error("Token de máquina não encontrado no store")
|
|
||||||
}
|
|
||||||
const convexUrl = resolveConvexUrl(data.config)
|
|
||||||
|
|
||||||
if (cached && cached.token === data.token && cached.convexUrl === convexUrl) {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fechar cliente antigo antes de criar novo (evita memory leak)
|
|
||||||
if (cached) {
|
|
||||||
try {
|
|
||||||
cached.client.close()
|
|
||||||
} catch {
|
|
||||||
// Ignora erro ao fechar cliente antigo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new ConvexClient(convexUrl)
|
|
||||||
cached = { client, token: data.token, convexUrl }
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subscribeMachineUpdates(
|
|
||||||
callback: (payload: MachineUpdatePayload) => void,
|
|
||||||
onError?: (error: Error) => void
|
|
||||||
): Promise<() => void> {
|
|
||||||
const { client, token } = await ensureClient()
|
|
||||||
|
|
||||||
const sub = client.onUpdate(
|
|
||||||
FN_CHECK_UPDATES as unknown as FunctionReference<"query">,
|
|
||||||
{ machineToken: token },
|
|
||||||
(value) => callback(value),
|
|
||||||
(err) => onError?.(err)
|
|
||||||
)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
sub.unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subscribeMachineMessages(
|
|
||||||
ticketId: string,
|
|
||||||
callback: (payload: { messages: ChatMessage[]; hasSession: boolean }) => void,
|
|
||||||
onError?: (error: Error) => void
|
|
||||||
): Promise<() => void> {
|
|
||||||
const { client, token } = await ensureClient()
|
|
||||||
|
|
||||||
const sub = client.onUpdate(
|
|
||||||
FN_LIST_MESSAGES as unknown as FunctionReference<"query">,
|
|
||||||
{
|
|
||||||
machineToken: token,
|
|
||||||
ticketId,
|
|
||||||
},
|
|
||||||
(value) => callback(value),
|
|
||||||
(err) => onError?.(err)
|
|
||||||
)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
sub.unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendMachineMessage(input: {
|
|
||||||
ticketId: string
|
|
||||||
body: string
|
|
||||||
attachments?: Array<{
|
|
||||||
storageId: string
|
|
||||||
name: string
|
|
||||||
size?: number
|
|
||||||
type?: string
|
|
||||||
}>
|
|
||||||
}) {
|
|
||||||
const { client, token } = await ensureClient()
|
|
||||||
return client.mutation(FN_POST_MESSAGE as unknown as FunctionReference<"mutation">, {
|
|
||||||
machineToken: token,
|
|
||||||
ticketId: input.ticketId,
|
|
||||||
body: input.body,
|
|
||||||
attachments: input.attachments?.map((att) => ({
|
|
||||||
storageId: att.storageId,
|
|
||||||
name: att.name,
|
|
||||||
size: att.size,
|
|
||||||
type: att.type,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markMachineMessagesRead(ticketId: string, messageIds: string[]) {
|
|
||||||
if (messageIds.length === 0) return
|
|
||||||
const { client, token } = await ensureClient()
|
|
||||||
await client.mutation(FN_MARK_READ as unknown as FunctionReference<"mutation">, {
|
|
||||||
machineToken: token,
|
|
||||||
ticketId,
|
|
||||||
messageIds,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMachineUploadUrl(opts: {
|
|
||||||
fileName: string
|
|
||||||
fileType: string
|
|
||||||
fileSize: number
|
|
||||||
}) {
|
|
||||||
const { client, token } = await ensureClient()
|
|
||||||
return client.action(FN_UPLOAD_URL as unknown as FunctionReference<"action">, {
|
|
||||||
machineToken: token,
|
|
||||||
fileName: opts.fileName,
|
|
||||||
fileType: opts.fileType,
|
|
||||||
fileSize: opts.fileSize,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadToConvexStorage(uploadUrl: string, file: Blob | ArrayBuffer, contentType: string) {
|
|
||||||
const response = await fetch(uploadUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": contentType },
|
|
||||||
body: file,
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.text()
|
|
||||||
throw new Error(`Upload falhou: ${response.status} ${body}`)
|
|
||||||
}
|
|
||||||
const json = await response.json().catch(() => ({}))
|
|
||||||
return json.storageId || json.storage_id
|
|
||||||
}
|
|
||||||
52
apps/desktop/src/chat/machineStore.ts
Normal file
52
apps/desktop/src/chat/machineStore.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Store } from "@tauri-apps/plugin-store"
|
||||||
|
import { appLocalDataDir, join } from "@tauri-apps/api/path"
|
||||||
|
|
||||||
|
const STORE_FILENAME = "machine-agent.json"
|
||||||
|
const DEFAULT_API_BASE_URL = "https://tickets.esdrasrenan.com.br"
|
||||||
|
|
||||||
|
type MachineStoreConfig = {
|
||||||
|
apiBaseUrl?: string
|
||||||
|
appUrl?: string
|
||||||
|
convexUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineStoreData = {
|
||||||
|
token?: string
|
||||||
|
config?: MachineStoreConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStore(): Promise<MachineStoreData> {
|
||||||
|
const appData = await appLocalDataDir()
|
||||||
|
const storePath = await join(appData, STORE_FILENAME)
|
||||||
|
const store = await Store.load(storePath)
|
||||||
|
const token = await store.get<string>("token")
|
||||||
|
const config = await store.get<MachineStoreConfig>("config")
|
||||||
|
return { token: token ?? undefined, config: config ?? undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(value?: string | null, fallback?: string) {
|
||||||
|
const trimmed = (value ?? fallback ?? "").trim()
|
||||||
|
if (!trimmed) return fallback ?? ""
|
||||||
|
return trimmed.replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApiBaseUrl(config?: MachineStoreConfig): string {
|
||||||
|
const fromConfig = normalizeUrl(config?.apiBaseUrl, DEFAULT_API_BASE_URL)
|
||||||
|
return fromConfig || DEFAULT_API_BASE_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAppUrl(config?: MachineStoreConfig, apiBaseUrl?: string): string {
|
||||||
|
const fromConfig = normalizeUrl(config?.appUrl, apiBaseUrl)
|
||||||
|
return fromConfig || apiBaseUrl || DEFAULT_API_BASE_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMachineStoreConfig() {
|
||||||
|
const data = await loadStore()
|
||||||
|
if (!data.token) {
|
||||||
|
throw new Error("Token de maquina nao encontrado no store")
|
||||||
|
}
|
||||||
|
const apiBaseUrl = resolveApiBaseUrl(data.config)
|
||||||
|
const appUrl = resolveAppUrl(data.config, apiBaseUrl)
|
||||||
|
return { token: data.token, apiBaseUrl, appUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -33,6 +33,7 @@ export interface ChatAttachment {
|
||||||
export interface ChatMessagesResponse {
|
export interface ChatMessagesResponse {
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
hasSession: boolean
|
hasSession: boolean
|
||||||
|
unreadCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageResponse {
|
export interface SendMessageResponse {
|
||||||
|
|
|
||||||
|
|
@ -1,416 +0,0 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
|
||||||
import { MessageCircle, X, Minus, Send, Loader2, ChevronLeft, ChevronDown, ChevronRight } from "lucide-react"
|
|
||||||
import { cn } from "../lib/utils"
|
|
||||||
import type { ChatSession, ChatMessage, ChatHistorySession } from "../chat/types"
|
|
||||||
import {
|
|
||||||
subscribeMachineUpdates,
|
|
||||||
subscribeMachineMessages,
|
|
||||||
sendMachineMessage,
|
|
||||||
markMachineMessagesRead,
|
|
||||||
} from "../chat/convexMachineClient"
|
|
||||||
|
|
||||||
interface ChatFloatingWidgetProps {
|
|
||||||
sessions: ChatSession[]
|
|
||||||
totalUnread: number
|
|
||||||
isOpen: boolean
|
|
||||||
onToggle: () => void
|
|
||||||
onMinimize: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatFloatingWidget({
|
|
||||||
sessions,
|
|
||||||
totalUnread,
|
|
||||||
isOpen,
|
|
||||||
onToggle,
|
|
||||||
onMinimize,
|
|
||||||
}: ChatFloatingWidgetProps) {
|
|
||||||
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null)
|
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
||||||
const [inputValue, setInputValue] = useState("")
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [isSending, setIsSending] = useState(false)
|
|
||||||
const [historyExpanded, setHistoryExpanded] = useState(false)
|
|
||||||
const [historySessions] = useState<ChatHistorySession[]>([])
|
|
||||||
const [liveSessions, setLiveSessions] = useState<ChatSession[]>(sessions)
|
|
||||||
const [liveUnread, setLiveUnread] = useState<number>(totalUnread)
|
|
||||||
const sessionList = liveSessions.length > 0 ? liveSessions : sessions
|
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
||||||
const messagesSubRef = useRef<(() => void) | null>(null)
|
|
||||||
const updatesSubRef = useRef<(() => void) | null>(null)
|
|
||||||
|
|
||||||
// Selecionar ticket mais recente automaticamente
|
|
||||||
useEffect(() => {
|
|
||||||
const source = liveSessions.length > 0 ? liveSessions : sessions
|
|
||||||
if (source.length > 0 && !selectedTicketId) {
|
|
||||||
const sorted = [...source].sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
|
||||||
setSelectedTicketId(sorted[0].ticketId)
|
|
||||||
}
|
|
||||||
}, [sessions, liveSessions, selectedTicketId])
|
|
||||||
|
|
||||||
// Scroll para o final quando novas mensagens chegam
|
|
||||||
const scrollToBottom = useCallback(() => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scrollToBottom()
|
|
||||||
}, [messages, scrollToBottom])
|
|
||||||
|
|
||||||
// Assinar updates de sessões/unread
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
subscribeMachineUpdates(
|
|
||||||
(payload) => {
|
|
||||||
if (cancelled) return
|
|
||||||
const mapped: ChatSession[] = (payload.sessions ?? []).map((s) => ({
|
|
||||||
sessionId: s.ticketId,
|
|
||||||
ticketId: s.ticketId,
|
|
||||||
ticketRef: 0,
|
|
||||||
ticketSubject: "",
|
|
||||||
agentName: "",
|
|
||||||
agentEmail: undefined,
|
|
||||||
agentAvatarUrl: undefined,
|
|
||||||
unreadCount: s.unreadCount,
|
|
||||||
lastActivityAt: s.lastActivityAt,
|
|
||||||
startedAt: 0,
|
|
||||||
}))
|
|
||||||
setLiveSessions(mapped)
|
|
||||||
setLiveUnread(payload.totalUnread ?? 0)
|
|
||||||
},
|
|
||||||
(err) => console.error("chat updates erro:", err)
|
|
||||||
).then((unsub) => {
|
|
||||||
updatesSubRef.current = unsub
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
updatesSubRef.current?.()
|
|
||||||
updatesSubRef.current = null
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Assinar mensagens do ticket selecionado
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedTicketId || !isOpen) return
|
|
||||||
messagesSubRef.current?.()
|
|
||||||
setMessages([])
|
|
||||||
setIsLoading(true)
|
|
||||||
|
|
||||||
subscribeMachineMessages(
|
|
||||||
selectedTicketId,
|
|
||||||
(payload) => {
|
|
||||||
setIsLoading(false)
|
|
||||||
setMessages(payload.messages)
|
|
||||||
const unreadIds = payload.messages
|
|
||||||
.filter((m) => !m.isFromMachine)
|
|
||||||
.map((m) => m.id as string)
|
|
||||||
if (unreadIds.length) {
|
|
||||||
markMachineMessagesRead(selectedTicketId, unreadIds).catch((err) =>
|
|
||||||
console.error("mark read falhou", err)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
setIsLoading(false)
|
|
||||||
console.error("chat messages erro:", err)
|
|
||||||
}
|
|
||||||
).then((unsub) => {
|
|
||||||
messagesSubRef.current = unsub
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
messagesSubRef.current?.()
|
|
||||||
messagesSubRef.current = null
|
|
||||||
}
|
|
||||||
}, [selectedTicketId, isOpen])
|
|
||||||
|
|
||||||
// Enviar mensagem
|
|
||||||
const handleSend = async () => {
|
|
||||||
if (!inputValue.trim() || isSending || !selectedTicketId) return
|
|
||||||
|
|
||||||
const messageText = inputValue.trim()
|
|
||||||
setInputValue("")
|
|
||||||
setIsSending(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendMachineMessage({ ticketId: selectedTicketId, body: messageText })
|
|
||||||
setMessages(prev => [...prev, {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
body: messageText,
|
|
||||||
authorName: "Você",
|
|
||||||
isFromMachine: true,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
attachments: [],
|
|
||||||
}])
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Erro ao enviar mensagem:", err)
|
|
||||||
setInputValue(messageText)
|
|
||||||
} finally {
|
|
||||||
setIsSending(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
handleSend()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSession = sessionList.find(s => s.ticketId === selectedTicketId)
|
|
||||||
|
|
||||||
// Botao flutuante (fechado)
|
|
||||||
// DEBUG: Log do estado do widget
|
|
||||||
// console.log("[ChatFloatingWidget] Estado:", {
|
|
||||||
// isOpen,
|
|
||||||
// totalUnread: liveUnread,
|
|
||||||
// sessionsCount: liveSessions.length,
|
|
||||||
// })
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-4 right-4 z-50">
|
|
||||||
{/* DEBUG: Indicador visual do estado */}
|
|
||||||
<div className="absolute -left-32 bottom-0 rounded bg-yellow-100 p-1 text-[10px] text-yellow-800 shadow">
|
|
||||||
unread: {liveUnread} | sessions: {sessionList.length}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className="relative flex size-14 items-center justify-center rounded-full bg-black text-white shadow-lg transition hover:bg-black/90"
|
|
||||||
>
|
|
||||||
<MessageCircle className="size-6" />
|
|
||||||
{liveUnread > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="absolute -right-1 -top-1 flex size-6 items-center justify-center">
|
|
||||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
|
|
||||||
<span className="relative flex size-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
|
|
||||||
{liveUnread > 99 ? "99+" : liveUnread}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Widget expandido
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-4 right-4 z-50 flex h-[520px] w-[380px] flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl">
|
|
||||||
{/* Header */}
|
|
||||||
<div 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">
|
|
||||||
{sessionList.length > 1 && selectedTicketId && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedTicketId(null)}
|
|
||||||
className="rounded p-1 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
|
|
||||||
<MessageCircle className="size-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-slate-900">
|
|
||||||
{currentSession?.agentName ?? "Suporte"}
|
|
||||||
</p>
|
|
||||||
{currentSession && (
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Chamado #{currentSession.ticketRef}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{/* Tabs de tickets (se houver mais de 1) */}
|
|
||||||
{sessionList.length > 1 && (
|
|
||||||
<div className="mr-2 flex items-center gap-1">
|
|
||||||
{sessionList.slice(0, 3).map((session) => (
|
|
||||||
<button
|
|
||||||
key={session.ticketId}
|
|
||||||
onClick={() => setSelectedTicketId(session.ticketId)}
|
|
||||||
className={cn(
|
|
||||||
"rounded px-2 py-1 text-xs font-medium transition",
|
|
||||||
session.ticketId === selectedTicketId
|
|
||||||
? "bg-black text-white"
|
|
||||||
: "bg-slate-200 text-slate-600 hover:bg-slate-300"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
#{session.ticketRef}
|
|
||||||
{session.unreadCount > 0 && (
|
|
||||||
<span className="ml-1 inline-flex size-4 items-center justify-center rounded-full bg-red-500 text-[10px] text-white">
|
|
||||||
{session.unreadCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{sessionList.length > 3 && (
|
|
||||||
<span className="text-xs text-slate-400">+{sessionList.length - 3}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onMinimize}
|
|
||||||
className="rounded p-1.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
|
||||||
>
|
|
||||||
<Minus className="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onMinimize}
|
|
||||||
className="rounded p-1.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selecao de ticket (se nenhum selecionado e ha multiplos) */}
|
|
||||||
{!selectedTicketId && sessionList.length > 1 ? (
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
|
||||||
<p className="mb-3 text-sm font-medium text-slate-700">Selecione um chamado:</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sessionList.map((session) => (
|
|
||||||
<button
|
|
||||||
key={session.ticketId}
|
|
||||||
onClick={() => setSelectedTicketId(session.ticketId)}
|
|
||||||
className="w-full rounded-lg border border-slate-200 p-3 text-left transition hover:border-slate-300 hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-slate-900">
|
|
||||||
#{session.ticketRef}
|
|
||||||
</span>
|
|
||||||
{session.unreadCount > 0 && (
|
|
||||||
<span className="inline-flex size-5 items-center justify-center rounded-full bg-red-500 text-xs text-white">
|
|
||||||
{session.unreadCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 truncate text-xs text-slate-500">
|
|
||||||
{session.ticketSubject}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-slate-400">
|
|
||||||
{session.agentName}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Area de mensagens */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
|
||||||
{/* Historico de sessoes anteriores */}
|
|
||||||
{historySessions.length > 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setHistoryExpanded(!historyExpanded)}
|
|
||||||
className="flex w-full items-center justify-between rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-600"
|
|
||||||
>
|
|
||||||
<span>Historico ({historySessions.length} sessoes)</span>
|
|
||||||
{historyExpanded ? (
|
|
||||||
<ChevronDown className="size-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{historyExpanded && (
|
|
||||||
<div className="mt-2 space-y-2 rounded-lg border border-slate-200 p-2">
|
|
||||||
{historySessions.map((session) => (
|
|
||||||
<div key={session.sessionId} className="text-xs text-slate-500">
|
|
||||||
<p className="font-medium">{session.agentName}</p>
|
|
||||||
<p>{session.messages.length} mensagens</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center">
|
|
||||||
<Loader2 className="size-8 animate-spin text-slate-400" />
|
|
||||||
<p className="mt-2 text-sm text-slate-500">Carregando...</p>
|
|
||||||
</div>
|
|
||||||
) : 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-3">
|
|
||||||
{messages.map((msg) => (
|
|
||||||
<div
|
|
||||||
key={msg.id}
|
|
||||||
className={`flex ${msg.isFromMachine ? "justify-end" : "justify-start"}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`max-w-[80%] rounded-2xl px-4 py-2 ${
|
|
||||||
msg.isFromMachine
|
|
||||||
? "bg-black text-white"
|
|
||||||
: "bg-slate-100 text-slate-900"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{!msg.isFromMachine && (
|
|
||||||
<p className="mb-1 text-xs font-medium text-slate-500">
|
|
||||||
{msg.authorName}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
|
|
||||||
<p
|
|
||||||
className={`mt-1 text-right text-xs ${
|
|
||||||
msg.isFromMachine ? "text-white/60" : "text-slate-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{formatTime(msg.createdAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input */}
|
|
||||||
<div className="border-t border-slate-200 p-3 rounded-b-2xl">
|
|
||||||
<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-[40px] flex-1 resize-none rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-1 focus:ring-black"
|
|
||||||
rows={1}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={!inputValue.trim() || isSending}
|
|
||||||
className="flex size-10 items-center justify-center rounded-lg bg-black text-white transition hover:bg-black/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{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",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -11,7 +11,6 @@ import { cn } from "./lib/utils"
|
||||||
import { ChatApp } from "./chat"
|
import { ChatApp } from "./chat"
|
||||||
import { DeactivationScreen } from "./components/DeactivationScreen"
|
import { DeactivationScreen } from "./components/DeactivationScreen"
|
||||||
import type { SessionStartedEvent, UnreadUpdateEvent, NewMessageEvent, SessionEndedEvent } from "./chat/types"
|
import type { SessionStartedEvent, UnreadUpdateEvent, NewMessageEvent, SessionEndedEvent } from "./chat/types"
|
||||||
import { subscribeMachineUpdates } from "./chat/convexMachineClient"
|
|
||||||
|
|
||||||
type MachineOs = {
|
type MachineOs = {
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -1084,7 +1083,8 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
}
|
}
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
// Assinatura direta no Convex para abrir/minimizar chat quando houver novas mensagens
|
/* Assinatura direta no Convex para abrir/minimizar chat quando houver novas mensagens
|
||||||
|
* (desativada: o Rust ja gerencia realtime via WS e eventos Tauri)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
|
|
||||||
|
|
@ -1131,6 +1131,7 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
unsub?.()
|
unsub?.()
|
||||||
}
|
}
|
||||||
}, [token, attemptSelfHeal])
|
}, [token, attemptSelfHeal])
|
||||||
|
*/
|
||||||
|
|
||||||
async function register() {
|
async function register() {
|
||||||
if (!profile) return
|
if (!profile) return
|
||||||
|
|
|
||||||
77
src/app/api/machines/chat/read/route.ts
Normal file
77
src/app/api/machines/chat/read/route.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||||
|
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||||
|
import { checkRateLimit, RATE_LIMITS, rateLimitHeaders } from "@/server/rate-limit"
|
||||||
|
|
||||||
|
const readSchema = z.object({
|
||||||
|
machineToken: z.string().min(1),
|
||||||
|
ticketId: z.string().min(1),
|
||||||
|
messageIds: z.array(z.string().min(1)).min(1).max(50),
|
||||||
|
})
|
||||||
|
|
||||||
|
const CORS_METHODS = "POST, OPTIONS"
|
||||||
|
|
||||||
|
export async function OPTIONS(request: Request) {
|
||||||
|
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/machines/chat/read
|
||||||
|
// Marca mensagens do chat como lidas pela maquina (zera unreadByMachine na sessao ativa)
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const origin = request.headers.get("origin")
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = createConvexClient()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ConvexConfigurationError) {
|
||||||
|
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload
|
||||||
|
try {
|
||||||
|
const raw = await request.json()
|
||||||
|
payload = readSchema.parse(raw)
|
||||||
|
} catch (error) {
|
||||||
|
return jsonWithCors(
|
||||||
|
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
|
||||||
|
400,
|
||||||
|
origin,
|
||||||
|
CORS_METHODS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimit = checkRateLimit(
|
||||||
|
`chat-read:${payload.machineToken}`,
|
||||||
|
RATE_LIMITS.CHAT_MESSAGES.maxRequests,
|
||||||
|
RATE_LIMITS.CHAT_MESSAGES.windowMs
|
||||||
|
)
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return jsonWithCors(
|
||||||
|
{ error: "Rate limit exceeded", retryAfterMs: rateLimit.retryAfterMs },
|
||||||
|
429,
|
||||||
|
origin,
|
||||||
|
CORS_METHODS,
|
||||||
|
rateLimitHeaders(rateLimit)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.mutation(api.liveChat.markMachineMessagesRead, {
|
||||||
|
machineToken: payload.machineToken,
|
||||||
|
ticketId: payload.ticketId as Id<"tickets">,
|
||||||
|
messageIds: payload.messageIds as unknown as Id<"ticketChatMessages">[],
|
||||||
|
})
|
||||||
|
return jsonWithCors(result, 200, origin, CORS_METHODS, rateLimitHeaders(rateLimit))
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[machines.chat.read] Falha ao marcar mensagens como lidas", error)
|
||||||
|
const details = error instanceof Error ? error.message : String(error)
|
||||||
|
return jsonWithCors({ error: "Falha ao marcar mensagens como lidas", details }, 500, origin, CORS_METHODS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -382,17 +382,24 @@ export function ChatWidget() {
|
||||||
|
|
||||||
// Se aumentou o número de sessões APOS a montagem inicial, é uma nova sessão - abrir o widget expandido
|
// Se aumentou o número de sessões APOS a montagem inicial, é uma nova sessão - abrir o widget expandido
|
||||||
if (currentCount > prevCount && hasRestoredStateRef.current) {
|
if (currentCount > prevCount && hasRestoredStateRef.current) {
|
||||||
setIsOpen(true)
|
// O estado do widget e definido com base nas nao lidas.
|
||||||
setIsMinimized(false)
|
|
||||||
// Selecionar a sessão mais recente (última da lista ou primeira se única)
|
// Selecionar a sessão mais recente (última da lista ou primeira se única)
|
||||||
const newestSession = activeSessions[activeSessions.length - 1] ?? activeSessions[0]
|
const newestSession = activeSessions[activeSessions.length - 1] ?? activeSessions[0]
|
||||||
|
const hasUnreadForAgent = (newestSession?.unreadCount ?? 0) > 0
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
setIsOpen(true)
|
||||||
|
setIsMinimized(!hasUnreadForAgent)
|
||||||
|
} else if (isMinimized && hasUnreadForAgent) {
|
||||||
|
setIsMinimized(false)
|
||||||
|
}
|
||||||
if (newestSession) {
|
if (newestSession) {
|
||||||
setActiveTicketId(newestSession.ticketId)
|
setActiveTicketId(newestSession.ticketId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prevSessionCountRef.current = currentCount
|
prevSessionCountRef.current = currentCount
|
||||||
}, [activeSessions])
|
}, [activeSessions, isOpen, isMinimized])
|
||||||
|
|
||||||
// Scroll para última mensagem
|
// Scroll para última mensagem
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue