Files
scoreodonto.com/frontend/views/newwhats/components/MessageArea.tsx
T
VPS 4 Builder 21bebd3256 feat(newwhats): ownership de sessões, auto-provisionamento, assinatura e drawer na agenda
- Drawer de WhatsApp focado (WhatsChatDrawer) aberto do slot do agendamento, com
  dropdown de números do paciente + grupo familiar e fluxo de iniciar conversa
- Auto-provisionamento de conta no motor: eager no cadastro/convite + self-heal no
  proxy (404 "conta não encontrada" → provisiona e refaz o request)
- Ownership por sessão (wa_session_authz): dono do workspace (owner_id ou vínculo
  donoclinica/donoconsultorio) tem poder total; delega re-scan, excluir sessão,
  apagar conversa/mensagem, acesso ao Inbox e à Secretária — por conta
- Enforcement no proxy: guardSessionAction (scan/rescan/delete sessão),
  guardAreaAccess (inbox/secretaria), guardInboxDestructive (apagar conversa/msg)
- Endpoints /api/nw/authz (dono gerencia) e /api/nw/access (acesso agregado)
- Assinatura do operador nas mensagens (*Nome*, desambiguação de homônimos → *Rui C.*)
- UI: painel "Gerenciar acesso" com 5 toggles; menu e botões de apagar condicionados
- Proxy de mídia /api/nw/media + re-download sob demanda

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:26:31 +02:00

278 lines
12 KiB
TypeScript

'use client';
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { ChevronDown, ArrowDown } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import MessageBubble from './MessageBubble';
import MediaViewer from './MediaViewer';
import { isSameDay, format, isToday, isYesterday } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { normalizeJid, parseSafeDate } from '../utils/inboxUtils';
import { Message } from '../types/inboxTypes';
import { mediaApi } from '../services/chatApiService';
import { useChatStore } from '../store/chatStore';
// Message interface removed and moved to types.ts
interface MessageAreaProps {
messages: Message[];
selectedChat: any;
scrollRef: React.RefObject<HTMLDivElement>;
onScroll: () => void;
msgLoading: boolean;
historyLoadingMore: boolean;
hasMoreHistory: boolean;
reactionPickerFor: string | null;
onReply: (msg: Message) => void;
onToggleReactionPicker: (msgId: string) => void;
onReact: (msg: Message, emoji: string) => void;
onForward?: (msg: Message) => void;
onDelete?: (msg: Message) => void;
onStar?: (msg: Message) => void;
onEdit?: (msg: Message) => void;
quickReactions: string[];
getGroupSenderLabel: (msg: Message) => string;
canDelete?: boolean;
}
const MessageArea: React.FC<MessageAreaProps> = ({
messages,
selectedChat,
scrollRef,
onScroll,
msgLoading,
historyLoadingMore,
hasMoreHistory,
reactionPickerFor,
onReply,
onToggleReactionPicker,
onReact,
onForward,
onDelete,
onStar,
onEdit,
quickReactions,
getGroupSenderLabel,
canDelete = true,
}) => {
const [showScrollBottom, setShowScrollBottom] = useState(false);
const lastScrollTop = useRef(0);
const needsInitialScroll = useRef(false);
// ── Media viewer state ────────────────────────────────────────────────────
const [viewerMsgId, setViewerMsgId] = useState<string | null>(null)
// ── Re-download de mídia não baixada ─────────────────────────────────────
// ~95% das mídias vêm com mediaUrl NULL (lazy). O motor re-baixa do WhatsApp
// e devolve { mediaUrl }; atualizamos o store direto pelo retorno (o WS
// message:media_ready não trafega pela ponte ext do satélite).
const updateMediaReady = useChatStore((s) => s.updateMediaReady)
const handleRedownloadMedia = useCallback(async (msg: Message) => {
// selectedChat no formato legado usa instance_name (não instanceId)
const instanceId = selectedChat?.instance_name ?? selectedChat?.instanceId
if (!instanceId || !msg.id) return
const { mediaUrl } = await mediaApi.redownload(instanceId, String(msg.id))
if (mediaUrl) updateMediaReady(String(msg.id), mediaUrl)
}, [selectedChat?.instance_name, selectedChat?.instanceId, updateMediaReady])
const mediaMessages = messages.filter(m => m.type === 'image' || m.type === 'video')
const handleInternalScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
setShowScrollBottom(!isAtBottom);
// Trigger parent onScroll (for infinite loading etc)
onScroll();
lastScrollTop.current = el.scrollTop;
}, [onScroll, scrollRef]);
const scrollToBottom = (smooth = true) => {
const el = scrollRef.current;
if (el) {
el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'instant' });
}
};
const formatDateSeparator = (date: Date) => {
try {
if (isToday(date)) return 'Hoje';
if (isYesterday(date)) return 'Ontem';
return format(date, "d 'de' MMMM 'de' yyyy", { locale: ptBR });
} catch (e) {
return 'Histórico';
}
};
const isGroupChat = Boolean(selectedChat?.remote_jid?.endsWith('@g.us'));
const chatId = selectedChat?.remote_jid ?? null;
// Marca que precisa scroll ao fundo quando trocar de chat
useEffect(() => {
needsInitialScroll.current = true;
}, [chatId]);
// Auto-scroll: roda sempre que messages muda
useEffect(() => {
const el = scrollRef.current;
if (!el || messages.length === 0) return;
if (needsInitialScroll.current) {
// Carga inicial após selectChat → scroll instantâneo ao fundo
needsInitialScroll.current = false;
// Triplo rAF: aguarda React render + layout + paint
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
});
});
} else {
// Nova mensagem em tempo real → só rola se já estiver perto do fundo
const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 250;
if (isNearBottom) {
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
});
}
}
}, [messages, scrollRef]);
return (
<>
<div className="flex-1 relative flex flex-col min-h-0 bg-transparent overflow-hidden">
<div
className="flex-1 overflow-y-auto px-4 md:px-14 py-6 flex flex-col custom-scrollbar relative z-10"
ref={scrollRef}
onScroll={handleInternalScroll}
>
{/* Loader for history */}
{historyLoadingMore && (
<div className="flex justify-center py-4">
<div className="w-5 h-5 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin" />
</div>
)}
{(() => {
const seen = new Set<string>();
return messages
.filter(m => {
// Reactions não são mensagens visuais — são metadados
if (m.type === 'reaction') return false;
const key = m.message_id || m.id?.toString();
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
})
.map((msg, idx) => {
const msgDate = parseSafeDate(msg.timestamp);
const prevMsg = messages[idx - 1];
const prevDate = prevMsg ? parseSafeDate(prevMsg.timestamp) : null;
const showDateSeparator = !prevDate || !isSameDay(prevDate, msgDate);
// Authentic WA Tail Logic: Show tail on the first message of a sequence from the same sender
const isSameSenderAsPrev = prevMsg && (prevMsg.direction === msg.direction);
const showTail = showDateSeparator || !isSameSenderAsPrev;
const senderLabel = isGroupChat ? getGroupSenderLabel(msg) : '';
// Resolve Quoted Sender
let quotedSenderLabel = 'Mensagem';
if (msg.quoted_participant) {
const qp = normalizeJid(msg.quoted_participant);
const chatJid = normalizeJid(selectedChat?.remote_jid || '');
if (qp === chatJid) quotedSenderLabel = selectedChat?.subject || 'Contato';
else quotedSenderLabel = 'Você';
}
return (
<React.Fragment key={msg.message_id || msg.id}>
{showDateSeparator && (
<div className="flex justify-center my-6">
<span className="px-3 py-1 bg-white text-[#54656f] text-[12.5px] font-normal uppercase tracking-tight rounded-lg shadow-sm border border-[#d1d7db]">
{formatDateSeparator(msgDate)}
</span>
</div>
)}
<MessageBubble
msg={msg}
isGroupChat={isGroupChat}
senderLabel={senderLabel}
showTail={showTail}
quotedSenderLabel={quotedSenderLabel}
quotedPreviewText={msg.quoted_message_text || 'Mídia'}
reactionPickerFor={reactionPickerFor}
onReply={onReply}
onToggleReactionPicker={onToggleReactionPicker}
onReact={onReact}
onForward={onForward}
onDelete={onDelete}
canDelete={canDelete}
onStar={onStar}
onEdit={onEdit}
quickReactions={quickReactions}
selectedChat={selectedChat}
onOpenMedia={(m) => setViewerMsgId(m.message_id)}
onRedownloadMedia={handleRedownloadMedia}
/>
</React.Fragment>
);
});
})()}
{/* Bottom Anchor */}
<div className="h-2 w-full shrink-0" />
</div>
{/* Scroll to Bottom Button */}
<AnimatePresence>
{showScrollBottom && (
<motion.button
initial={{ opacity: 0, scale: 0.8, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: 10 }}
onClick={() => scrollToBottom()}
className="absolute bottom-6 right-6 w-11 h-11 bg-white text-[#54656f] rounded-full shadow-lg flex items-center justify-center hover:bg-[#f0f2f5] transition-all z-30 border border-[#d1d7db] active:scale-90"
>
<ArrowDown className="w-5 h-5" />
{selectedChat?.unread_count > 0 && (
<span className="absolute -top-1 -right-1 bg-[#00a884] text-white text-[10px] min-w-[20px] h-5 px-1 flex items-center justify-center rounded-full font-bold border-2 border-white shadow-sm">
{selectedChat.unread_count}
</span>
)}
</motion.button>
)}
</AnimatePresence>
{/* Initial Loading Overlay */}
{msgLoading && messages.length === 0 && (
<div className="absolute inset-0 bg-white/50 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-[3px] border-[#00a884]/20 border-t-[#00a884] rounded-full animate-spin" />
<span className="text-[12px] font-medium text-[#667781]">Carregando mensagens...</span>
</div>
</div>
)}
</div>
{/* ── Media viewer portal ───────────────────────────────────────────── */}
{viewerMsgId && mediaMessages.length > 0 && (
<MediaViewer
msgs={mediaMessages}
currentMsgId={viewerMsgId}
onClose={() => setViewerMsgId(null)}
onReply={onReply}
onStar={onStar}
/>
)}
</>
);
};
export default MessageArea;