09edeb9558
- MessageBubble: carrega <url>.thumb.webp na prévia (fallback ao full via onError) - mediaUnavailable proativo via flag media_recoverable (evita 400 em mídia legada) - useNewInboxBridge: mapeia mediaRecoverable -> media_recoverable Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
288 lines
12 KiB
TypeScript
288 lines
12 KiB
TypeScript
/**
|
|
* useNewInboxBridge — adapta chatStore + instanceStore para o formato
|
|
* que os componentes de display legados já consomem.
|
|
* Nenhum componente visual precisa ser alterado.
|
|
*/
|
|
import { useMemo, useCallback, useEffect, useRef } from 'react'
|
|
import { useChatStore } from '../../store/chatStore'
|
|
import { useInstanceStore } from '../../store/instanceStore'
|
|
import { chatApi, messageApi, reconcileApi } from '../../services/chatApiService'
|
|
import { socketService } from '../../services/socketService'
|
|
import { formatPhone } from '../../utils/inboxUtils'
|
|
import type { NewWhatsChat } from '../../types/inboxTypes'
|
|
|
|
// ─── Mapeamentos legado ───────────────────────────────────────────────────────
|
|
|
|
function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]): NewWhatsChat {
|
|
const isGroup = c.jid.endsWith('@g.us')
|
|
const jidUser = c.jid.split('@')[0]
|
|
|
|
// Telefone formatado — sempre passa por formatPhone para normalizar E.164 → exibição
|
|
// c.contactPhone é o número cru do DB (ex: "5511999887766"); formatPhone aplica máscara
|
|
const formattedPhone = isGroup ? jidUser : formatPhone(c.contactPhone ?? jidUser, c.jid)
|
|
|
|
// 1. Nome de Exibição
|
|
// Regra estrita: grupos usam SEMPRE o subject (c.name) — nunca o
|
|
// pushName/contactName do último participante que enviou mensagem.
|
|
let displayName = isGroup
|
|
? (c.name || 'Grupo')
|
|
: (c.contactName || c.name || formattedPhone || jidUser)
|
|
|
|
// Limpeza final: se por algum motivo ainda vier o JID completo, removemos o sufixo
|
|
if (displayName.includes('@')) {
|
|
displayName = displayName.split('@')[0]
|
|
}
|
|
|
|
// 2. Avatar — o motor (ext /inbox) já devolve a URL pública do CDN do
|
|
// WhatsApp (pps.whatsapp.net) em contactAvatarUrl; usamos direto (o <img>
|
|
// carrega cross-origin sem CORS). Quando é null, Avatar.tsx mostra iniciais.
|
|
const avatar_url: string | null = c.contactAvatarUrl ?? null
|
|
|
|
const avatar_version: string | null = c.contactAvatarVersion ?? null
|
|
|
|
return {
|
|
id: c.id as any,
|
|
instance_name: c.instanceId,
|
|
remote_jid: c.jid,
|
|
phone: formattedPhone,
|
|
lead_name: c.contactName,
|
|
avatar_url,
|
|
avatar_version,
|
|
bio: isGroup ? 'Detalhes do Grupo' : (formattedPhone || 'Clique para informações'),
|
|
displayName,
|
|
subject: c.protocolNumber ? `#${c.protocolNumber}` : (isGroup ? null : formattedPhone),
|
|
last_message_text: c.lastMessageBody || null,
|
|
last_message_time: c.lastMessageAt || c.createdAt,
|
|
last_message_from_me: c.lastMessageFromMe ? 1 : 0,
|
|
last_message_status: c.lastMessageStatus?.toLowerCase() ?? null,
|
|
last_message_type: c.lastMessageType?.toLowerCase() ?? null,
|
|
last_message_sender: c.lastMessageSender ?? null,
|
|
unread_count: c.unreadCount,
|
|
is_pinned: c.isPinned,
|
|
is_archived: c.isArchived,
|
|
bot_paused: c.botPaused ?? null,
|
|
labels: [],
|
|
}
|
|
}
|
|
|
|
function messagesToLegacy(msgs: ReturnType<typeof useChatStore.getState>['messages'][string]) {
|
|
return (msgs ?? []).map((m) => ({
|
|
id: m.id as any,
|
|
chat_id: m.chatId as any,
|
|
message_id: m.messageId,
|
|
text: m.body ?? '',
|
|
direction: (m.fromMe ? 'out' : 'in') as 'in' | 'out',
|
|
timestamp: new Date(m.timestamp).getTime(),
|
|
status: m.status.toLowerCase(),
|
|
type: m.type.toLowerCase(),
|
|
media_url: m.mediaUrl,
|
|
// false só para mídias legadas sem WAMessage salvo (fromMe irrecuperável); default
|
|
// true para não bloquear mensagens vindas por outros caminhos (socket, etc.).
|
|
media_recoverable: (m as any).mediaRecoverable ?? true,
|
|
transcription: (m as any).transcription ?? null,
|
|
mime_type: m.mimeType,
|
|
reply_to_id: m.replyToId,
|
|
// Quoted message — mapeia replyTo do Prisma para o formato do MessageBubble
|
|
quoted_id: m.replyTo?.id ?? null,
|
|
quoted_message_id: m.replyTo?.messageId ?? null,
|
|
quoted_message_text: m.replyTo?.body ?? null,
|
|
quoted_participant: m.replyTo ? (m.replyTo.fromMe ? 'me' : null) : null,
|
|
participant: m.pushName ?? null,
|
|
senderJid: m.senderJid ?? null,
|
|
sender_jid: m.senderJid ?? null,
|
|
remote_jid: m.chatId,
|
|
from_me: m.fromMe ? 1 : 0,
|
|
rich_payload: (m as any).richPayload ?? null,
|
|
}))
|
|
}
|
|
|
|
// ─── Hook principal ───────────────────────────────────────────────────────────
|
|
|
|
export function useNewInboxBridge() {
|
|
const initialized = useRef(false)
|
|
const chatStore = useChatStore()
|
|
const instanceStore = useInstanceStore()
|
|
|
|
// ── Boot: socket + instâncias ─────────────────────────────────────────────
|
|
useEffect(() => {
|
|
if (initialized.current) return
|
|
initialized.current = true
|
|
|
|
// Satélite: o socketService lê o token do scoreodonto internamente.
|
|
socketService.connect()
|
|
chatStore.initSocketListeners()
|
|
instanceStore.initSocketListeners()
|
|
instanceStore.loadInstances()
|
|
}, [])
|
|
|
|
// ── Carregar chats quando instância ativa muda ─────────────────────────────
|
|
useEffect(() => {
|
|
const { activeInstanceId } = instanceStore
|
|
if (!activeInstanceId) return
|
|
chatStore.loadChats(activeInstanceId)
|
|
socketService.joinInstance(activeInstanceId)
|
|
// Reconcilia nomes em background (cache 30min — leve em escala)
|
|
reconcileApi.reconcileNames(activeInstanceId).catch(() => {})
|
|
}, [instanceStore.activeInstanceId])
|
|
|
|
// ── Adaptação: instâncias → legado ────────────────────────────────────────
|
|
const instancesLegacy = useMemo(
|
|
() =>
|
|
instanceStore.instances.map((i) => ({
|
|
instance: i.id,
|
|
nome: i.name,
|
|
status: i.status === 'CONNECTED' ? 'connected' : i.status.toLowerCase(),
|
|
phone: i.phone,
|
|
avatar: null,
|
|
profilePictureUrl: null,
|
|
})),
|
|
[instanceStore.instances]
|
|
)
|
|
|
|
const activeInstanceLegacy = useMemo(
|
|
() => instancesLegacy.find((i) => i.instance === instanceStore.activeInstanceId) ?? null,
|
|
[instancesLegacy, instanceStore.activeInstanceId]
|
|
)
|
|
|
|
// ── Adaptação: chats → legado ─────────────────────────────────────────────
|
|
const chatsLegacy = useMemo(() => chatStore.chats.map(chatToLegacy), [chatStore.chats])
|
|
|
|
const selectedChatLegacy = useMemo(() => {
|
|
if (!chatStore.activeChatId) return null
|
|
const c = chatStore.chats.find((c) => c.id === chatStore.activeChatId)
|
|
return c ? chatToLegacy(c) : null
|
|
}, [chatStore.activeChatId, chatStore.chats])
|
|
|
|
const messagesLegacy = useMemo(() => {
|
|
if (!chatStore.activeChatId) return []
|
|
const raw = chatStore.messages[chatStore.activeChatId] ?? []
|
|
const mapped = messagesToLegacy(raw)
|
|
console.log('[bridge] messagesLegacy recomputed:', { activeChatId: chatStore.activeChatId, rawCount: raw.length, mappedCount: mapped.length })
|
|
return mapped
|
|
}, [chatStore.activeChatId, chatStore.messages])
|
|
|
|
// ── Handlers ──────────────────────────────────────────────────────────────
|
|
|
|
const handleSelectChat = useCallback(async (chat: NewWhatsChat | null) => {
|
|
if (!chat) { useChatStore.getState().setActiveChat(null); return }
|
|
await chatStore.selectChat(String(chat.id))
|
|
// Assina a presença do contato (composing/recording) — só assim o motor emite.
|
|
chatApi.subscribePresence(String(chat.id)).catch(() => {})
|
|
}, [chatStore])
|
|
|
|
const handleSendText = useCallback(async (text: string, mentions?: string[], replyToMessageId?: string) => {
|
|
if (!chatStore.activeChatId || !instanceStore.activeInstanceId) return
|
|
const chat = chatStore.chats.find((c) => c.id === chatStore.activeChatId)
|
|
if (!chat) return
|
|
await chatStore.sendMessage(instanceStore.activeInstanceId, chat.jid, text, replyToMessageId, mentions)
|
|
}, [chatStore, instanceStore.activeInstanceId])
|
|
|
|
const handleLoadMoreHistory = useCallback(async () => {
|
|
if (!chatStore.activeChatId) return { hasMore: false }
|
|
const oldest = chatStore.messages[chatStore.activeChatId]?.[0]
|
|
if (!oldest) return { hasMore: false }
|
|
return chatStore.loadMessages(chatStore.activeChatId, { before: oldest.timestamp })
|
|
}, [chatStore])
|
|
|
|
const handleSetActiveInstance = useCallback((legacyInstance: any) => {
|
|
instanceStore.setActiveInstance(legacyInstance?.instance ?? null)
|
|
}, [instanceStore])
|
|
|
|
const handleRefreshInstances = useCallback(async () => {
|
|
await instanceStore.loadInstances()
|
|
}, [instanceStore])
|
|
|
|
const handleConnectInstance = useCallback(async (instanceId: string) => {
|
|
await instanceStore.connectInstance(instanceId)
|
|
}, [instanceStore])
|
|
|
|
const handleArchiveChat = useCallback(async (chatId: string, archived: boolean) => {
|
|
await chatApi.archive(chatId, archived)
|
|
useChatStore.getState().patchChat(chatId, { isArchived: archived })
|
|
}, [])
|
|
|
|
const handlePinChat = useCallback(async (chatId: string, pinned: boolean) => {
|
|
await chatApi.pin(chatId, pinned)
|
|
useChatStore.getState().patchChat(chatId, { isPinned: pinned })
|
|
}, [])
|
|
|
|
const handleDeleteChat = useCallback(async (chatId: string) => {
|
|
await chatApi.delete(chatId)
|
|
useChatStore.getState().setChats(
|
|
useChatStore.getState().chats.filter((c) => c.id !== chatId)
|
|
)
|
|
if (useChatStore.getState().activeChatId === chatId) {
|
|
useChatStore.getState().setActiveChat(null)
|
|
}
|
|
}, [])
|
|
|
|
const handleMarkRead = useCallback(async (chatId: string) => {
|
|
await chatApi.markRead(chatId)
|
|
useChatStore.getState().patchChat(chatId, { unreadCount: 0 })
|
|
}, [])
|
|
|
|
// Apaga a mensagem PARA TODOS (revoke no WhatsApp). Confirma antes; em erro (ex.:
|
|
// mensagem não é sua / instância offline) mostra o motivo. messageId = id interno.
|
|
const handleDeleteMessage = useCallback(async (chatId: string, messageId: string) => {
|
|
if (!chatId || !messageId) return
|
|
if (typeof window !== 'undefined' && !window.confirm('Apagar esta mensagem para todos? Esta ação não pode ser desfeita.')) return
|
|
try {
|
|
await useChatStore.getState().deleteMessage(chatId, messageId)
|
|
} catch (e: any) {
|
|
const m = e?.response?.data?.error || e?.message || 'Não foi possível apagar a mensagem.'
|
|
if (typeof window !== 'undefined') window.alert(m)
|
|
}
|
|
}, [])
|
|
|
|
const handleSearchMessages = useCallback(async (q: string) => {
|
|
if (!instanceStore.activeInstanceId || q.length < 2) return []
|
|
return messageApi.search(instanceStore.activeInstanceId, q)
|
|
}, [instanceStore.activeInstanceId])
|
|
|
|
// action é a string-key emitida por ChatListItem (ex: 'archive', 'pin', ...)
|
|
// onFavoriteToggle é injetado pelo componente pai (inbox page) pois favoritos são estado local
|
|
const handleMenuAction = useCallback((
|
|
action: string,
|
|
chat: NewWhatsChat,
|
|
onFavoriteToggle?: (jid: string) => void
|
|
) => {
|
|
const chatId = String(chat.id)
|
|
switch (action) {
|
|
case 'archive': handleArchiveChat(chatId, !chat.is_archived); break
|
|
case 'pin': handlePinChat(chatId, !chat.is_pinned); break
|
|
case 'mark_read': handleMarkRead(chatId); break
|
|
case 'delete': handleDeleteChat(chatId); break
|
|
case 'favorite': onFavoriteToggle?.(chat.remote_jid); break
|
|
}
|
|
}, [handleArchiveChat, handlePinChat, handleMarkRead, handleDeleteChat])
|
|
|
|
return {
|
|
instances: instancesLegacy,
|
|
activeInstance: activeInstanceLegacy,
|
|
chats: chatsLegacy,
|
|
selectedChat: selectedChatLegacy,
|
|
messages: messagesLegacy,
|
|
loadingChats: chatStore.chatsLoading,
|
|
loadingMessages: chatStore.messagesLoading,
|
|
activeInstanceId: instanceStore.activeInstanceId,
|
|
qrByInstance: instanceStore.qrByInstance,
|
|
|
|
setActiveInstance: handleSetActiveInstance,
|
|
refreshInstances: handleRefreshInstances,
|
|
handleSelectChat,
|
|
handleSendText,
|
|
handleLoadMoreHistory,
|
|
handleConnectInstance,
|
|
handleArchiveChat,
|
|
handlePinChat,
|
|
handleDeleteChat,
|
|
handleDeleteMessage,
|
|
handleMarkRead,
|
|
handleSearchMessages,
|
|
handleMenuAction,
|
|
|
|
presenceByJid: chatStore.presenceByJid as Record<string, any>,
|
|
isFavoriteChat: (_: NewWhatsChat) => false,
|
|
}
|
|
}
|