Files
clube67_newwhats.local/clube67/newwhats.local/frontend/components/MessageArea.tsx
T
VPS 4 Deploy Agent 539b922ce3 feat(media): re-download de mídia não baixada ao clicar
- Armazena payload WAMessage (serializado com _b64 para Uint8Array) em
  messages.mediaPayload no momento da recepção da mensagem
- Nova rota POST /instances/:id/redownload-media/:msgId que usa
  sock.updateMediaMessage para renovar URL expirada + re-upload Wasabi
- Frontend: placeholders clicáveis (spinner + texto) para imagem, vídeo,
  áudio, documento e figurinha sem media_url; atualização automática via
  socket message:media_ready existente

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 18:58:01 +02:00

268 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';
// 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;
}
const MessageArea: React.FC<MessageAreaProps> = ({
messages,
selectedChat,
scrollRef,
onScroll,
msgLoading,
historyLoadingMore,
hasMoreHistory,
reactionPickerFor,
onReply,
onToggleReactionPicker,
onReact,
onForward,
onDelete,
onStar,
onEdit,
quickReactions,
getGroupSenderLabel,
}) => {
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 ─────────────────────────────────────
const handleRedownloadMedia = useCallback(async (msg: Message) => {
const instanceId = selectedChat?.instanceId
if (!instanceId || !msg.id) return
await mediaApi.redownload(instanceId, String(msg.id))
}, [selectedChat?.instanceId])
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}
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;