fdae8beb06
- Nome exibido: contact.name (agenda) → verifiedName/notify (nome do WhatsApp) → pushName da última msg, em vez de cair direto no número quando não há nome salvo na agenda. - ThinSidebar: ícone (BrainCircuit) que direciona para /secretaria. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
451 lines
18 KiB
TypeScript
451 lines
18 KiB
TypeScript
/**
|
|
* 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,
|
|
// Prioridade do nome: agenda (contact.name) → nome do WhatsApp
|
|
// (verifiedName de Business → notify/pushName) → pushName da última msg.
|
|
contactName: raw.contact?.name
|
|
?? raw.contact?.verifiedName
|
|
?? raw.contact?.notify
|
|
?? raw.contactName
|
|
?? raw.lastMessage?.pushName
|
|
?? null,
|
|
contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null,
|
|
contactAvatarVersion: raw.contact?.avatarVersion ?? raw.contactAvatarVersion ?? 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
|
|
contactAvatarVersion: 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<string, Message[]>
|
|
messagesLoading: boolean
|
|
|
|
// Sync
|
|
setChats: (chats: Chat[]) => void
|
|
setChatsLoading: (v: boolean) => void
|
|
upsertChat: (chat: Chat) => void
|
|
patchChat: (chatId: string, patch: Partial<Chat>) => 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<Message>) => void
|
|
updateMediaReady: (msgId: string, mediaUrl: string) => void
|
|
|
|
// Async
|
|
loadChats: (instanceId: string, opts?: { search?: string; archived?: boolean }) => Promise<void>
|
|
loadMessages: (chatId: string, opts?: { before?: string }) => Promise<{ hasMore: boolean }>
|
|
selectChat: (chatId: string) => Promise<void>
|
|
sendMessage: (instanceId: string, jid: string, text: string, replyToMessageId?: string, mentions?: string[]) => Promise<void>
|
|
|
|
// 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<ChatState>()(
|
|
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)
|
|
}
|
|
})
|
|
},
|
|
}))
|
|
)
|