From c51b08f127b4a600a78ad01f4a440484c12640f5 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Mon, 15 Dec 2025 23:40:34 -0300 Subject: [PATCH] feat(desktop): implementa Convex React subscriptions para chat em tempo real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/desktop/package.json | 1 + apps/desktop/src/chat/ChatHubWidget.tsx | 250 ++++----- apps/desktop/src/chat/ChatSessionItem.tsx | 86 ---- apps/desktop/src/chat/ChatSessionList.tsx | 99 ---- apps/desktop/src/chat/ChatWidget.tsx | 483 +++++------------- .../src/chat/ConvexMachineProvider.tsx | 146 ++++++ apps/desktop/src/chat/index.tsx | 49 +- .../src/chat/useConvexMachineQueries.ts | 206 ++++++++ apps/desktop/src/convex/_generated/api.d.ts | 121 +++++ apps/desktop/src/convex/_generated/api.js | 23 + .../src/convex/_generated/dataModel.d.ts | 60 +++ .../desktop/src/convex/_generated/server.d.ts | 143 ++++++ apps/desktop/src/convex/_generated/server.js | 93 ++++ apps/desktop/tsconfig.json | 8 +- apps/desktop/vite.config.ts | 8 + bun.lock | 55 ++ 16 files changed, 1181 insertions(+), 650 deletions(-) delete mode 100644 apps/desktop/src/chat/ChatSessionItem.tsx delete mode 100644 apps/desktop/src/chat/ChatSessionList.tsx create mode 100644 apps/desktop/src/chat/ConvexMachineProvider.tsx create mode 100644 apps/desktop/src/chat/useConvexMachineQueries.ts create mode 100644 apps/desktop/src/convex/_generated/api.d.ts create mode 100644 apps/desktop/src/convex/_generated/api.js create mode 100644 apps/desktop/src/convex/_generated/dataModel.d.ts create mode 100644 apps/desktop/src/convex/_generated/server.d.ts create mode 100644 apps/desktop/src/convex/_generated/server.js diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cac2fb0..1c403b7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -21,6 +21,7 @@ "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-updater": "^2", + "convex": "^1.31.0", "lucide-react": "^0.544.0", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/apps/desktop/src/chat/ChatHubWidget.tsx b/apps/desktop/src/chat/ChatHubWidget.tsx index b05e51c..e8f91d5 100644 --- a/apps/desktop/src/chat/ChatHubWidget.tsx +++ b/apps/desktop/src/chat/ChatHubWidget.tsx @@ -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 { listen } from "@tauri-apps/api/event" -import { Loader2, MessageCircle, ChevronUp } from "lucide-react" -import { ChatSessionList } from "./ChatSessionList" -import type { ChatSession, NewMessageEvent, SessionStartedEvent, SessionEndedEvent, UnreadUpdateEvent } from "./types" -import { getMachineStoreConfig } from "./machineStore" +import { Loader2, MessageCircle, ChevronUp, X, Minimize2 } from "lucide-react" +import { useMachineSessions, type MachineSession } from "./useConvexMachineQueries" /** * Hub Widget - Lista todas as sessoes de chat ativas * Ao clicar em uma sessao, abre/foca a janela de chat daquele ticket */ export function ChatHubWidget() { - const [sessions, setSessions] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - const [isMinimized, setIsMinimized] = useState(true) // Inicia minimizado + const [isMinimized, setIsMinimized] = useState(true) - const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null) - - 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("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("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("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("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]) + // Convex subscription reativa + const { sessions = [], isLoading, hasToken } = useMachineSessions() // Sincronizar estado minimizado com tamanho da janela useEffect(() => { @@ -109,7 +40,6 @@ export function ChatHubWidget() { const handleSelectSession = async (ticketId: string, ticketRef: number) => { try { - // Tauri 2 espera snake_case nos parametros await invoke("open_chat_window", { ticket_id: ticketId, ticket_ref: ticketRef }) } catch (err) { console.error("Erro ao abrir janela de chat:", err) @@ -128,11 +58,10 @@ export function ChatHubWidget() { const handleExpand = async () => { try { await invoke("set_hub_minimized", { minimized: false }) - // Aguarda a janela redimensionar antes de atualizar o estado setTimeout(() => setIsMinimized(false), 100) } catch (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) + // Sem token + if (!hasToken) { + return ( +
+
+ Token nao configurado +
+
+ ) + } + // Loading if (isLoading) { return ( @@ -156,26 +96,7 @@ export function ChatHubWidget() { ) } - // Erro - if (error) { - return ( -
- -
- ) - } - - // Sem sessoes ativas - mostrar chip cinza + // Sem sessoes ativas if (sessions.length === 0) { return (
@@ -187,7 +108,7 @@ export function ChatHubWidget() { ) } - // Minimizado - mostrar chip com contador + // Minimizado if (isMinimized) { return (
@@ -211,15 +132,114 @@ export function ChatHubWidget() { ) } - // Expandido - mostrar lista + // Expandido return (
- + {/* Header */} +
+
+
+ +
+
+

Chats Ativos

+

+ {sessions.length} conversa{sessions.length !== 1 ? "s" : ""} +

+
+
+
+ + +
+
+ + {/* Lista de sessoes */} +
+
+ {sessions.map((session) => ( + handleSelectSession(session.ticketId, session.ticketRef)} + /> + ))} +
+
) } + +function SessionItem({ + session, + onClick, +}: { + session: MachineSession + onClick: () => void +}) { + return ( + + ) +} + +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` +} diff --git a/apps/desktop/src/chat/ChatSessionItem.tsx b/apps/desktop/src/chat/ChatSessionItem.tsx deleted file mode 100644 index 142fef7..0000000 --- a/apps/desktop/src/chat/ChatSessionItem.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/apps/desktop/src/chat/ChatSessionList.tsx b/apps/desktop/src/chat/ChatSessionList.tsx deleted file mode 100644 index d7d2386..0000000 --- a/apps/desktop/src/chat/ChatSessionList.tsx +++ /dev/null @@ -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 ( -
- {/* Header - arrastavel */} -
-
-
- -
-
-

Chats

-

- {sessions.length} conversa{sessions.length !== 1 ? "s" : ""} ativa{sessions.length !== 1 ? "s" : ""} - {totalUnread > 0 && ( - - ({totalUnread} não lida{totalUnread !== 1 ? "s" : ""}) - - )} -

-
-
-
- - -
-
- - {/* Lista de sessoes */} -
- {sortedSessions.length === 0 ? ( -
-
- -
-

Nenhum chat ativo

-

- Os chats aparecerao aqui quando iniciados -

-
- ) : ( - sortedSessions.map((session) => ( - onSelectSession(session.ticketId, session.ticketRef)} - /> - )) - )} -
-
- ) -} diff --git a/apps/desktop/src/chat/ChatWidget.tsx b/apps/desktop/src/chat/ChatWidget.tsx index fe0e2b7..ad20ed9 100644 --- a/apps/desktop/src/chat/ChatWidget.tsx +++ b/apps/desktop/src/chat/ChatWidget.tsx @@ -1,25 +1,26 @@ +/** + * ChatWidget - Componente de chat em tempo real usando Convex subscriptions + * + * Arquitetura: + * - Usa useQuery do Convex React para subscriptions reativas (tempo real verdadeiro) + * - Usa useMutation do Convex React para enviar mensagens + * - Mantém Tauri apenas para: upload de arquivos, gerenciamento de janela + * - Sem polling - todas as atualizacoes sao push-based via WebSocket + */ + import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { open as openDialog } from "@tauri-apps/plugin-dialog" import { openUrl as openExternal } from "@tauri-apps/plugin-opener" import { invoke } from "@tauri-apps/api/core" -import { listen } from "@tauri-apps/api/event" import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check } from "lucide-react" -import type { - ChatAttachment, - ChatMessage, - ChatMessagesResponse, - NewMessageEvent, - SessionEndedEvent, - SessionStartedEvent, - UnreadUpdateEvent, -} from "./types" -import { getMachineStoreConfig } from "./machineStore" +import type { Id } from "@convex/_generated/dataModel" +import { useMachineMessages, usePostMachineMessage, useMarkMachineMessagesRead, type MachineMessage } from "./useConvexMachineQueries" +import { useConvexMachine } from "./ConvexMachineProvider" -const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak +const MAX_MESSAGES_IN_MEMORY = 200 const MARK_READ_BATCH_SIZE = 50 const SCROLL_BOTTOM_THRESHOLD_PX = 120 -// Tipos de arquivo permitidos const ALLOWED_EXTENSIONS = [ "jpg", "jpeg", "png", "gif", "webp", "pdf", "txt", "doc", "docx", "xls", "xlsx", @@ -32,6 +33,13 @@ interface UploadedAttachment { type?: string } +interface ChatAttachment { + storageId: string + name: string + size?: number + type?: string +} + function getFileIcon(fileName: string) { const ext = fileName.toLowerCase().split(".").pop() ?? "" if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) { @@ -57,7 +65,7 @@ function formatAttachmentSize(size?: number) { return `${(kb / 1024).toFixed(1)}MB` } -function getUnreadAgentMessageIds(messages: ChatMessage[], unreadCount: number): string[] { +function getUnreadAgentMessageIds(messages: MachineMessage[], unreadCount: number): string[] { if (unreadCount <= 0 || messages.length === 0) return [] const ids: string[] = [] for (let i = messages.length - 1; i >= 0 && ids.length < unreadCount; i--) { @@ -138,7 +146,6 @@ function MessageAttachment({ setTimeout(() => setDownloaded(false), 2000) } catch (err) { console.error("Falha ao baixar anexo:", err) - // Fallback: abrir no navegador/sistema await handleView() } finally { setDownloading(false) @@ -160,7 +167,7 @@ function MessageAttachment({ if (isImage && url) { return (
- {/* eslint-disable-next-line @next/next/no-img-element */} + {/* eslint-disable-next-line @next/next/no-img-element -- Tauri desktop app, not Next.js */} {attachment.name}([]) const [inputValue, setInputValue] = useState("") - const [isLoading, setIsLoading] = useState(true) const [isSending, setIsSending] = useState(false) const [isUploading, setIsUploading] = useState(false) - const [error, setError] = useState(null) - const [ticketInfo, setTicketInfo] = useState<{ ref: number; subject: string; agentName: string } | null>(null) - const [hasSession, setHasSession] = useState(false) const [pendingAttachments, setPendingAttachments] = useState([]) - // Inicializa minimizado porque o Rust abre a janela e minimiza imediatamente const [isMinimized, setIsMinimized] = useState(true) - const [unreadCount, setUnreadCount] = useState(0) + + // Convex hooks + const { apiBaseUrl, machineToken } = useConvexMachine() + const { messages: convexMessages, hasSession, unreadCount, isLoading } = useMachineMessages( + ticketId as Id<"tickets">, + { limit: MAX_MESSAGES_IN_MEMORY } + ) + const postMessage = usePostMachineMessage() + const markMessagesRead = useMarkMachineMessagesRead() + + // Limitar mensagens em memoria + const messages = useMemo(() => convexMessages.slice(-MAX_MESSAGES_IN_MEMORY), [convexMessages]) const messagesEndRef = useRef(null) const messagesContainerRef = useRef(null) const messageElementsRef = useRef>(new Map()) const prevHasSessionRef = useRef(false) - const retryDelayMsRef = useRef(1_000) const [isAtBottom, setIsAtBottom] = useState(true) const isAtBottomRef = useRef(true) @@ -288,43 +299,39 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { return true }, [updateIsAtBottom]) - // Quando a sessão termina (hasSession muda de true -> false), fechar a janela para não ficar "Offline" preso + // Fechar janela quando sessao termina useEffect(() => { const prevHasSession = prevHasSessionRef.current if (prevHasSession && !hasSession) { invoke("close_chat_window", { ticket_id: ticketId }).catch((err) => { - console.error("Erro ao fechar janela ao encerrar sessão:", err) + console.error("Erro ao fechar janela ao encerrar sessao:", err) }) } prevHasSessionRef.current = hasSession }, [hasSession, ticketId]) - // Ref para acessar isMinimized dentro do callback sem causar resubscription + // Ref para acessar isMinimized dentro de callbacks const isMinimizedRef = useRef(isMinimized) useEffect(() => { isMinimizedRef.current = isMinimized }, [isMinimized]) - const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null) - - const ensureConfig = useCallback(async () => { - const cfg = configRef.current ?? (await getMachineStoreConfig()) - configRef.current = cfg - return cfg - }, []) - + // Cache de URLs de anexos const attachmentUrlCacheRef = useRef>(new Map()) const loadAttachmentUrl = useCallback(async (storageId: string) => { const cached = attachmentUrlCacheRef.current.get(storageId) if (cached) return cached - const cfg = await ensureConfig() - const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/attachments/url`, { + if (!apiBaseUrl || !machineToken) { + throw new Error("Configuracao nao disponivel") + } + + const response = await fetch(`${apiBaseUrl}/api/machines/chat/attachments/url`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - machineToken: cfg.token, + machineToken, ticketId, storageId, }), @@ -342,148 +349,32 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { attachmentUrlCacheRef.current.set(storageId, data.url) return data.url - }, [ensureConfig, ticketId]) - - const loadMessages = useCallback(async () => { - try { - const cfg = await ensureConfig() - const result = await invoke("fetch_chat_messages", { - baseUrl: cfg.apiBaseUrl, - token: cfg.token, - ticketId, - since: null, - }) - - setHasSession(result.hasSession) - setUnreadCount(result.unreadCount ?? 0) - setMessages(result.messages.slice(-MAX_MESSAGES_IN_MEMORY)) - - if (result.messages.length > 0) { - const first = result.messages[0] - setTicketInfo((prevInfo) => - prevInfo ?? { - ref: ticketRef ?? 0, - subject: "", - agentName: first.authorName ?? "Suporte", - } - ) - } - - setError(null) - retryDelayMsRef.current = 1_000 - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message || "Erro ao carregar mensagens.") - } finally { - setIsLoading(false) - } - }, [ensureConfig, ticketId, ticketRef]) - - // Auto-retry leve quando houver erro (evita ficar "morto" após falha transiente). - useEffect(() => { - if (!error) return - - const delayMs = retryDelayMsRef.current - const timeout = window.setTimeout(() => { - loadMessages() - }, delayMs) - - retryDelayMsRef.current = Math.min(retryDelayMsRef.current * 2, 30_000) - - return () => { - window.clearTimeout(timeout) - } - }, [error, loadMessages]) + }, [apiBaseUrl, machineToken, ticketId]) const markUnreadMessagesRead = useCallback(async () => { if (unreadCount <= 0) return const ids = getUnreadAgentMessageIds(messages, unreadCount) if (ids.length === 0) return - const cfg = await ensureConfig() const chunks = chunkArray(ids, MARK_READ_BATCH_SIZE) - for (const chunk of chunks) { - await invoke("mark_chat_messages_read", { - base_url: cfg.apiBaseUrl, - token: cfg.token, - ticket_id: ticketId, - message_ids: chunk, + await markMessagesRead({ + ticketId: ticketId as Id<"tickets">, + messageIds: chunk as Id<"ticketChatMessages">[], }) } + }, [messages, ticketId, unreadCount, markMessagesRead]) - setUnreadCount(0) - }, [ensureConfig, messages, ticketId, unreadCount]) - - // Carregar mensagens na montagem / troca de ticket + // Auto-scroll quando novas mensagens chegam (se ja estava no bottom) + const prevMessagesLengthRef = useRef(messages.length) useEffect(() => { - setIsLoading(true) - setMessages([]) - setUnreadCount(0) - loadMessages() - }, [loadMessages]) - - // Recarregar quando o Rust sinalizar novas mensagens para este ticket - useEffect(() => { - let unlisten: (() => void) | null = null - listen("raven://chat/new-message", (event) => { - const sessions = event.payload?.sessions ?? [] - if (sessions.some((s) => s.ticketId === ticketId)) { - const shouldAutoScroll = !isMinimizedRef.current && isAtBottomRef.current - if (shouldAutoScroll) { - pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: true } - } - loadMessages() - } - }) - .then((u) => { - unlisten = u - }) - .catch((err) => console.error("Falha ao registrar listener new-message:", err)) - - return () => { - unlisten?.() + if (messages.length > prevMessagesLengthRef.current && isAtBottomRef.current && !isMinimizedRef.current) { + pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: true } } - }, [ticketId, loadMessages]) + prevMessagesLengthRef.current = messages.length + }, [messages.length]) - // Recarregar quando uma nova sessão iniciar (usuário pode estar com o chat aberto em "Offline") - useEffect(() => { - let unlisten: (() => void) | null = null - listen("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("raven://chat/unread-update", (event) => { - const sessions = event.payload?.sessions ?? [] - const session = sessions.find((s) => s.ticketId === ticketId) - setUnreadCount(session?.unreadCount ?? 0) - }) - .then((u) => { - unlisten = u - }) - .catch((err) => console.error("Falha ao registrar listener unread-update:", err)) - - return () => { - unlisten?.() - } - }, [ticketId]) - - // Executar scroll pendente (após expandir ou após novas mensagens) + // Executar scroll pendente useEffect(() => { if (isMinimized) return @@ -517,84 +408,16 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { } }, [isMinimized, messages, markUnreadMessagesRead, scrollToBottom, scrollToMessage]) - // Recarregar quando a sessao for encerrada (para refletir offline/minimizar corretamente) + // Sincronizar estado minimizado com tamanho da janela useEffect(() => { - let unlisten: (() => void) | null = null - listen("raven://chat/session-ended", (event) => { - if (event.payload?.ticketId === ticketId) { - loadMessages() - } - }) - .then((u) => { - unlisten = u - }) - .catch((err) => console.error("Falha ao registrar listener session-ended:", err)) - - return () => { - unlisten?.() - } - }, [ticketId, loadMessages]) - - // Inicializacao via Convex (WS) - NAO depende de isMinimized para evitar resubscriptions - /* useEffect(() => { - setIsLoading(true) - setMessages([]) - messagesSubRef.current?.() - // reset contador ao trocar ticket - setUnreadCount(0) - - subscribeMachineMessages( - ticketId, - (payload) => { - setIsLoading(false) - setHasSession(payload.hasSession) - hadSessionRef.current = hadSessionRef.current || payload.hasSession - // Usa o unreadCount do backend (baseado em unreadByMachine da sessao) - const backendUnreadCount = (payload as { unreadCount?: number }).unreadCount ?? 0 - setUnreadCount(backendUnreadCount) - setMessages(prev => { - const existingIds = new Set(prev.map(m => m.id)) - const combined = [...prev, ...payload.messages.filter(m => !existingIds.has(m.id))] - return combined.slice(-MAX_MESSAGES_IN_MEMORY) - }) - // Atualiza info basica do ticket - if (payload.messages.length > 0) { - const first = payload.messages[0] - setTicketInfo((prevInfo) => prevInfo ?? { ref: 0, subject: "", agentName: first.authorName ?? "Suporte" }) - } - // NAO marca como lidas aqui - deixa o useEffect de expansao fazer isso - // Isso evita marcar como lidas antes do usuario expandir o chat - }, - (err) => { - setIsLoading(false) - setError(err.message || "Erro ao carregar mensagens.") - } - ).then((unsub) => { - messagesSubRef.current = unsub - }) - - return () => { - messagesSubRef.current?.() - messagesSubRef.current = null - } - }, [ticketId]) */ // Removido isMinimized - evita memory leak de resubscriptions - - // Sincroniza estado de minimizado com o tamanho da janela (apenas em resizes reais, nao na montagem) - // O estado inicial isMinimized=true e definido no useState e nao deve ser sobrescrito na montagem - useEffect(() => { - // Ignorar todos os eventos de resize nos primeiros 500ms apos a montagem - // Isso da tempo ao Tauri de aplicar o tamanho correto da janela - // e evita que resizes transitórios durante a criação da janela alterem o estado const mountTime = Date.now() - const STABILIZATION_DELAY = 500 // ms para a janela estabilizar + const STABILIZATION_DELAY = 500 const handler = () => { - // Ignorar eventos de resize durante o periodo de estabilizacao if (Date.now() - mountTime < STABILIZATION_DELAY) { return } const h = window.innerHeight - // thresholds alinhados com set_chat_minimized (52px minimizado, 520px expandido) setIsMinimized(h < 100) } window.addEventListener("resize", handler) @@ -616,16 +439,17 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { if (!selected) return - // O retorno pode ser string (path único) ou objeto com path const filePath = typeof selected === "string" ? selected : (selected as { path: string }).path setIsUploading(true) - const config = await getMachineStoreConfig() + if (!apiBaseUrl || !machineToken) { + throw new Error("Configuracao nao disponivel") + } const attachment = await invoke("upload_chat_file", { - baseUrl: config.apiBaseUrl, - token: config.token, + baseUrl: apiBaseUrl, + token: machineToken, filePath, }) @@ -654,34 +478,19 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { setIsSending(true) try { - const bodyToSend = messageText - const cfg = await ensureConfig() - await invoke("send_chat_message", { - base_url: cfg.apiBaseUrl, - token: cfg.token, - ticket_id: ticketId, - body: bodyToSend, - attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined, - }) - - // Adicionar mensagem localmente - setMessages(prev => [...prev, { - id: crypto.randomUUID(), - body: bodyToSend, - authorName: "Você", - isFromMachine: true, - createdAt: Date.now(), - attachments: attachmentsToSend.map(a => ({ - storageId: a.storageId, + await postMessage({ + ticketId: ticketId as Id<"tickets">, + body: messageText, + attachments: attachmentsToSend.length > 0 ? attachmentsToSend.map(a => ({ + storageId: a.storageId as Id<"_storage">, name: a.name, size: a.size, type: a.type, - })), - }]) + })) : undefined, + }) pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: false } } catch (err) { console.error("Erro ao enviar mensagem:", err) - // Restaurar input e anexos em caso de erro setInputValue(messageText) setPendingAttachments(attachmentsToSend) } finally { @@ -726,9 +535,8 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { } } + // Loading if (isLoading) { - // Mostrar chip compacto enquanto carrega (compativel com janela minimizada) - // pointer-events-none no container para que a area transparente nao seja clicavel return (
@@ -739,31 +547,14 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { ) } - if (error) { - // Mostrar chip compacto de erro (compativel com janela minimizada) - return ( -
- -
- ) - } - - // Quando não há sessão, mostrar versão minimizada com indicador de offline + // Sem sessao ativa if (!hasSession) { return (
- {ticketRef ? `Ticket #${ticketRef}` : ticketInfo?.ref ? `Ticket #${ticketInfo.ref}` : "Chat"} + {ticketRef ? `Ticket #${ticketRef}` : "Chat"} Offline @@ -779,8 +570,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { ) } - // Versão minimizada (chip compacto igual web) - // pointer-events-none no container para que apenas o botao seja clicavel + // Minimizado if (isMinimized) { return (
@@ -790,11 +580,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { > - Ticket #{ticketRef ?? ticketInfo?.ref} + Ticket #{ticketRef} - {/* Badge de mensagens não lidas */} {unreadCount > 0 && ( {unreadCount > 9 ? "9+" : unreadCount} @@ -805,9 +594,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { ) } + // Expandido return (
- {/* Header - arrastavel */} + {/* Header */}
- {(ticketRef || ticketInfo) && ( -

- Ticket #{ticketRef ?? ticketInfo?.ref} - {ticketInfo?.agentName ?? "Suporte"} -

- )} +

+ Ticket #{ticketRef} - Suporte +

@@ -861,14 +649,12 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { Nenhuma mensagem ainda

- O agente iniciará a conversa em breve + O agente iniciara a conversa em breve

) : (
{messages.map((msg) => { - // No desktop: isFromMachine=true significa mensagem do cliente (maquina) - // Layout igual à web: cliente à esquerda, agente à direita const isAgent = !msg.isFromMachine const bodyText = msg.body.trim() const shouldShowBody = @@ -883,62 +669,67 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
)} -
{ - if (el) { - messageElementsRef.current.set(msg.id, el) - } else { - messageElementsRef.current.delete(msg.id) - } - }} - className={`flex gap-2 ${isAgent ? "flex-row-reverse" : "flex-row"}`} - > - {/* Avatar */}
{ + if (el) { + messageElementsRef.current.set(msg.id, el) + } else { + messageElementsRef.current.delete(msg.id) + } + }} + className={`flex gap-2 ${isAgent ? "flex-row-reverse" : "flex-row"}`} > - {isAgent ? : } -
- - {/* Bubble */} -
- {!isAgent && ( -

- {msg.authorName} -

- )} - {shouldShowBody &&

{msg.body}

} - {/* Anexos */} - {msg.attachments && msg.attachments.length > 0 && ( -
- {msg.attachments.map((att) => ( - - ))} -
- )} -

- {formatTime(msg.createdAt)} -

+ {isAgent ? : } +
+ + {/* Bubble */} +
+ {!isAgent && ( +

+ {msg.authorName} +

+ )} + {shouldShowBody &&

{msg.body}

} + {/* Anexos */} + {msg.attachments && msg.attachments.length > 0 && ( +
+ {msg.attachments.map((att) => ( + + ))} +
+ )} +

+ {formatTime(msg.createdAt)} +

+
-
) })}
diff --git a/apps/desktop/src/chat/ConvexMachineProvider.tsx b/apps/desktop/src/chat/ConvexMachineProvider.tsx new file mode 100644 index 0000000..6793d58 --- /dev/null +++ b/apps/desktop/src/chat/ConvexMachineProvider.tsx @@ -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 +} + +const ConvexMachineContext = createContext(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({ + token: null, + apiBaseUrl: null, + isLoading: true, + error: null, + }) + + const [client, setClient] = useState(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 ( + + {children} + + ) +} diff --git a/apps/desktop/src/chat/index.tsx b/apps/desktop/src/chat/index.tsx index d20f75c..db123c0 100644 --- a/apps/desktop/src/chat/index.tsx +++ b/apps/desktop/src/chat/index.tsx @@ -1,20 +1,63 @@ +import { ConvexProvider } from "convex/react" import { ChatWidget } from "./ChatWidget" 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 const params = new URLSearchParams(window.location.search) const ticketId = params.get("ticketId") const ticketRef = params.get("ticketRef") const isHub = params.get("hub") === "true" + // Aguardar cliente Convex estar pronto + if (!isReady || !client) { + if (error) { + return ( +
+
+ Erro: {error} +
+
+ ) + } + + return ( +
+
+ + Conectando... +
+
+ ) + } + // Modo hub - lista de todas as sessoes if (isHub || !ticketId) { - return + return ( + + + + ) } // Modo chat - conversa de um ticket especifico - return + return ( + + + + ) +} + +export function ChatApp() { + return ( + + + + ) } export { ChatWidget } diff --git a/apps/desktop/src/chat/useConvexMachineQueries.ts b/apps/desktop/src/chat/useConvexMachineQueries.ts new file mode 100644 index 0000000..ac68253 --- /dev/null +++ b/apps/desktop/src/chat/useConvexMachineQueries.ts @@ -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, + }) + } +} diff --git a/apps/desktop/src/convex/_generated/api.d.ts b/apps/desktop/src/convex/_generated/api.d.ts new file mode 100644 index 0000000..75fbfcb --- /dev/null +++ b/apps/desktop/src/convex/_generated/api.d.ts @@ -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 +>; + +/** + * 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 +>; + +export declare const components: {}; diff --git a/apps/desktop/src/convex/_generated/api.js b/apps/desktop/src/convex/_generated/api.js new file mode 100644 index 0000000..44bf985 --- /dev/null +++ b/apps/desktop/src/convex/_generated/api.js @@ -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(); diff --git a/apps/desktop/src/convex/_generated/dataModel.d.ts b/apps/desktop/src/convex/_generated/dataModel.d.ts new file mode 100644 index 0000000..8541f31 --- /dev/null +++ b/apps/desktop/src/convex/_generated/dataModel.d.ts @@ -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; + +/** + * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Doc = 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 = + GenericId; + +/** + * 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; diff --git a/apps/desktop/src/convex/_generated/server.d.ts b/apps/desktop/src/convex/_generated/server.d.ts new file mode 100644 index 0000000..bec05e6 --- /dev/null +++ b/apps/desktop/src/convex/_generated/server.d.ts @@ -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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; diff --git a/apps/desktop/src/convex/_generated/server.js b/apps/desktop/src/convex/_generated/server.js new file mode 100644 index 0000000..bf3d25a --- /dev/null +++ b/apps/desktop/src/convex/_generated/server.js @@ -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; diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 0a3d9c7..5e0a227 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -19,7 +19,13 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "jsx": "react-jsx", - "types": ["vite/client"] + "types": ["vite/client"], + + /* Paths */ + "baseUrl": ".", + "paths": { + "@convex/_generated/*": ["./src/convex/_generated/*"] + } }, "include": ["src"] } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 9c1d6d2..1f22f44 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { resolve } from "path"; const host = process.env.TAURI_DEV_HOST; @@ -7,6 +8,13 @@ const host = process.env.TAURI_DEV_HOST; export default defineConfig(async () => ({ 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` // // 1. prevent Vite from obscuring rust errors diff --git a/bun.lock b/bun.lock index d1095c6..4eddfda 100644 --- a/bun.lock +++ b/bun.lock @@ -115,6 +115,7 @@ "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-updater": "^2", + "convex": "^1.31.0", "lucide-react": "^0.544.0", "react": "^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=="], + "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/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=="], + "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=="], "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/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=="], } }