/** * chatStore.ts — Store de Inbox: chats e mensagens. * Instâncias foram extraídas para instanceStore.ts. * Usa Zustand + Immer para mutações diretas e legíveis. */ import { create } from 'zustand' import { immer } from 'zustand/middleware/immer' import { socketService } from '../services/socketService' import { chatApi, messageApi } from '../services/chatApiService' // ─── Mapper: resposta aninhada do backend → Chat plano do store ──────────────── function mapBackendChat(raw: any): Chat { return { id: raw.id, tenantId: raw.tenantId ?? '', instanceId: raw.instanceId, jid: raw.jid, name: raw.name ?? null, unreadCount: raw.unreadCount ?? 0, lastMessageAt: raw.lastMessageAt ?? null, createdAt: raw.createdAt ?? null, isPinned: raw.isPinned ?? false, isArchived: raw.isArchived ?? false, contactName: raw.contact?.name ?? raw.contactName ?? null, contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null, contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null, scoreReputacao: raw.contact?.scoreReputacao ?? raw.scoreReputacao ?? 0, flagRestricao: raw.contact?.flagRestricao ?? raw.flagRestricao ?? false, lastMessageBody: raw.lastMessage?.body ?? raw.lastMessageBody ?? null, lastMessageFromMe: raw.lastMessage?.fromMe ?? raw.lastMessageFromMe ?? false, lastMessageStatus: raw.lastMessage?.status ?? raw.lastMessageStatus ?? null, lastMessageType: raw.lastMessage?.type ?? raw.lastMessageType ?? null, lastMessageSender: raw.lastMessage?.pushName ?? raw.lastMessageSender ?? null, protocolNumber: raw.protocol?.number ?? raw.protocolNumber ?? null, botPaused: raw.botPaused ?? false, } } // ─── Tipos ──────────────────────────────────────────────────────────────────── export interface Chat { id: string tenantId: string instanceId: string jid: string name: string | null // subject do grupo; null para individuais unreadCount: number lastMessageAt: string | null createdAt: string | null // quando o chat foi registrado (fallback de ordenação) isPinned: boolean isArchived: boolean contactName: string | null contactAvatarUrl: string | null contactPhone: string | null scoreReputacao: number flagRestricao: boolean lastMessageBody: string | null lastMessageFromMe: boolean lastMessageStatus: string | null lastMessageType: string | null lastMessageSender: string | null // pushName do remetente (grupos: quem enviou a última msg) protocolNumber: string | null botPaused: boolean } export interface Message { id: string chatId: string messageId: string fromMe: boolean type: string body: string | null mediaUrl: string | null mimeType: string | null fileName: string | null replyToId: string | null replyTo?: { id: string messageId: string body: string | null fromMe: boolean type: string mediaUrl: string | null } | null status: 'PENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED' timestamp: string pushName?: string | null senderJid?: string | null } // ─── Estado ─────────────────────────────────────────────────────────────────── interface ChatState { chats: Chat[] chatsLoading: boolean activeChatId: string | null messages: Record messagesLoading: boolean // Sync setChats: (chats: Chat[]) => void setChatsLoading: (v: boolean) => void upsertChat: (chat: Chat) => void patchChat: (chatId: string, patch: Partial) => void setActiveChat: (chatId: string | null) => void clearUnread: (chatId: string) => void setMessages: (chatId: string, msgs: Message[]) => void setMessagesLoading: (v: boolean) => void appendMessage: (msg: Message) => void patchMessage: (msgId: string, patch: Partial) => void updateMediaReady: (msgId: string, mediaUrl: string) => void // Async loadChats: (instanceId: string, opts?: { search?: string; archived?: boolean }) => Promise loadMessages: (chatId: string, opts?: { before?: string }) => Promise<{ hasMore: boolean }> selectChat: (chatId: string) => Promise sendMessage: (instanceId: string, jid: string, text: string, replyToMessageId?: string, mentions?: string[]) => Promise // Socket initSocketListeners: () => void } // ─── Helpers ────────────────────────────────────────────────────────────────── function chatSortKey(c: Chat): number { // WhatsApp ordena por: timestamp da última msg; se não houver msg, usa hora de criação do chat if (c.lastMessageAt) return new Date(c.lastMessageAt).getTime() if (c.createdAt) return new Date(c.createdAt).getTime() return 0 } function sortChats(chats: Chat[]): Chat[] { return [...chats].sort((a, b) => { // Fixados sempre primeiro (mesma lógica do WhatsApp oficial) if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1 return chatSortKey(b) - chatSortKey(a) }) } // ─── Store ──────────────────────────────────────────────────────────────────── // ─── Cache local (localStorage) — UX instantâneo no reload ───────────────────── // Persiste os primeiros N chats por instância para hidratar a UI antes do fetch. // Chave: `inboxCache:v1:${instanceId}:${archived ? '1' : '0'}` // TTL implícito: validamos pela data salva e descartamos se > 7 dias. const INBOX_CACHE_PREFIX = 'inboxCache:v1' const INBOX_CACHE_LIMIT = 10 const INBOX_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 interface InboxCacheEntry { savedAt: number chats: Chat[] } function inboxCacheKey(instanceId: string, archived: boolean): string { return `${INBOX_CACHE_PREFIX}:${instanceId}:${archived ? '1' : '0'}` } function readInboxCache(instanceId: string, archived: boolean): Chat[] | null { if (typeof window === 'undefined') return null try { const raw = localStorage.getItem(inboxCacheKey(instanceId, archived)) if (!raw) return null const parsed = JSON.parse(raw) as InboxCacheEntry if (!parsed || !Array.isArray(parsed.chats)) return null if (Date.now() - parsed.savedAt > INBOX_CACHE_MAX_AGE_MS) return null return parsed.chats } catch { return null } } function writeInboxCache(instanceId: string, archived: boolean, chats: Chat[]): void { if (typeof window === 'undefined') return try { const slim = chats.slice(0, INBOX_CACHE_LIMIT) const entry: InboxCacheEntry = { savedAt: Date.now(), chats: slim } localStorage.setItem(inboxCacheKey(instanceId, archived), JSON.stringify(entry)) } catch { /* quota exceeded — ignora */ } } export const useChatStore = create()( immer((set, get) => ({ chats: [], chatsLoading: false, activeChatId: null, messages: {}, messagesLoading: false, // ── Chats ───────────────────────────────────────────────────────────────── setChats: (chats) => set((s) => { s.chats = sortChats(chats) }), setChatsLoading: (v) => set((s) => { s.chatsLoading = v }), upsertChat: (chat) => set((s) => { const idx = s.chats.findIndex((c) => c.id === chat.id) if (idx !== -1) s.chats[idx] = chat else s.chats.unshift(chat) s.chats = sortChats(s.chats as Chat[]) }), patchChat: (chatId, patch) => set((s) => { const idx = s.chats.findIndex((c) => c.id === chatId) if (idx !== -1) { Object.assign(s.chats[idx], patch) s.chats = sortChats(s.chats as Chat[]) } }), setActiveChat: (chatId) => set((s) => { s.activeChatId = chatId }), clearUnread: (chatId) => set((s) => { const c = s.chats.find((c) => c.id === chatId) if (c) c.unreadCount = 0 }), // ── Mensagens ───────────────────────────────────────────────────────────── setMessages: (chatId, msgs) => set((s) => { s.messages[chatId] = msgs }), setMessagesLoading: (v) => set((s) => { s.messagesLoading = v }), appendMessage: (msg) => set((s) => { const list = s.messages[msg.chatId] if (!list) { s.messages[msg.chatId] = [msg]; return } const exists = list.some((m) => m.id === msg.id || m.messageId === msg.messageId) if (!exists) list.push(msg) }), patchMessage: (msgId, patch) => set((s) => { for (const list of Object.values(s.messages)) { const idx = list.findIndex((m) => m.id === msgId || m.messageId === msgId) if (idx !== -1) { Object.assign(list[idx], patch); break } } }), updateMediaReady: (msgId, mediaUrl) => set((s) => { for (const list of Object.values(s.messages)) { const msg = list.find((m) => m.id === msgId) if (msg) { msg.mediaUrl = mediaUrl; break } } }), // ── Async ───────────────────────────────────────────────────────────────── loadChats: async (instanceId, opts = {}) => { // Hidratação instantânea: se temos cache local válido para esta instância, // mostra os 10 chats salvos imediatamente enquanto a request real roda. // Evita tela em branco ou skeleton de 15s+ em redes lentas. const archivedFlag = opts.archived ?? false const hasSearch = !!opts.search const current = get().chats if (!hasSearch) { const sameInstance = current.length > 0 && current[0]?.instanceId === instanceId if (!sameInstance) { const cached = readInboxCache(instanceId, archivedFlag) if (cached && cached.length > 0) { set((s) => { s.chats = sortChats(cached); s.chatsLoading = true }) } else { set((s) => { s.chatsLoading = true }) } } else { set((s) => { s.chatsLoading = true }) } } else { set((s) => { s.chatsLoading = true }) } try { const raw = await chatApi.list(instanceId, opts) const data: Chat[] = (Array.isArray(raw) ? raw : []).map(mapBackendChat) const sorted = sortChats(data) set((s) => { s.chats = sorted; s.chatsLoading = false }) if (!hasSearch) writeInboxCache(instanceId, archivedFlag, sorted) } catch { set((s) => { s.chatsLoading = false }) } }, loadMessages: async (chatId, opts = {}) => { console.log('[chatStore] loadMessages chamado:', chatId, opts) set((s) => { s.messagesLoading = true }) try { const res = await messageApi.list(chatId, opts) console.log('[chatStore] API retornou:', { hasMore: res.hasMore, count: res.messages?.length, raw: typeof res }) const { messages, hasMore } = res if (!Array.isArray(messages)) { console.error('[chatStore] messages NÃO é array!', messages) set((s) => { s.messagesLoading = false }) return { hasMore: false } } set((s) => { if (opts.before) { const existing = s.messages[chatId] ?? [] const ids = new Set(existing.map((m) => m.id)) s.messages[chatId] = [...messages.filter((m: Message) => !ids.has(m.id)), ...existing] } else { s.messages[chatId] = messages } s.messagesLoading = false }) console.log('[chatStore] messages armazenadas:', get().messages[chatId]?.length) return { hasMore } } catch (err) { console.error('[chatStore] loadMessages ERRO:', err) set((s) => { s.messagesLoading = false }) return { hasMore: false } } }, selectChat: async (chatId) => { console.log('[chatStore] selectChat:', chatId) const prev = get().activeChatId if (prev && prev !== chatId) socketService.leaveChat(prev) set((s) => { s.activeChatId = chatId }) socketService.joinChat(chatId) get().clearUnread(chatId) chatApi.markRead(chatId).catch(() => {}) await get().loadMessages(chatId) console.log('[chatStore] selectChat completo. Messages:', get().messages[chatId]?.length) }, sendMessage: async (instanceId, jid, text, replyToMessageId, mentions) => { const chatId = get().chats.find((c) => c.jid === jid && c.instanceId === instanceId)?.id if (!chatId) return // Resolve replyTo a partir das mensagens já no store const replyToMsg = replyToMessageId ? get().messages[chatId]?.find((m) => m.messageId === replyToMessageId) ?? null : null const optimistic: Message = { id: `opt-${Date.now()}`, chatId, messageId: `opt-${Date.now()}`, fromMe: true, type: 'TEXT', body: text, mediaUrl: null, mimeType: null, fileName: null, replyToId: replyToMsg?.id ?? null, replyTo: replyToMsg ? { id: replyToMsg.id, messageId: replyToMsg.messageId, body: replyToMsg.body, fromMe: replyToMsg.fromMe, type: replyToMsg.type, mediaUrl: replyToMsg.mediaUrl } : null, status: 'PENDING', timestamp: new Date().toISOString(), } get().appendMessage(optimistic) try { const saved = await messageApi.send(instanceId, jid, text, replyToMessageId, mentions) get().patchMessage(optimistic.id, { id: saved.id, messageId: saved.messageId, status: 'SENT' }) get().patchChat(chatId, { lastMessageBody: text, lastMessageFromMe: true, lastMessageAt: saved.timestamp, lastMessageStatus: 'SENT', lastMessageType: 'TEXT', }) } catch { get().patchMessage(optimistic.id, { status: 'FAILED' }) } }, // ── Socket ──────────────────────────────────────────────────────────────── initSocketListeners: () => { // IMPORTANTE: sempre usar get() dentro dos callbacks para ler estado atual. // Capturar `const store = get()` fora criaria uma closure stale. socketService.on('message:new', (msg: Message) => { console.log('[socket] message:new recebida:', { chatId: msg.chatId, body: msg.body?.slice(0, 40), fromMe: msg.fromMe }) get().appendMessage(msg) const current = get() const isActive = current.activeChatId === msg.chatId current.patchChat(msg.chatId, { lastMessageBody: msg.body, lastMessageFromMe: msg.fromMe, lastMessageAt: msg.timestamp, lastMessageStatus: msg.status, lastMessageType: msg.type, lastMessageSender: msg.fromMe ? null : (msg.pushName ?? null), unreadCount: msg.fromMe || isActive ? 0 : (current.chats.find((c) => c.id === msg.chatId)?.unreadCount ?? 0) + 1, }) }) socketService.on('message:media_ready', ({ messageId, mediaUrl }: { messageId: string; mediaUrl: string }) => { get().updateMediaReady(messageId, mediaUrl) }) socketService.on('message:status', ({ messageId, status }: { messageId: string; status: Message['status'] }) => { get().patchMessage(messageId, { status }) }) socketService.on('chat:upsert', (raw: any) => { get().upsertChat(mapBackendChat(raw)) }) socketService.on('bot:escalated', ({ chatId }: { chatId: string }) => { get().patchChat(chatId, { botPaused: true }) }) socketService.on('contact:avatar', ({ instanceId, jid, avatarUrl }: { instanceId: string; jid: string; avatarUrl: string }) => { set((s) => { const chat = s.chats.find((c) => c.instanceId === instanceId && c.jid === jid) if (chat) chat.contactAvatarUrl = avatarUrl }) }) // Reconciliação de nomes concluída — re-busca lista de chats socketService.on('chats:refresh', ({ instanceId }: { instanceId: string }) => { const chats = get().chats if (chats.length > 0 && chats[0]?.instanceId === instanceId) { get().loadChats(instanceId) } }) // Reconciliação via ContactHandler (pós-sync) socketService.on('contacts:reconciled', ({ instanceId }: { instanceId: string }) => { const chats = get().chats if (chats.length > 0 && chats[0]?.instanceId === instanceId) { get().loadChats(instanceId) } }) }, })) )