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
|
|
@ -21,6 +21,7 @@
|
||||||
"@tauri-apps/plugin-process": "^2",
|
"@tauri-apps/plugin-process": "^2",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
"@tauri-apps/plugin-store": "^2",
|
||||||
"@tauri-apps/plugin-updater": "^2",
|
"@tauri-apps/plugin-updater": "^2",
|
||||||
|
"convex": "^1.31.0",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,26 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
/**
|
||||||
|
* ChatHubWidget - Lista de sessoes de chat ativas usando Convex subscriptions
|
||||||
|
*
|
||||||
|
* Arquitetura:
|
||||||
|
* - Usa useQuery do Convex React para subscription reativa (tempo real verdadeiro)
|
||||||
|
* - Sem polling - todas as atualizacoes sao push-based via WebSocket
|
||||||
|
* - Tauri usado apenas para gerenciamento de janelas
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import { listen } from "@tauri-apps/api/event"
|
import { Loader2, MessageCircle, ChevronUp, X, Minimize2 } from "lucide-react"
|
||||||
import { Loader2, MessageCircle, ChevronUp } from "lucide-react"
|
import { useMachineSessions, type MachineSession } from "./useConvexMachineQueries"
|
||||||
import { ChatSessionList } from "./ChatSessionList"
|
|
||||||
import type { ChatSession, NewMessageEvent, SessionStartedEvent, SessionEndedEvent, UnreadUpdateEvent } from "./types"
|
|
||||||
import { getMachineStoreConfig } from "./machineStore"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hub Widget - Lista todas as sessoes de chat ativas
|
* Hub Widget - Lista todas as sessoes de chat ativas
|
||||||
* Ao clicar em uma sessao, abre/foca a janela de chat daquele ticket
|
* Ao clicar em uma sessao, abre/foca a janela de chat daquele ticket
|
||||||
*/
|
*/
|
||||||
export function ChatHubWidget() {
|
export function ChatHubWidget() {
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>([])
|
const [isMinimized, setIsMinimized] = useState(true)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [isMinimized, setIsMinimized] = useState(true) // Inicia minimizado
|
|
||||||
|
|
||||||
const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null)
|
// Convex subscription reativa
|
||||||
|
const { sessions = [], isLoading, hasToken } = useMachineSessions()
|
||||||
const ensureConfig = useCallback(async () => {
|
|
||||||
const cfg = configRef.current ?? (await getMachineStoreConfig())
|
|
||||||
configRef.current = cfg
|
|
||||||
return cfg
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Buscar sessoes do backend
|
|
||||||
const loadSessions = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const cfg = await ensureConfig()
|
|
||||||
const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/sessions`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ machineToken: cfg.token }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Falha ao buscar sessoes: ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json() as { sessions: ChatSession[] }
|
|
||||||
setSessions(data.sessions || [])
|
|
||||||
setError(null)
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Erro ao carregar sessoes:", err)
|
|
||||||
setError(err instanceof Error ? err.message : "Erro desconhecido")
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}, [ensureConfig])
|
|
||||||
|
|
||||||
// Carregar sessoes na montagem
|
|
||||||
useEffect(() => {
|
|
||||||
loadSessions()
|
|
||||||
}, [loadSessions])
|
|
||||||
|
|
||||||
// Escutar eventos de atualizacao
|
|
||||||
useEffect(() => {
|
|
||||||
const unlisteners: (() => void)[] = []
|
|
||||||
|
|
||||||
// Quando nova sessao inicia
|
|
||||||
listen<SessionStartedEvent>("raven://chat/session-started", () => {
|
|
||||||
loadSessions()
|
|
||||||
}).then((unlisten) => unlisteners.push(unlisten)).catch((err) => {
|
|
||||||
console.error("Erro ao registrar listener session-started:", err)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Quando sessao encerra
|
|
||||||
listen<SessionEndedEvent>("raven://chat/session-ended", () => {
|
|
||||||
loadSessions()
|
|
||||||
}).then((unlisten) => unlisteners.push(unlisten)).catch((err) => {
|
|
||||||
console.error("Erro ao registrar listener session-ended:", err)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Quando contador de nao lidos muda
|
|
||||||
listen<UnreadUpdateEvent>("raven://chat/unread-update", (event) => {
|
|
||||||
setSessions(event.payload.sessions || [])
|
|
||||||
}).then((unlisten) => unlisteners.push(unlisten)).catch((err) => {
|
|
||||||
console.error("Erro ao registrar listener unread-update:", err)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Quando nova mensagem chega
|
|
||||||
listen<NewMessageEvent>("raven://chat/new-message", (event) => {
|
|
||||||
setSessions(event.payload.sessions || [])
|
|
||||||
}).then((unlisten) => unlisteners.push(unlisten)).catch((err) => {
|
|
||||||
console.error("Erro ao registrar listener new-message:", err)
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlisteners.forEach((unlisten) => unlisten())
|
|
||||||
}
|
|
||||||
}, [loadSessions])
|
|
||||||
|
|
||||||
// Sincronizar estado minimizado com tamanho da janela
|
// Sincronizar estado minimizado com tamanho da janela
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -109,7 +40,6 @@ export function ChatHubWidget() {
|
||||||
|
|
||||||
const handleSelectSession = async (ticketId: string, ticketRef: number) => {
|
const handleSelectSession = async (ticketId: string, ticketRef: number) => {
|
||||||
try {
|
try {
|
||||||
// Tauri 2 espera snake_case nos parametros
|
|
||||||
await invoke("open_chat_window", { ticket_id: ticketId, ticket_ref: ticketRef })
|
await invoke("open_chat_window", { ticket_id: ticketId, ticket_ref: ticketRef })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Erro ao abrir janela de chat:", err)
|
console.error("Erro ao abrir janela de chat:", err)
|
||||||
|
|
@ -128,11 +58,10 @@ export function ChatHubWidget() {
|
||||||
const handleExpand = async () => {
|
const handleExpand = async () => {
|
||||||
try {
|
try {
|
||||||
await invoke("set_hub_minimized", { minimized: false })
|
await invoke("set_hub_minimized", { minimized: false })
|
||||||
// Aguarda a janela redimensionar antes de atualizar o estado
|
|
||||||
setTimeout(() => setIsMinimized(false), 100)
|
setTimeout(() => setIsMinimized(false), 100)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Erro ao expandir hub:", err)
|
console.error("Erro ao expandir hub:", err)
|
||||||
setIsMinimized(false) // Fallback
|
setIsMinimized(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,6 +73,17 @@ export function ChatHubWidget() {
|
||||||
|
|
||||||
const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0)
|
const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0)
|
||||||
|
|
||||||
|
// Sem token
|
||||||
|
if (!hasToken) {
|
||||||
|
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-red-100 px-4 py-2 text-red-600 shadow-lg">
|
||||||
|
<span className="text-sm font-medium">Token nao configurado</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Loading
|
// Loading
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -156,26 +96,7 @@ export function ChatHubWidget() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erro
|
// Sem sessoes ativas
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setIsLoading(true)
|
|
||||||
loadSessions()
|
|
||||||
}}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<span className="text-sm font-medium">Erro - Tentar novamente</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sem sessoes ativas - mostrar chip cinza
|
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||||
|
|
@ -187,7 +108,7 @@ export function ChatHubWidget() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimizado - mostrar chip com contador
|
// Minimizado
|
||||||
if (isMinimized) {
|
if (isMinimized) {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
|
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
|
||||||
|
|
@ -211,15 +132,114 @@ export function ChatHubWidget() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expandido - mostrar lista
|
// Expandido
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
|
<div className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
|
||||||
<ChatSessionList
|
{/* Header */}
|
||||||
sessions={sessions}
|
<div
|
||||||
onSelectSession={handleSelectSession}
|
data-tauri-drag-region
|
||||||
onClose={handleClose}
|
className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3 rounded-t-2xl"
|
||||||
onMinimize={handleMinimize}
|
>
|
||||||
|
<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>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">Chats Ativos</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{sessions.length} conversa{sessions.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleMinimize}
|
||||||
|
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
||||||
|
title="Minimizar"
|
||||||
|
>
|
||||||
|
<Minimize2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
||||||
|
title="Fechar"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de sessoes */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<SessionItem
|
||||||
|
key={session.sessionId}
|
||||||
|
session={session}
|
||||||
|
onClick={() => handleSelectSession(session.ticketId, session.ticketRef)}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SessionItem({
|
||||||
|
session,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
session: MachineSession
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl p-3 text-left transition hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="relative flex size-10 shrink-0 items-center justify-center rounded-full bg-black text-white">
|
||||||
|
<MessageCircle className="size-5" />
|
||||||
|
{/* Indicador online */}
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-white bg-emerald-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="truncate text-sm font-medium text-slate-900">
|
||||||
|
Ticket #{session.ticketRef}
|
||||||
|
</p>
|
||||||
|
<span className="shrink-0 text-xs text-slate-400">
|
||||||
|
{formatRelativeTime(session.lastActivityAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-xs text-slate-500">
|
||||||
|
{session.agentName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badge nao lidas */}
|
||||||
|
{session.unreadCount > 0 && (
|
||||||
|
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
|
||||||
|
{session.unreadCount > 9 ? "9+" : session.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(timestamp: number): string {
|
||||||
|
const now = Date.now()
|
||||||
|
const diff = now - timestamp
|
||||||
|
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
if (minutes < 1) return "agora"
|
||||||
|
if (minutes < 60) return `${minutes}m`
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}h`
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return `${days}d`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
import { MessageCircle } from "lucide-react"
|
|
||||||
import type { ChatSession } from "./types"
|
|
||||||
|
|
||||||
type ChatSessionItemProps = {
|
|
||||||
session: ChatSession
|
|
||||||
isActive?: boolean
|
|
||||||
onClick: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(timestamp: number) {
|
|
||||||
const now = Date.now()
|
|
||||||
const diff = now - timestamp
|
|
||||||
const minutes = Math.floor(diff / 60000)
|
|
||||||
const hours = Math.floor(diff / 3600000)
|
|
||||||
const days = Math.floor(diff / 86400000)
|
|
||||||
|
|
||||||
if (minutes < 1) return "Agora"
|
|
||||||
if (minutes < 60) return `${minutes}min`
|
|
||||||
if (hours < 24) return `${hours}h`
|
|
||||||
if (days === 1) return "Ontem"
|
|
||||||
|
|
||||||
return new Date(timestamp).toLocaleDateString("pt-BR", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatSessionItem({ session, isActive, onClick }: ChatSessionItemProps) {
|
|
||||||
const hasUnread = session.unreadCount > 0
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
onClick()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClick}
|
|
||||||
className={`flex w-full items-start gap-3 border-b border-slate-100 px-4 py-3 text-left transition-colors hover:bg-slate-50 ${
|
|
||||||
isActive ? "bg-slate-100" : ""
|
|
||||||
} ${hasUnread ? "bg-red-50/50 hover:bg-red-50" : ""}`}
|
|
||||||
>
|
|
||||||
{/* Avatar/Icone */}
|
|
||||||
<div
|
|
||||||
className={`flex size-10 shrink-0 items-center justify-center rounded-full ${
|
|
||||||
hasUnread ? "bg-red-100 text-red-600" : "bg-slate-100 text-slate-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<MessageCircle className="size-5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Conteudo */}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`text-sm font-semibold ${hasUnread ? "text-red-700" : "text-slate-900"}`}>
|
|
||||||
#{session.ticketRef}
|
|
||||||
</span>
|
|
||||||
{/* Indicador online - sessao ativa significa online */}
|
|
||||||
<span className="size-2 rounded-full bg-emerald-500" title="Online" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-slate-400">
|
|
||||||
{formatTime(session.lastActivityAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-0.5 truncate text-sm text-slate-600">
|
|
||||||
{session.ticketSubject}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="mt-0.5 truncate text-xs text-slate-400">
|
|
||||||
{session.agentName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Badge de nao lidos */}
|
|
||||||
{hasUnread && (
|
|
||||||
<div className="flex shrink-0 items-center justify-center">
|
|
||||||
<span className="flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
|
|
||||||
{session.unreadCount > 9 ? "9+" : session.unreadCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
import { useMemo } from "react"
|
|
||||||
import { MessageCircle, X } from "lucide-react"
|
|
||||||
import { ChatSessionItem } from "./ChatSessionItem"
|
|
||||||
import type { ChatSession } from "./types"
|
|
||||||
|
|
||||||
type ChatSessionListProps = {
|
|
||||||
sessions: ChatSession[]
|
|
||||||
onSelectSession: (ticketId: string, ticketRef: number) => void
|
|
||||||
onClose: () => void
|
|
||||||
onMinimize: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatSessionList({
|
|
||||||
sessions,
|
|
||||||
onSelectSession,
|
|
||||||
onClose,
|
|
||||||
onMinimize,
|
|
||||||
}: ChatSessionListProps) {
|
|
||||||
// Ordenar: nao lidos primeiro, depois por ultima atividade (desc)
|
|
||||||
const sortedSessions = useMemo(() => {
|
|
||||||
return [...sessions].sort((a, b) => {
|
|
||||||
// Nao lidos primeiro
|
|
||||||
if (a.unreadCount > 0 && b.unreadCount === 0) return -1
|
|
||||||
if (a.unreadCount === 0 && b.unreadCount > 0) return 1
|
|
||||||
// Depois por ultima atividade (mais recente primeiro)
|
|
||||||
return b.lastActivityAt - a.lastActivityAt
|
|
||||||
})
|
|
||||||
}, [sessions])
|
|
||||||
|
|
||||||
const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
{/* Header - arrastavel */}
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="flex items-center justify-between rounded-t-2xl border-b border-slate-200 bg-slate-50 px-4 py-3"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<p className="text-sm font-semibold text-slate-900">Chats</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{sessions.length} conversa{sessions.length !== 1 ? "s" : ""} ativa{sessions.length !== 1 ? "s" : ""}
|
|
||||||
{totalUnread > 0 && (
|
|
||||||
<span className="ml-1 text-red-500">
|
|
||||||
({totalUnread} não lida{totalUnread !== 1 ? "s" : ""})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={onMinimize}
|
|
||||||
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
|
||||||
title="Minimizar"
|
|
||||||
>
|
|
||||||
<svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
|
||||||
title="Fechar"
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lista de sessoes */}
|
|
||||||
<div className="flex-1 overflow-y-auto bg-white">
|
|
||||||
{sortedSessions.length === 0 ? (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center p-4 text-center">
|
|
||||||
<div className="flex size-12 items-center justify-center rounded-full bg-slate-100">
|
|
||||||
<MessageCircle className="size-6 text-slate-400" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm font-medium text-slate-600">Nenhum chat ativo</p>
|
|
||||||
<p className="mt-1 text-xs text-slate-400">
|
|
||||||
Os chats aparecerao aqui quando iniciados
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
sortedSessions.map((session) => (
|
|
||||||
<ChatSessionItem
|
|
||||||
key={session.ticketId}
|
|
||||||
session={session}
|
|
||||||
onClick={() => onSelectSession(session.ticketId, session.ticketRef)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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 { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { open as openDialog } from "@tauri-apps/plugin-dialog"
|
import { open as openDialog } from "@tauri-apps/plugin-dialog"
|
||||||
import { openUrl as openExternal } from "@tauri-apps/plugin-opener"
|
import { openUrl as openExternal } from "@tauri-apps/plugin-opener"
|
||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import { listen } from "@tauri-apps/api/event"
|
|
||||||
import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check } from "lucide-react"
|
import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check } from "lucide-react"
|
||||||
import type {
|
import type { Id } from "@convex/_generated/dataModel"
|
||||||
ChatAttachment,
|
import { useMachineMessages, usePostMachineMessage, useMarkMachineMessagesRead, type MachineMessage } from "./useConvexMachineQueries"
|
||||||
ChatMessage,
|
import { useConvexMachine } from "./ConvexMachineProvider"
|
||||||
ChatMessagesResponse,
|
|
||||||
NewMessageEvent,
|
|
||||||
SessionEndedEvent,
|
|
||||||
SessionStartedEvent,
|
|
||||||
UnreadUpdateEvent,
|
|
||||||
} from "./types"
|
|
||||||
import { getMachineStoreConfig } from "./machineStore"
|
|
||||||
|
|
||||||
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 MARK_READ_BATCH_SIZE = 50
|
||||||
const SCROLL_BOTTOM_THRESHOLD_PX = 120
|
const SCROLL_BOTTOM_THRESHOLD_PX = 120
|
||||||
|
|
||||||
// Tipos de arquivo permitidos
|
|
||||||
const ALLOWED_EXTENSIONS = [
|
const ALLOWED_EXTENSIONS = [
|
||||||
"jpg", "jpeg", "png", "gif", "webp",
|
"jpg", "jpeg", "png", "gif", "webp",
|
||||||
"pdf", "txt", "doc", "docx", "xls", "xlsx",
|
"pdf", "txt", "doc", "docx", "xls", "xlsx",
|
||||||
|
|
@ -32,6 +33,13 @@ interface UploadedAttachment {
|
||||||
type?: string
|
type?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChatAttachment {
|
||||||
|
storageId: string
|
||||||
|
name: string
|
||||||
|
size?: number
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
function getFileIcon(fileName: string) {
|
function getFileIcon(fileName: string) {
|
||||||
const ext = fileName.toLowerCase().split(".").pop() ?? ""
|
const ext = fileName.toLowerCase().split(".").pop() ?? ""
|
||||||
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
|
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
|
||||||
|
|
@ -57,7 +65,7 @@ function formatAttachmentSize(size?: number) {
|
||||||
return `${(kb / 1024).toFixed(1)}MB`
|
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 []
|
if (unreadCount <= 0 || messages.length === 0) return []
|
||||||
const ids: string[] = []
|
const ids: string[] = []
|
||||||
for (let i = messages.length - 1; i >= 0 && ids.length < unreadCount; i--) {
|
for (let i = messages.length - 1; i >= 0 && ids.length < unreadCount; i--) {
|
||||||
|
|
@ -138,7 +146,6 @@ function MessageAttachment({
|
||||||
setTimeout(() => setDownloaded(false), 2000)
|
setTimeout(() => setDownloaded(false), 2000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Falha ao baixar anexo:", err)
|
console.error("Falha ao baixar anexo:", err)
|
||||||
// Fallback: abrir no navegador/sistema
|
|
||||||
await handleView()
|
await handleView()
|
||||||
} finally {
|
} finally {
|
||||||
setDownloading(false)
|
setDownloading(false)
|
||||||
|
|
@ -160,7 +167,7 @@ function MessageAttachment({
|
||||||
if (isImage && url) {
|
if (isImage && url) {
|
||||||
return (
|
return (
|
||||||
<div className={`group relative overflow-hidden rounded-lg border ${isAgent ? "border-white/10" : "border-slate-200"}`}>
|
<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
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={attachment.name}
|
alt={attachment.name}
|
||||||
|
|
@ -234,24 +241,28 @@ interface ChatWidgetProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
||||||
const [inputValue, setInputValue] = useState("")
|
const [inputValue, setInputValue] = useState("")
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
const [isSending, setIsSending] = useState(false)
|
const [isSending, setIsSending] = useState(false)
|
||||||
const [isUploading, setIsUploading] = 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[]>([])
|
const [pendingAttachments, setPendingAttachments] = useState<UploadedAttachment[]>([])
|
||||||
// Inicializa minimizado porque o Rust abre a janela e minimiza imediatamente
|
|
||||||
const [isMinimized, setIsMinimized] = useState(true)
|
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 messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const messageElementsRef = useRef<Map<string, HTMLDivElement>>(new Map())
|
const messageElementsRef = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||||
const prevHasSessionRef = useRef<boolean>(false)
|
const prevHasSessionRef = useRef<boolean>(false)
|
||||||
const retryDelayMsRef = useRef<number>(1_000)
|
|
||||||
|
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true)
|
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||||
const isAtBottomRef = useRef(true)
|
const isAtBottomRef = useRef(true)
|
||||||
|
|
@ -288,43 +299,39 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
return true
|
return true
|
||||||
}, [updateIsAtBottom])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const prevHasSession = prevHasSessionRef.current
|
const prevHasSession = prevHasSessionRef.current
|
||||||
if (prevHasSession && !hasSession) {
|
if (prevHasSession && !hasSession) {
|
||||||
invoke("close_chat_window", { ticket_id: ticketId }).catch((err) => {
|
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
|
prevHasSessionRef.current = hasSession
|
||||||
}, [hasSession, ticketId])
|
}, [hasSession, ticketId])
|
||||||
|
|
||||||
// Ref para acessar isMinimized dentro do callback sem causar resubscription
|
// Ref para acessar isMinimized dentro de callbacks
|
||||||
const isMinimizedRef = useRef(isMinimized)
|
const isMinimizedRef = useRef(isMinimized)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMinimizedRef.current = isMinimized
|
isMinimizedRef.current = isMinimized
|
||||||
}, [isMinimized])
|
}, [isMinimized])
|
||||||
|
|
||||||
const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null)
|
// Cache de URLs de anexos
|
||||||
|
|
||||||
const ensureConfig = useCallback(async () => {
|
|
||||||
const cfg = configRef.current ?? (await getMachineStoreConfig())
|
|
||||||
configRef.current = cfg
|
|
||||||
return cfg
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const attachmentUrlCacheRef = useRef<Map<string, string>>(new Map())
|
const attachmentUrlCacheRef = useRef<Map<string, string>>(new Map())
|
||||||
|
|
||||||
const loadAttachmentUrl = useCallback(async (storageId: string) => {
|
const loadAttachmentUrl = useCallback(async (storageId: string) => {
|
||||||
const cached = attachmentUrlCacheRef.current.get(storageId)
|
const cached = attachmentUrlCacheRef.current.get(storageId)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
const cfg = await ensureConfig()
|
if (!apiBaseUrl || !machineToken) {
|
||||||
const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/attachments/url`, {
|
throw new Error("Configuracao nao disponivel")
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${apiBaseUrl}/api/machines/chat/attachments/url`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
machineToken: cfg.token,
|
machineToken,
|
||||||
ticketId,
|
ticketId,
|
||||||
storageId,
|
storageId,
|
||||||
}),
|
}),
|
||||||
|
|
@ -342,148 +349,32 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
|
|
||||||
attachmentUrlCacheRef.current.set(storageId, data.url)
|
attachmentUrlCacheRef.current.set(storageId, data.url)
|
||||||
return data.url
|
return data.url
|
||||||
}, [ensureConfig, ticketId])
|
}, [apiBaseUrl, machineToken, 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])
|
|
||||||
|
|
||||||
const markUnreadMessagesRead = useCallback(async () => {
|
const markUnreadMessagesRead = useCallback(async () => {
|
||||||
if (unreadCount <= 0) return
|
if (unreadCount <= 0) return
|
||||||
const ids = getUnreadAgentMessageIds(messages, unreadCount)
|
const ids = getUnreadAgentMessageIds(messages, unreadCount)
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
|
|
||||||
const cfg = await ensureConfig()
|
|
||||||
const chunks = chunkArray(ids, MARK_READ_BATCH_SIZE)
|
const chunks = chunkArray(ids, MARK_READ_BATCH_SIZE)
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
await invoke("mark_chat_messages_read", {
|
await markMessagesRead({
|
||||||
base_url: cfg.apiBaseUrl,
|
ticketId: ticketId as Id<"tickets">,
|
||||||
token: cfg.token,
|
messageIds: chunk as Id<"ticketChatMessages">[],
|
||||||
ticket_id: ticketId,
|
|
||||||
message_ids: chunk,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}, [messages, ticketId, unreadCount, markMessagesRead])
|
||||||
|
|
||||||
setUnreadCount(0)
|
// Auto-scroll quando novas mensagens chegam (se ja estava no bottom)
|
||||||
}, [ensureConfig, messages, ticketId, unreadCount])
|
const prevMessagesLengthRef = useRef(messages.length)
|
||||||
|
|
||||||
// Carregar mensagens na montagem / troca de ticket
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true)
|
if (messages.length > prevMessagesLengthRef.current && isAtBottomRef.current && !isMinimizedRef.current) {
|
||||||
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 }
|
pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: true }
|
||||||
}
|
}
|
||||||
loadMessages()
|
prevMessagesLengthRef.current = messages.length
|
||||||
}
|
}, [messages.length])
|
||||||
})
|
|
||||||
.then((u) => {
|
|
||||||
unlisten = u
|
|
||||||
})
|
|
||||||
.catch((err) => console.error("Falha ao registrar listener new-message:", err))
|
|
||||||
|
|
||||||
return () => {
|
// Executar scroll pendente
|
||||||
unlisten?.()
|
|
||||||
}
|
|
||||||
}, [ticketId, loadMessages])
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMinimized) return
|
if (isMinimized) return
|
||||||
|
|
||||||
|
|
@ -517,84 +408,16 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
}
|
}
|
||||||
}, [isMinimized, messages, markUnreadMessagesRead, scrollToBottom, scrollToMessage])
|
}, [isMinimized, messages, markUnreadMessagesRead, scrollToBottom, scrollToMessage])
|
||||||
|
|
||||||
// Recarregar quando a sessao for encerrada (para refletir offline/minimizar corretamente)
|
// Sincronizar estado minimizado com tamanho da janela
|
||||||
useEffect(() => {
|
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 mountTime = Date.now()
|
||||||
const STABILIZATION_DELAY = 500 // ms para a janela estabilizar
|
const STABILIZATION_DELAY = 500
|
||||||
|
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
// Ignorar eventos de resize durante o periodo de estabilizacao
|
|
||||||
if (Date.now() - mountTime < STABILIZATION_DELAY) {
|
if (Date.now() - mountTime < STABILIZATION_DELAY) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const h = window.innerHeight
|
const h = window.innerHeight
|
||||||
// thresholds alinhados com set_chat_minimized (52px minimizado, 520px expandido)
|
|
||||||
setIsMinimized(h < 100)
|
setIsMinimized(h < 100)
|
||||||
}
|
}
|
||||||
window.addEventListener("resize", handler)
|
window.addEventListener("resize", handler)
|
||||||
|
|
@ -616,16 +439,17 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
|
|
||||||
if (!selected) return
|
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
|
const filePath = typeof selected === "string" ? selected : (selected as { path: string }).path
|
||||||
|
|
||||||
setIsUploading(true)
|
setIsUploading(true)
|
||||||
|
|
||||||
const config = await getMachineStoreConfig()
|
if (!apiBaseUrl || !machineToken) {
|
||||||
|
throw new Error("Configuracao nao disponivel")
|
||||||
|
}
|
||||||
|
|
||||||
const attachment = await invoke<UploadedAttachment>("upload_chat_file", {
|
const attachment = await invoke<UploadedAttachment>("upload_chat_file", {
|
||||||
baseUrl: config.apiBaseUrl,
|
baseUrl: apiBaseUrl,
|
||||||
token: config.token,
|
token: machineToken,
|
||||||
filePath,
|
filePath,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -654,34 +478,19 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
setIsSending(true)
|
setIsSending(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bodyToSend = messageText
|
await postMessage({
|
||||||
const cfg = await ensureConfig()
|
ticketId: ticketId as Id<"tickets">,
|
||||||
await invoke("send_chat_message", {
|
body: messageText,
|
||||||
base_url: cfg.apiBaseUrl,
|
attachments: attachmentsToSend.length > 0 ? attachmentsToSend.map(a => ({
|
||||||
token: cfg.token,
|
storageId: a.storageId as Id<"_storage">,
|
||||||
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,
|
|
||||||
name: a.name,
|
name: a.name,
|
||||||
size: a.size,
|
size: a.size,
|
||||||
type: a.type,
|
type: a.type,
|
||||||
})),
|
})) : undefined,
|
||||||
}])
|
})
|
||||||
pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: false }
|
pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: false }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Erro ao enviar mensagem:", err)
|
console.error("Erro ao enviar mensagem:", err)
|
||||||
// Restaurar input e anexos em caso de erro
|
|
||||||
setInputValue(messageText)
|
setInputValue(messageText)
|
||||||
setPendingAttachments(attachmentsToSend)
|
setPendingAttachments(attachmentsToSend)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -726,9 +535,8 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loading
|
||||||
if (isLoading) {
|
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 (
|
return (
|
||||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
<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">
|
<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) {
|
// Sem sessao ativa
|
||||||
// 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
|
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent">
|
<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">
|
<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" />
|
<MessageCircle className="size-4" />
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{ticketRef ? `Ticket #${ticketRef}` : ticketInfo?.ref ? `Ticket #${ticketInfo.ref}` : "Chat"}
|
{ticketRef ? `Ticket #${ticketRef}` : "Chat"}
|
||||||
</span>
|
</span>
|
||||||
<span className="size-2 rounded-full bg-slate-400" />
|
<span className="size-2 rounded-full bg-slate-400" />
|
||||||
<span className="text-xs text-slate-500">Offline</span>
|
<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)
|
// Minimizado
|
||||||
// pointer-events-none no container para que apenas o botao seja clicavel
|
|
||||||
if (isMinimized) {
|
if (isMinimized) {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
|
<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" />
|
<MessageCircle className="size-4" />
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
Ticket #{ticketRef ?? ticketInfo?.ref}
|
Ticket #{ticketRef}
|
||||||
</span>
|
</span>
|
||||||
<span className="size-2 rounded-full bg-emerald-400" />
|
<span className="size-2 rounded-full bg-emerald-400" />
|
||||||
<ChevronUp className="size-4" />
|
<ChevronUp className="size-4" />
|
||||||
{/* Badge de mensagens não lidas */}
|
|
||||||
{unreadCount > 0 && (
|
{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">
|
<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}
|
{unreadCount > 9 ? "9+" : unreadCount}
|
||||||
|
|
@ -805,9 +594,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expandido
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
|
<div className="flex h-screen flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
|
||||||
{/* Header - arrastavel */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
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"
|
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
|
Online
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(ticketRef || ticketInfo) && (
|
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
Ticket #{ticketRef ?? ticketInfo?.ref} - {ticketInfo?.agentName ?? "Suporte"}
|
Ticket #{ticketRef} - Suporte
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|
@ -861,14 +649,12 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
Nenhuma mensagem ainda
|
Nenhuma mensagem ainda
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-slate-400">
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
O agente iniciará a conversa em breve
|
O agente iniciara a conversa em breve
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{messages.map((msg) => {
|
{messages.map((msg) => {
|
||||||
// No desktop: isFromMachine=true significa mensagem do cliente (maquina)
|
|
||||||
// Layout igual à web: cliente à esquerda, agente à direita
|
|
||||||
const isAgent = !msg.isFromMachine
|
const isAgent = !msg.isFromMachine
|
||||||
const bodyText = msg.body.trim()
|
const bodyText = msg.body.trim()
|
||||||
const shouldShowBody =
|
const shouldShowBody =
|
||||||
|
|
@ -922,7 +708,12 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
{msg.attachments.map((att) => (
|
{msg.attachments.map((att) => (
|
||||||
<MessageAttachment
|
<MessageAttachment
|
||||||
key={att.storageId}
|
key={att.storageId}
|
||||||
attachment={att}
|
attachment={{
|
||||||
|
storageId: att.storageId as string,
|
||||||
|
name: att.name,
|
||||||
|
size: att.size,
|
||||||
|
type: att.type,
|
||||||
|
}}
|
||||||
isAgent={isAgent}
|
isAgent={isAgent}
|
||||||
loadUrl={loadAttachmentUrl}
|
loadUrl={loadAttachmentUrl}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
146
apps/desktop/src/chat/ConvexMachineProvider.tsx
Normal file
146
apps/desktop/src/chat/ConvexMachineProvider.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
/**
|
||||||
|
* ConvexMachineProvider - Provider Convex para autenticacao via token de maquina
|
||||||
|
*
|
||||||
|
* Este provider inicializa o ConvexReactClient usando o token da maquina
|
||||||
|
* armazenado no Tauri Store, permitindo subscriptions reativas em tempo real.
|
||||||
|
*
|
||||||
|
* Arquitetura:
|
||||||
|
* - Carrega o token do Tauri Store na montagem
|
||||||
|
* - Inicializa o ConvexReactClient com a URL do Convex
|
||||||
|
* - Disponibiliza o cliente para componentes filhos via Context
|
||||||
|
* - Reconecta automaticamente quando o token muda
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
|
||||||
|
import { ConvexReactClient } from "convex/react"
|
||||||
|
import { getMachineStoreConfig } from "./machineStore"
|
||||||
|
|
||||||
|
// URL do Convex - em producao, usa o dominio personalizado
|
||||||
|
const CONVEX_URL = import.meta.env.MODE === "production"
|
||||||
|
? "https://convex.esdrasrenan.com.br"
|
||||||
|
: (import.meta.env.VITE_CONVEX_URL ?? "https://convex.esdrasrenan.com.br")
|
||||||
|
|
||||||
|
type MachineAuthState = {
|
||||||
|
token: string | null
|
||||||
|
apiBaseUrl: string | null
|
||||||
|
isLoading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConvexMachineContextValue = {
|
||||||
|
client: ConvexReactClient | null
|
||||||
|
machineToken: string | null
|
||||||
|
apiBaseUrl: string | null
|
||||||
|
isReady: boolean
|
||||||
|
error: string | null
|
||||||
|
reload: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConvexMachineContext = createContext<ConvexMachineContextValue | null>(null)
|
||||||
|
|
||||||
|
export function useConvexMachine() {
|
||||||
|
const ctx = useContext(ConvexMachineContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useConvexMachine must be used within ConvexMachineProvider")
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMachineToken() {
|
||||||
|
const { machineToken } = useConvexMachine()
|
||||||
|
return machineToken
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConvexMachineProviderProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConvexMachineProvider({ children }: ConvexMachineProviderProps) {
|
||||||
|
const [authState, setAuthState] = useState<MachineAuthState>({
|
||||||
|
token: null,
|
||||||
|
apiBaseUrl: null,
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [client, setClient] = useState<ConvexReactClient | null>(null)
|
||||||
|
|
||||||
|
// Funcao para carregar configuracao do Tauri Store
|
||||||
|
const loadConfig = async () => {
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getMachineStoreConfig()
|
||||||
|
|
||||||
|
if (!config.token) {
|
||||||
|
setAuthState({
|
||||||
|
token: null,
|
||||||
|
apiBaseUrl: config.apiBaseUrl,
|
||||||
|
isLoading: false,
|
||||||
|
error: "Token da maquina nao encontrado",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthState({
|
||||||
|
token: config.token,
|
||||||
|
apiBaseUrl: config.apiBaseUrl,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
setAuthState({
|
||||||
|
token: null,
|
||||||
|
apiBaseUrl: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: message || "Erro ao carregar configuracao",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carregar configuracao na montagem
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Inicializar/reinicializar cliente Convex quando token muda
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authState.token) {
|
||||||
|
// Limpar cliente se nao tem token
|
||||||
|
if (client) {
|
||||||
|
client.close()
|
||||||
|
setClient(null)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar novo cliente Convex
|
||||||
|
const newClient = new ConvexReactClient(CONVEX_URL, {
|
||||||
|
// Desabilitar retry agressivo para evitar loops infinitos
|
||||||
|
unsavedChangesWarning: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
setClient(newClient)
|
||||||
|
|
||||||
|
// Cleanup ao desmontar ou trocar token
|
||||||
|
return () => {
|
||||||
|
newClient.close()
|
||||||
|
}
|
||||||
|
}, [authState.token]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const contextValue: ConvexMachineContextValue = {
|
||||||
|
client,
|
||||||
|
machineToken: authState.token,
|
||||||
|
apiBaseUrl: authState.apiBaseUrl,
|
||||||
|
isReady: !authState.isLoading && !!client && !!authState.token,
|
||||||
|
error: authState.error,
|
||||||
|
reload: loadConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConvexMachineContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</ConvexMachineContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,63 @@
|
||||||
|
import { ConvexProvider } from "convex/react"
|
||||||
import { ChatWidget } from "./ChatWidget"
|
import { ChatWidget } from "./ChatWidget"
|
||||||
import { ChatHubWidget } from "./ChatHubWidget"
|
import { ChatHubWidget } from "./ChatHubWidget"
|
||||||
|
import { ConvexMachineProvider, useConvexMachine } from "./ConvexMachineProvider"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
function ChatAppContent() {
|
||||||
|
const { client, isReady, error } = useConvexMachine()
|
||||||
|
|
||||||
export function ChatApp() {
|
|
||||||
// Obter ticketId e ticketRef da URL
|
// Obter ticketId e ticketRef da URL
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
const ticketId = params.get("ticketId")
|
const ticketId = params.get("ticketId")
|
||||||
const ticketRef = params.get("ticketRef")
|
const ticketRef = params.get("ticketRef")
|
||||||
const isHub = params.get("hub") === "true"
|
const isHub = params.get("hub") === "true"
|
||||||
|
|
||||||
|
// Aguardar cliente Convex estar pronto
|
||||||
|
if (!isReady || !client) {
|
||||||
|
if (error) {
|
||||||
|
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-red-100 px-4 py-2 text-red-600 shadow-lg">
|
||||||
|
<span className="text-sm font-medium">Erro: {error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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">Conectando...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Modo hub - lista de todas as sessoes
|
// Modo hub - lista de todas as sessoes
|
||||||
if (isHub || !ticketId) {
|
if (isHub || !ticketId) {
|
||||||
return <ChatHubWidget />
|
return (
|
||||||
|
<ConvexProvider client={client}>
|
||||||
|
<ChatHubWidget />
|
||||||
|
</ConvexProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modo chat - conversa de um ticket especifico
|
// Modo chat - conversa de um ticket especifico
|
||||||
return <ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} />
|
return (
|
||||||
|
<ConvexProvider client={client}>
|
||||||
|
<ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} />
|
||||||
|
</ConvexProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatApp() {
|
||||||
|
return (
|
||||||
|
<ConvexMachineProvider>
|
||||||
|
<ChatAppContent />
|
||||||
|
</ConvexMachineProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ChatWidget }
|
export { ChatWidget }
|
||||||
|
|
|
||||||
206
apps/desktop/src/chat/useConvexMachineQueries.ts
Normal file
206
apps/desktop/src/chat/useConvexMachineQueries.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
/**
|
||||||
|
* Hooks customizados para queries/mutations do Convex com token de maquina
|
||||||
|
*
|
||||||
|
* Estes hooks encapsulam a logica de passar o machineToken automaticamente
|
||||||
|
* para as queries e mutations do Convex, proporcionando uma API simples
|
||||||
|
* e reativa para os componentes de chat.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useAction } from "convex/react"
|
||||||
|
import { api } from "@convex/_generated/api"
|
||||||
|
import type { Id } from "@convex/_generated/dataModel"
|
||||||
|
import { useMachineToken } from "./ConvexMachineProvider"
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TIPOS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type MachineSession = {
|
||||||
|
sessionId: Id<"liveChatSessions">
|
||||||
|
ticketId: Id<"tickets">
|
||||||
|
ticketRef: number
|
||||||
|
ticketSubject: string
|
||||||
|
agentName: string
|
||||||
|
agentEmail?: string
|
||||||
|
agentAvatarUrl?: string
|
||||||
|
unreadCount: number
|
||||||
|
lastActivityAt: number
|
||||||
|
startedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MachineMessage = {
|
||||||
|
id: Id<"ticketChatMessages">
|
||||||
|
body: string
|
||||||
|
authorName: string
|
||||||
|
authorAvatarUrl?: string
|
||||||
|
isFromMachine: boolean
|
||||||
|
createdAt: number
|
||||||
|
attachments: Array<{
|
||||||
|
storageId: Id<"_storage">
|
||||||
|
name: string
|
||||||
|
size?: number
|
||||||
|
type?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MachineMessagesResult = {
|
||||||
|
messages: MachineMessage[]
|
||||||
|
hasSession: boolean
|
||||||
|
unreadCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MachineUpdatesResult = {
|
||||||
|
hasActiveSessions: boolean
|
||||||
|
sessions: Array<{
|
||||||
|
ticketId: Id<"tickets">
|
||||||
|
ticketRef: number
|
||||||
|
unreadCount: number
|
||||||
|
lastActivityAt: number
|
||||||
|
}>
|
||||||
|
totalUnread: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HOOKS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para listar sessoes ativas da maquina
|
||||||
|
* Subscription reativa - atualiza automaticamente quando ha mudancas
|
||||||
|
*/
|
||||||
|
export function useMachineSessions() {
|
||||||
|
const machineToken = useMachineToken()
|
||||||
|
|
||||||
|
const sessions = useQuery(
|
||||||
|
api.liveChat.listMachineSessions,
|
||||||
|
machineToken ? { machineToken } : "skip"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: sessions as MachineSession[] | undefined,
|
||||||
|
isLoading: sessions === undefined && !!machineToken,
|
||||||
|
hasToken: !!machineToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para listar mensagens de um ticket especifico
|
||||||
|
* Subscription reativa - atualiza automaticamente quando ha novas mensagens
|
||||||
|
*/
|
||||||
|
export function useMachineMessages(ticketId: Id<"tickets"> | null, options?: { limit?: number }) {
|
||||||
|
const machineToken = useMachineToken()
|
||||||
|
|
||||||
|
const result = useQuery(
|
||||||
|
api.liveChat.listMachineMessages,
|
||||||
|
machineToken && ticketId
|
||||||
|
? { machineToken, ticketId, limit: options?.limit }
|
||||||
|
: "skip"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: (result as MachineMessagesResult | undefined)?.messages ?? [],
|
||||||
|
hasSession: (result as MachineMessagesResult | undefined)?.hasSession ?? false,
|
||||||
|
unreadCount: (result as MachineMessagesResult | undefined)?.unreadCount ?? 0,
|
||||||
|
isLoading: result === undefined && !!machineToken && !!ticketId,
|
||||||
|
hasToken: !!machineToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para verificar updates (polling leve)
|
||||||
|
* Usado como fallback ou para verificar status rapidamente
|
||||||
|
*/
|
||||||
|
export function useMachineUpdates() {
|
||||||
|
const machineToken = useMachineToken()
|
||||||
|
|
||||||
|
const result = useQuery(
|
||||||
|
api.liveChat.checkMachineUpdates,
|
||||||
|
machineToken ? { machineToken } : "skip"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasActiveSessions: (result as MachineUpdatesResult | undefined)?.hasActiveSessions ?? false,
|
||||||
|
sessions: (result as MachineUpdatesResult | undefined)?.sessions ?? [],
|
||||||
|
totalUnread: (result as MachineUpdatesResult | undefined)?.totalUnread ?? 0,
|
||||||
|
isLoading: result === undefined && !!machineToken,
|
||||||
|
hasToken: !!machineToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para enviar mensagem
|
||||||
|
*/
|
||||||
|
export function usePostMachineMessage() {
|
||||||
|
const machineToken = useMachineToken()
|
||||||
|
const postMessage = useMutation(api.liveChat.postMachineMessage)
|
||||||
|
|
||||||
|
return async (args: {
|
||||||
|
ticketId: Id<"tickets">
|
||||||
|
body: string
|
||||||
|
attachments?: Array<{
|
||||||
|
storageId: Id<"_storage">
|
||||||
|
name: string
|
||||||
|
size?: number
|
||||||
|
type?: string
|
||||||
|
}>
|
||||||
|
}) => {
|
||||||
|
if (!machineToken) {
|
||||||
|
throw new Error("Token da maquina nao disponivel")
|
||||||
|
}
|
||||||
|
|
||||||
|
return postMessage({
|
||||||
|
machineToken,
|
||||||
|
ticketId: args.ticketId,
|
||||||
|
body: args.body,
|
||||||
|
attachments: args.attachments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para marcar mensagens como lidas
|
||||||
|
*/
|
||||||
|
export function useMarkMachineMessagesRead() {
|
||||||
|
const machineToken = useMachineToken()
|
||||||
|
const markRead = useMutation(api.liveChat.markMachineMessagesRead)
|
||||||
|
|
||||||
|
return async (args: {
|
||||||
|
ticketId: Id<"tickets">
|
||||||
|
messageIds: Id<"ticketChatMessages">[]
|
||||||
|
}) => {
|
||||||
|
if (!machineToken) {
|
||||||
|
throw new Error("Token da maquina nao disponivel")
|
||||||
|
}
|
||||||
|
|
||||||
|
return markRead({
|
||||||
|
machineToken,
|
||||||
|
ticketId: args.ticketId,
|
||||||
|
messageIds: args.messageIds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para gerar URL de upload
|
||||||
|
*/
|
||||||
|
export function useGenerateMachineUploadUrl() {
|
||||||
|
const machineToken = useMachineToken()
|
||||||
|
const generateUrl = useAction(api.liveChat.generateMachineUploadUrl)
|
||||||
|
|
||||||
|
return async (args: {
|
||||||
|
fileName: string
|
||||||
|
fileType: string
|
||||||
|
fileSize: number
|
||||||
|
}) => {
|
||||||
|
if (!machineToken) {
|
||||||
|
throw new Error("Token da maquina nao disponivel")
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateUrl({
|
||||||
|
machineToken,
|
||||||
|
fileName: args.fileName,
|
||||||
|
fileType: args.fileType,
|
||||||
|
fileSize: args.fileSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
121
apps/desktop/src/convex/_generated/api.d.ts
vendored
Normal file
121
apps/desktop/src/convex/_generated/api.d.ts
vendored
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated `api` utility.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as alerts from "../alerts.js";
|
||||||
|
import type * as automations from "../automations.js";
|
||||||
|
import type * as bootstrap from "../bootstrap.js";
|
||||||
|
import type * as categories from "../categories.js";
|
||||||
|
import type * as categorySlas from "../categorySlas.js";
|
||||||
|
import type * as checklistTemplates from "../checklistTemplates.js";
|
||||||
|
import type * as commentTemplates from "../commentTemplates.js";
|
||||||
|
import type * as companies from "../companies.js";
|
||||||
|
import type * as crons from "../crons.js";
|
||||||
|
import type * as dashboards from "../dashboards.js";
|
||||||
|
import type * as deviceExportTemplates from "../deviceExportTemplates.js";
|
||||||
|
import type * as deviceFieldDefaults from "../deviceFieldDefaults.js";
|
||||||
|
import type * as deviceFields from "../deviceFields.js";
|
||||||
|
import type * as devices from "../devices.js";
|
||||||
|
import type * as emprestimos from "../emprestimos.js";
|
||||||
|
import type * as fields from "../fields.js";
|
||||||
|
import type * as files from "../files.js";
|
||||||
|
import type * as incidents from "../incidents.js";
|
||||||
|
import type * as invites from "../invites.js";
|
||||||
|
import type * as liveChat from "../liveChat.js";
|
||||||
|
import type * as machines from "../machines.js";
|
||||||
|
import type * as metrics from "../metrics.js";
|
||||||
|
import type * as migrations from "../migrations.js";
|
||||||
|
import type * as ops from "../ops.js";
|
||||||
|
import type * as queues from "../queues.js";
|
||||||
|
import type * as rbac from "../rbac.js";
|
||||||
|
import type * as reports from "../reports.js";
|
||||||
|
import type * as revision from "../revision.js";
|
||||||
|
import type * as seed from "../seed.js";
|
||||||
|
import type * as slas from "../slas.js";
|
||||||
|
import type * as teams from "../teams.js";
|
||||||
|
import type * as ticketFormSettings from "../ticketFormSettings.js";
|
||||||
|
import type * as ticketFormTemplates from "../ticketFormTemplates.js";
|
||||||
|
import type * as ticketNotifications from "../ticketNotifications.js";
|
||||||
|
import type * as tickets from "../tickets.js";
|
||||||
|
import type * as usbPolicy from "../usbPolicy.js";
|
||||||
|
import type * as users from "../users.js";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ApiFromModules,
|
||||||
|
FilterApi,
|
||||||
|
FunctionReference,
|
||||||
|
} from "convex/server";
|
||||||
|
|
||||||
|
declare const fullApi: ApiFromModules<{
|
||||||
|
alerts: typeof alerts;
|
||||||
|
automations: typeof automations;
|
||||||
|
bootstrap: typeof bootstrap;
|
||||||
|
categories: typeof categories;
|
||||||
|
categorySlas: typeof categorySlas;
|
||||||
|
checklistTemplates: typeof checklistTemplates;
|
||||||
|
commentTemplates: typeof commentTemplates;
|
||||||
|
companies: typeof companies;
|
||||||
|
crons: typeof crons;
|
||||||
|
dashboards: typeof dashboards;
|
||||||
|
deviceExportTemplates: typeof deviceExportTemplates;
|
||||||
|
deviceFieldDefaults: typeof deviceFieldDefaults;
|
||||||
|
deviceFields: typeof deviceFields;
|
||||||
|
devices: typeof devices;
|
||||||
|
emprestimos: typeof emprestimos;
|
||||||
|
fields: typeof fields;
|
||||||
|
files: typeof files;
|
||||||
|
incidents: typeof incidents;
|
||||||
|
invites: typeof invites;
|
||||||
|
liveChat: typeof liveChat;
|
||||||
|
machines: typeof machines;
|
||||||
|
metrics: typeof metrics;
|
||||||
|
migrations: typeof migrations;
|
||||||
|
ops: typeof ops;
|
||||||
|
queues: typeof queues;
|
||||||
|
rbac: typeof rbac;
|
||||||
|
reports: typeof reports;
|
||||||
|
revision: typeof revision;
|
||||||
|
seed: typeof seed;
|
||||||
|
slas: typeof slas;
|
||||||
|
teams: typeof teams;
|
||||||
|
ticketFormSettings: typeof ticketFormSettings;
|
||||||
|
ticketFormTemplates: typeof ticketFormTemplates;
|
||||||
|
ticketNotifications: typeof ticketNotifications;
|
||||||
|
tickets: typeof tickets;
|
||||||
|
usbPolicy: typeof usbPolicy;
|
||||||
|
users: typeof users;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility for referencing Convex functions in your app's public API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```js
|
||||||
|
* const myFunctionReference = api.myModule.myFunction;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export declare const api: FilterApi<
|
||||||
|
typeof fullApi,
|
||||||
|
FunctionReference<any, "public">
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility for referencing Convex functions in your app's internal API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```js
|
||||||
|
* const myFunctionReference = internal.myModule.myFunction;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export declare const internal: FilterApi<
|
||||||
|
typeof fullApi,
|
||||||
|
FunctionReference<any, "internal">
|
||||||
|
>;
|
||||||
|
|
||||||
|
export declare const components: {};
|
||||||
23
apps/desktop/src/convex/_generated/api.js
Normal file
23
apps/desktop/src/convex/_generated/api.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated `api` utility.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { anyApi, componentsGeneric } from "convex/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility for referencing Convex functions in your app's API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```js
|
||||||
|
* const myFunctionReference = api.myModule.myFunction;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const api = anyApi;
|
||||||
|
export const internal = anyApi;
|
||||||
|
export const components = componentsGeneric();
|
||||||
60
apps/desktop/src/convex/_generated/dataModel.d.ts
vendored
Normal file
60
apps/desktop/src/convex/_generated/dataModel.d.ts
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated data model types.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DataModelFromSchemaDefinition,
|
||||||
|
DocumentByName,
|
||||||
|
TableNamesInDataModel,
|
||||||
|
SystemTableNames,
|
||||||
|
} from "convex/server";
|
||||||
|
import type { GenericId } from "convex/values";
|
||||||
|
import schema from "../schema.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The names of all of your Convex tables.
|
||||||
|
*/
|
||||||
|
export type TableNames = TableNamesInDataModel<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of a document stored in Convex.
|
||||||
|
*
|
||||||
|
* @typeParam TableName - A string literal type of the table name (like "users").
|
||||||
|
*/
|
||||||
|
export type Doc<TableName extends TableNames> = DocumentByName<
|
||||||
|
DataModel,
|
||||||
|
TableName
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An identifier for a document in Convex.
|
||||||
|
*
|
||||||
|
* Convex documents are uniquely identified by their `Id`, which is accessible
|
||||||
|
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
|
||||||
|
*
|
||||||
|
* Documents can be loaded using `db.get(id)` in query and mutation functions.
|
||||||
|
*
|
||||||
|
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
||||||
|
* strings when type checking.
|
||||||
|
*
|
||||||
|
* @typeParam TableName - A string literal type of the table name (like "users").
|
||||||
|
*/
|
||||||
|
export type Id<TableName extends TableNames | SystemTableNames> =
|
||||||
|
GenericId<TableName>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type describing your Convex data model.
|
||||||
|
*
|
||||||
|
* This type includes information about what tables you have, the type of
|
||||||
|
* documents stored in those tables, and the indexes defined on them.
|
||||||
|
*
|
||||||
|
* This type is used to parameterize methods like `queryGeneric` and
|
||||||
|
* `mutationGeneric` to make them type-safe.
|
||||||
|
*/
|
||||||
|
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
|
||||||
143
apps/desktop/src/convex/_generated/server.d.ts
vendored
Normal file
143
apps/desktop/src/convex/_generated/server.d.ts
vendored
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActionBuilder,
|
||||||
|
HttpActionBuilder,
|
||||||
|
MutationBuilder,
|
||||||
|
QueryBuilder,
|
||||||
|
GenericActionCtx,
|
||||||
|
GenericMutationCtx,
|
||||||
|
GenericQueryCtx,
|
||||||
|
GenericDatabaseReader,
|
||||||
|
GenericDatabaseWriter,
|
||||||
|
} from "convex/server";
|
||||||
|
import type { DataModel } from "./dataModel.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to read your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const query: QueryBuilder<DataModel, "public">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const mutation: MutationBuilder<DataModel, "public">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* An action is a function which can execute any JavaScript code, including non-deterministic
|
||||||
|
* code and code with side-effects, like calling third-party services.
|
||||||
|
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
||||||
|
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
||||||
|
*
|
||||||
|
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const action: ActionBuilder<DataModel, "public">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const internalAction: ActionBuilder<DataModel, "internal">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an HTTP action.
|
||||||
|
*
|
||||||
|
* The wrapped function will be used to respond to HTTP requests received
|
||||||
|
* by a Convex deployment if the requests matches the path and method where
|
||||||
|
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||||
|
* and a Fetch API `Request` object as its second.
|
||||||
|
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||||
|
*/
|
||||||
|
export declare const httpAction: HttpActionBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of services for use within Convex query functions.
|
||||||
|
*
|
||||||
|
* The query context is passed as the first argument to any Convex query
|
||||||
|
* function run on the server.
|
||||||
|
*
|
||||||
|
* This differs from the {@link MutationCtx} because all of the services are
|
||||||
|
* read-only.
|
||||||
|
*/
|
||||||
|
export type QueryCtx = GenericQueryCtx<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of services for use within Convex mutation functions.
|
||||||
|
*
|
||||||
|
* The mutation context is passed as the first argument to any Convex mutation
|
||||||
|
* function run on the server.
|
||||||
|
*/
|
||||||
|
export type MutationCtx = GenericMutationCtx<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of services for use within Convex action functions.
|
||||||
|
*
|
||||||
|
* The action context is passed as the first argument to any Convex action
|
||||||
|
* function run on the server.
|
||||||
|
*/
|
||||||
|
export type ActionCtx = GenericActionCtx<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface to read from the database within Convex query functions.
|
||||||
|
*
|
||||||
|
* The two entry points are {@link DatabaseReader.get}, which fetches a single
|
||||||
|
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
|
||||||
|
* building a query.
|
||||||
|
*/
|
||||||
|
export type DatabaseReader = GenericDatabaseReader<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface to read from and write to the database within Convex mutation
|
||||||
|
* functions.
|
||||||
|
*
|
||||||
|
* Convex guarantees that all writes within a single mutation are
|
||||||
|
* executed atomically, so you never have to worry about partial writes leaving
|
||||||
|
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
|
||||||
|
* for the guarantees Convex provides your functions.
|
||||||
|
*/
|
||||||
|
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
|
||||||
93
apps/desktop/src/convex/_generated/server.js
Normal file
93
apps/desktop/src/convex/_generated/server.js
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
actionGeneric,
|
||||||
|
httpActionGeneric,
|
||||||
|
queryGeneric,
|
||||||
|
mutationGeneric,
|
||||||
|
internalActionGeneric,
|
||||||
|
internalMutationGeneric,
|
||||||
|
internalQueryGeneric,
|
||||||
|
} from "convex/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to read your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const query = queryGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const internalQuery = internalQueryGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const mutation = mutationGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const internalMutation = internalMutationGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* An action is a function which can execute any JavaScript code, including non-deterministic
|
||||||
|
* code and code with side-effects, like calling third-party services.
|
||||||
|
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
||||||
|
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
||||||
|
*
|
||||||
|
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const action = actionGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const internalAction = internalActionGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an HTTP action.
|
||||||
|
*
|
||||||
|
* The wrapped function will be used to respond to HTTP requests received
|
||||||
|
* by a Convex deployment if the requests matches the path and method where
|
||||||
|
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||||
|
* and a Fetch API `Request` object as its second.
|
||||||
|
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||||
|
*/
|
||||||
|
export const httpAction = httpActionGeneric;
|
||||||
|
|
@ -19,7 +19,13 @@
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"types": ["vite/client"]
|
"types": ["vite/client"],
|
||||||
|
|
||||||
|
/* Paths */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@convex/_generated/*": ["./src/convex/_generated/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
|
@ -7,6 +8,13 @@ const host = process.env.TAURI_DEV_HOST;
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
// Usar arquivos _generated locais para evitar problemas de type-check
|
||||||
|
"@convex/_generated": resolve(__dirname, "./src/convex/_generated"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
//
|
//
|
||||||
// 1. prevent Vite from obscuring rust errors
|
// 1. prevent Vite from obscuring rust errors
|
||||||
|
|
|
||||||
55
bun.lock
55
bun.lock
|
|
@ -115,6 +115,7 @@
|
||||||
"@tauri-apps/plugin-process": "^2",
|
"@tauri-apps/plugin-process": "^2",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
"@tauri-apps/plugin-store": "^2",
|
||||||
"@tauri-apps/plugin-updater": "^2",
|
"@tauri-apps/plugin-updater": "^2",
|
||||||
|
"convex": "^1.31.0",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|
@ -2335,6 +2336,8 @@
|
||||||
|
|
||||||
"ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
"ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex": ["convex@1.31.0", "", { "dependencies": { "esbuild": "0.25.4", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-ht3dtpWQmxX62T8PT3p/5PDlRzSW5p2IDTP4exKjQ5dqmvhtn1wLFakJAX4CCeu1s0Ch0dKY5g2dk/wETTRAOw=="],
|
||||||
|
|
||||||
"appsdesktop/lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
|
"appsdesktop/lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
|
||||||
|
|
||||||
"appsdesktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
"appsdesktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||||
|
|
@ -2469,6 +2472,8 @@
|
||||||
|
|
||||||
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
|
||||||
|
|
||||||
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
"conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
"conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||||
|
|
@ -2600,5 +2605,55 @@
|
||||||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="],
|
||||||
|
|
||||||
|
"appsdesktop/convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue