// WaInboxView — Inbox WhatsApp do scoreodonto, consumindo o motor NewWhats via // proxy do backend (/api/nw/v1/* → motor /api/ext/v1/*). Estágio 1: lista de // conversas + thread + envio. Visual aproximado do motor (WhatsApp-like). import React, { useEffect, useRef, useState } from 'react'; import { PageHeader } from '../components/PageHeader.tsx'; const API = (import.meta as any).env?.VITE_API_URL || '/api'; async function nw(path: string, opts: RequestInit = {}) { const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); const res = await fetch(`${API}/nw/v1${path}`, { ...opts, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}), }, }); if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`.slice(0, 200)); return res.json(); } // URL do túnel WS (/api/nw/v1/stream → motor). Deriva o host do API (relativo ou // absoluto) e troca http(s)→ws(s). O JWT vai no query porque o browser não envia // Authorization no handshake de WebSocket. function streamUrl(token: string) { const base = API.startsWith('http') ? API : `${window.location.origin}${API}`; const u = new URL(`${base}/nw/v1/stream`); u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:'; u.searchParams.set('token', token); return u.toString(); } interface Chat { id: string | number; remote_jid?: string; displayName?: string; phone?: string; last_message_text?: string | null; unread_count?: number } interface Msg { id: string | number; text?: string; direction?: 'in' | 'out'; from_me?: number; timestamp?: number; type?: string } // Normaliza a mensagem do stream (shape do motor) para o Msg do inbox. function normalizeWsMsg(m: any): Msg { return { id: m?.id ?? `ws-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, text: m?.body ?? m?.text, from_me: (m?.fromMe === true || m?.fromMe === 1) ? 1 : 0, direction: m?.fromMe ? 'out' : 'in', type: m?.type, timestamp: m?.timestamp, }; } // Atualiza a lista de conversas com uma mensagem nova: preview, badge de unread // (só quando não é a conversa aberta e não é enviada por nós) e sobe pro topo. function bumpChat(list: Chat[], chatId: any, m: Msg, countUnread: boolean): Chat[] { const i = list.findIndex((c) => String(c.id) === String(chatId)); if (i === -1) return list; // conversa ainda não carregada — aparece no próximo refresh const updated: Chat = { ...list[i], last_message_text: m.text || `[${m.type || 'mídia'}]` }; if (countUnread && m.from_me !== 1) updated.unread_count = (updated.unread_count || 0) + 1; return [updated, ...list.filter((_, idx) => idx !== i)]; } export const WaInboxView: React.FC = () => { const [sessions, setSessions] = useState([]); const [session, setSession] = useState(''); const [chats, setChats] = useState([]); const [active, setActive] = useState(null); const [msgs, setMsgs] = useState([]); const [text, setText] = useState(''); const [err, setErr] = useState(null); const [loading, setLoading] = useState(false); const [wsUp, setWsUp] = useState(false); const [qr, setQr] = useState(null); const endRef = useRef(null); // Ref com a conversa aberta para o handler do WS ler sempre o valor atual // (evita closure velha dentro do onmessage). const activeRef = useRef(null); useEffect(() => { activeRef.current = active; }, [active]); // Tempo real: conecta ao túnel WS e reflete eventos do motor sem refetch. useEffect(() => { const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); if (!token) return; let ws: WebSocket | null = null; let retry: ReturnType | null = null; let closed = false; const connect = () => { try { ws = new WebSocket(streamUrl(token)); } catch { return; } ws.onopen = () => setWsUp(true); ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } }; ws.onclose = () => { setWsUp(false); if (!closed) retry = setTimeout(connect, 3000); // reconexão simples }; ws.onmessage = (ev) => { let env: any; try { env = JSON.parse(ev.data); } catch { return; } const { event, data } = env || {}; if (event === 'message.new') { const norm = normalizeWsMsg(data?.message); const isActive = !!activeRef.current && String(data?.chatId) === String(activeRef.current.id); if (isActive) { setMsgs((p) => p.some((x) => String(x.id) === String(norm.id)) ? p : [...p, norm]); setTimeout(() => endRef.current?.scrollIntoView(), 50); } setChats((prev) => bumpChat(prev, data?.chatId, norm, !isActive)); } else if (event === 'session.status') { setSessions((prev) => prev.map((s) => String(s.id || s.instance) === String(data?.instanceId) ? { ...s, status: data?.status } : s)); if (data?.status === 'connected') setQr(null); } else if (event === 'session.qr') { setQr(data?.qrBase64 || null); } }; }; connect(); return () => { closed = true; if (retry) clearTimeout(retry); try { ws?.close(); } catch { /* ignore */ } }; }, []); useEffect(() => { nw('/sessions').then((s) => { const list = Array.isArray(s) ? s : []; setSessions(list); const conn = list.find((i: any) => String(i.status).toLowerCase() === 'connected') || list[0]; if (conn) setSession(conn.id || conn.instance); }).catch((e) => setErr(String(e.message))); }, []); useEffect(() => { if (!session) return; setLoading(true); nw(`/inbox?session=${encodeURIComponent(session)}&limit=50`) .then((c) => setChats(Array.isArray(c) ? c : (c.chats ?? []))) .catch((e) => setErr(String(e.message))) .finally(() => setLoading(false)); }, [session]); const openChat = async (c: Chat) => { setActive(c); setMsgs([]); setChats((prev) => prev.map((x) => String(x.id) === String(c.id) ? { ...x, unread_count: 0 } : x)); try { const m = await nw(`/inbox/${encodeURIComponent(String(c.id))}/messages?limit=50`); setMsgs(Array.isArray(m) ? m : (m.messages ?? [])); setTimeout(() => endRef.current?.scrollIntoView(), 50); } catch (e: any) { setErr(String(e.message)); } }; const send = async () => { if (!active || !text.trim()) return; const body = text.trim(); setText(''); try { await nw(`/inbox/${encodeURIComponent(String(active.id))}/send`, { method: 'POST', body: JSON.stringify({ text: body }) }); setMsgs((p) => [...p, { id: `tmp-${Date.now()}`, text: body, from_me: 1, direction: 'out', timestamp: Date.now() }]); setTimeout(() => endRef.current?.scrollIntoView(), 50); } catch (e: any) { setErr(String(e.message)); } }; return (
{err &&
Erro: {err}. Verifique a configuração do plugin (super-admin) e o pareamento com o motor.
}
{/* Lista de conversas */}
{wsUp ? 'Tempo real ativo' : 'Conectando tempo real…'}
{qr && (
Escaneie para conectar
QR Code
)}
{loading &&
Carregando…
} {chats.map((c) => (
openChat(c)} style={{ padding: '10px 12px', cursor: 'pointer', borderBottom: '1px solid #f3f4f6', background: active?.id === c.id ? '#f0fdf4' : 'transparent' }}>
{c.displayName || c.phone || c.remote_jid} {!!c.unread_count && {c.unread_count}}
{c.last_message_text || ''}
))} {!loading && !chats.length &&
Nenhuma conversa.
}
{/* Thread */}
{!active ? (
Selecione uma conversa
) : ( <>
{active.displayName || active.phone || active.remote_jid}
{msgs.map((m) => { const out = m.from_me === 1 || m.direction === 'out'; return (
{m.text || [{m.type || 'mídia'}]}
); })}
setText(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} placeholder="Mensagem" style={{ flex: 1, padding: '8px 12px', borderRadius: 20, border: '1px solid #d1d5db' }} />
)}
); };