Files
clube67_newwhats.local/clube67/newwhats.local/frontend/components/MessageBubble.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

977 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useMemo, useState, useRef, useCallback } from 'react';
import {
Check,
CheckCheck,
Clock,
AlertCircle,
ImageIcon,
Video,
FileText,
Mic,
Reply,
MoreHorizontal,
ChevronDown,
Forward,
Star,
Trash2,
Link,
Copy,
Phone,
List,
ChevronRight,
BarChart2,
X,
Download,
} from 'lucide-react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { format } from 'date-fns';
import { Message, RichPayload, NativeBtn } from '../types/inboxTypes';
import { normalizeJid, formatPhone, getMediaUrl, parseSafeDate } from '../utils/inboxUtils';
import LinkPreview, { extractFirstUrl, renderTextWithLinks } from './LinkPreview';
import Avatar from './ui/Avatar';
// ── AudioBubble ───────────────────────────────────────────────────────────────
interface AudioBubbleProps {
msg: Message
fullMediaUrl: string | null
isOut: boolean
TimestampRow: React.ReactNode
}
const WAVEFORM_BARS = 30
const AudioBubble: React.FC<AudioBubbleProps> = ({ msg, fullMediaUrl, isOut, TimestampRow }) => {
const audioRef = useRef<HTMLAudioElement>(null)
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [speed, setSpeed] = useState<1 | 1.5 | 2>(1)
const [played, setPlayed] = useState(false)
const toggle = useCallback(() => {
const el = audioRef.current
if (!el) return
if (playing) {
el.pause()
setPlaying(false)
} else {
el.play().catch(() => {})
setPlaying(true)
setPlayed(true)
}
}, [playing])
const cycleSpeed = useCallback(() => {
const el = audioRef.current
const next: 1 | 1.5 | 2 = speed === 1 ? 1.5 : speed === 1.5 ? 2 : 1
setSpeed(next)
if (el) el.playbackRate = next
}, [speed])
const formatTime = (s: number) => {
if (!isFinite(s) || s <= 0) return '0:00'
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, '0')}`
}
const progress = duration > 0 ? currentTime / duration : 0
// Incoming unread PTT = green; played or outgoing = blue
const isPtt = msg.type === 'ptt'
const accent = isPtt && !isOut && !played ? '#00a884' : '#53bdeb'
// Deterministic waveform heights seeded from message_id
const waveHeights = useMemo(() => {
const seed = msg.message_id.split('').reduce((a, c) => (a + c.charCodeAt(0)) % 97, 0)
return Array.from({ length: WAVEFORM_BARS }, (_, i) => {
const v = Math.abs(Math.sin((i + seed) * 0.85)) * 0.55 + Math.abs(Math.sin((i * 2.3 + seed) * 0.4)) * 0.45
return Math.max(0.15, Math.min(1, v))
})
}, [msg.message_id])
return (
<div>
<audio
ref={audioRef}
src={fullMediaUrl || undefined}
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
onDurationChange={() => setDuration(audioRef.current?.duration ?? 0)}
onEnded={() => { setPlaying(false); setCurrentTime(0) }}
preload="metadata"
/>
<div className="flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px]">
{/* Play/pause */}
<button
onClick={toggle}
className="w-10 h-10 rounded-full flex items-center justify-center shrink-0 shadow-sm transition-transform active:scale-95"
style={{ backgroundColor: accent }}
>
{playing ? (
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
<rect x="5" y="3" width="4" height="18" rx="1" />
<rect x="15" y="3" width="4" height="18" rx="1" />
</svg>
) : (
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
<polygon points="6,3 20,12 6,21" />
</svg>
)}
</button>
{/* Waveform + meta */}
<div className="flex-1 flex flex-col gap-1 min-w-0">
<div className="flex items-end gap-[2px] h-7">
{waveHeights.map((h, i) => (
<div
key={i}
className="flex-1 rounded-full transition-colors duration-100"
style={{
height: `${h * 100}%`,
backgroundColor: i / WAVEFORM_BARS <= progress ? accent : '#cdd4da',
}}
/>
))}
</div>
<div className="flex items-center justify-between">
<span className="text-[11px] text-[#667781] tabular-nums leading-none">
{currentTime > 0 ? formatTime(currentTime) : formatTime(duration)}
</span>
<button
onClick={cycleSpeed}
className="text-[11px] font-bold text-[#667781] hover:text-[#111b21] transition-colors leading-none"
>
{speed === 1 ? '1×' : speed === 1.5 ? '1.5×' : '2×'}
</button>
</div>
</div>
</div>
{TimestampRow}
</div>
)
}
// ── DocumentBubble ────────────────────────────────────────────────────────────
interface DocumentBubbleProps {
msg: Message
fullMediaUrl: string | null
TimestampRow: React.ReactNode
onRedownload?: () => void
isRedownloading?: boolean
}
const DOC_TYPES: Record<string, { bg: string; label: string }> = {
pdf: { bg: '#e53e3e', label: 'PDF' },
doc: { bg: '#2b6cb0', label: 'W' },
docx: { bg: '#2b6cb0', label: 'W' },
xls: { bg: '#276749', label: 'X' },
xlsx: { bg: '#276749', label: 'X' },
ppt: { bg: '#c05621', label: 'P' },
pptx: { bg: '#c05621', label: 'P' },
txt: { bg: '#718096', label: 'TXT' },
csv: { bg: '#276749', label: 'CSV' },
}
const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, TimestampRow, onRedownload, isRedownloading }) => {
const [previewOpen, setPreviewOpen] = useState(false)
// Derive extension from media_type or media_url
const ext = (() => {
const fromMime = msg.media_type?.split('/').pop()?.replace(/[^a-z0-9]/gi, '') ?? ''
const fromUrl = fullMediaUrl?.split('?')[0].split('.').pop()?.toLowerCase() ?? ''
return (fromMime || fromUrl || 'file').toLowerCase().slice(0, 5)
})()
const docType = DOC_TYPES[ext] ?? { bg: '#00a884', label: ext.toUpperCase().slice(0, 3) || 'FILE' }
// Filename: prefer msg.text; fallback to last segment of URL
const filename = msg.text?.trim() ||
fullMediaUrl?.split('?')[0].split('/').pop()?.replace(/^\d+-/, '') ||
'Documento'
const sizeStr = msg.media_size && msg.media_size > 0
? msg.media_size >= 1024 * 1024
? `${(msg.media_size / 1024 / 1024).toFixed(1)} MB`
: `${Math.ceil(msg.media_size / 1024)} KB`
: null
const isPdf = ext === 'pdf'
const handleClick = () => {
if (!fullMediaUrl) {
onRedownload?.()
return
}
if (isPdf) {
setPreviewOpen(true)
} else {
window.open(fullMediaUrl, '_blank')
}
}
return (
<>
<div>
<div
onClick={handleClick}
className="flex items-center gap-3 bg-[#f0f2f5] p-3 rounded-xl border border-black/5 cursor-pointer hover:bg-[#e8eaed] transition-all active:scale-[0.98] min-w-[220px] max-w-[280px]"
>
{/* Type icon */}
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 shadow-sm"
style={{ backgroundColor: docType.bg }}
>
{isRedownloading
? <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <span className="text-white text-[10px] font-extrabold tracking-tight leading-none">{docType.label}</span>
}
</div>
{/* Filename + meta */}
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[13.5px] font-normal truncate text-[#111b21] leading-snug">
{filename}
</span>
<span className="text-[11px] text-[#667781] mt-[2px]">
{isRedownloading ? 'Baixando...' : `${ext.toUpperCase()}${sizeStr ? ` · ${sizeStr}` : ''}`}
</span>
</div>
<Download className="w-4 h-4 text-[#8696a0] shrink-0" />
</div>
{TimestampRow}
</div>
{/* PDF inline preview modal — rendered via portal to escape framer-motion stacking context */}
{previewOpen && fullMediaUrl && typeof document !== 'undefined' && createPortal(
<div
className="fixed inset-0 z-[9999] flex flex-col bg-black/80 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) setPreviewOpen(false) }}
>
<div className="flex items-center justify-between px-4 py-2 bg-[#111b21] shrink-0">
<span className="text-white text-[13px] font-medium truncate max-w-[70%]">{filename}</span>
<div className="flex items-center gap-3">
<a
href={fullMediaUrl}
download={filename}
className="text-[#8696a0] hover:text-white transition-colors"
onClick={(e) => e.stopPropagation()}
>
<Download className="w-5 h-5" />
</a>
<button
onClick={() => setPreviewOpen(false)}
className="text-[#8696a0] hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<iframe
src={fullMediaUrl}
className="flex-1 w-full border-0"
title={filename}
/>
</div>,
document.body
)}
</>
)
}
// Message interface removed and moved to types.ts
interface MessageBubbleProps {
msg: Message;
isGroupChat: boolean;
senderLabel: string;
showTail?: boolean;
quotedSenderLabel?: string;
quotedPreviewText?: string;
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;
onOpenMedia?: (msg: Message) => void;
onRedownloadMedia?: (msg: Message) => Promise<void>;
quickReactions: string[];
selectedChat?: any;
}
const MessageBubble: React.FC<MessageBubbleProps> = ({
msg,
isGroupChat,
senderLabel,
quotedSenderLabel,
quotedPreviewText,
reactionPickerFor,
onReply,
onToggleReactionPicker,
onReact,
onForward,
onDelete,
onStar,
onEdit,
onOpenMedia,
onRedownloadMedia,
quickReactions,
selectedChat,
showTail = false,
}) => {
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const [listOpen, setListOpen] = useState(false);
const [isRedownloading, setIsRedownloading] = useState(false);
const handleRedownload = useCallback(async () => {
if (!onRedownloadMedia || isRedownloading) return
setIsRedownloading(true)
try {
await onRedownloadMedia(msg)
} finally {
setIsRedownloading(false)
}
}, [onRedownloadMedia, msg, isRedownloading])
const isOut = msg.direction === 'out';
const showActionsLeft = isOut;
const showActionsRight = !isOut;
// variant: 'dark' = texto escuro (balão claro), 'white' = overlay sobre imagem
const renderMessageStatus = (status: string, variant: 'dark' | 'white' = 'dark') => {
const c = variant === 'white'
? { pending: 'text-white/50', sent: 'text-white/70', check2: 'text-white/70', read: 'text-[#53bdeb]', fail: 'text-red-300' }
: { pending: 'text-[#8696a0]/60', sent: 'text-[#8696a0]', check2: 'text-[#8696a0]', read: 'text-[#53bdeb]', fail: 'text-[#ef4444]' }
switch (status) {
case 'pending':
// Mensagem enfileirada — ainda não chegou ao servidor
return <Clock className={`w-3 h-3 ${c.pending}`} />
case 'sent':
// Chegou ao servidor — não sabemos se foi entregue
return <Check className={`w-3.5 h-3.5 ${c.sent}`} />
case 'delivered':
// Entregue no dispositivo do destinatário
return <CheckCheck className={`w-3.5 h-3.5 ${c.check2}`} />
case 'read':
// Lido pelo destinatário
return <CheckCheck className={`w-3.5 h-3.5 ${c.read}`} />
case 'failed':
return <span title="Falha no envio"><AlertCircle className={`w-3.5 h-3.5 ${c.fail}`} /></span>
default:
return null
}
};
// ── Timestamp helpers ─────────────────────────────────────────────────────
const timeStr = format(parseSafeDate(msg.timestamp), 'HH:mm');
// Espaçador invisível — empurra o texto da última linha para criar espaço
// para o timestamp sem sobrepor o conteúdo (técnica usada pelo WhatsApp oficial)
// outgoing: tempo (~38px) + gap + check (~14px) = ~62px
// incoming: tempo somente (~42px)
const spacerW = isOut ? 62 : 42;
const TimespacerEl = (
<span
className="inline-block pointer-events-none select-none opacity-0 align-bottom"
style={{ width: spacerW, height: 14 }}
aria-hidden
/>
);
// Timestamp padrão (sobre fundo claro)
const TimestampEl = (
<span className="inline-flex items-center gap-[3px] text-[11px] text-[#667781] font-normal leading-none select-none flex-shrink-0">
{timeStr}
{isOut && renderMessageStatus(msg.status)}
</span>
);
// Timestamp para overlay em fundo escuro (imagem/vídeo sem legenda)
const TimestampWhiteEl = (
<span className="inline-flex items-center gap-[3px] text-[11px] text-white/90 font-normal leading-none select-none flex-shrink-0 drop-shadow">
{timeStr}
{isOut && renderMessageStatus(msg.status, 'white')}
</span>
);
// Linha de timestamp abaixo do conteúdo (áudio, documento, sticker, etc.)
const TimestampRow = (
<div className="flex items-center justify-end gap-[3px] mt-1 -mb-0.5 pr-0.5">
{TimestampEl}
</div>
);
// ── Rich message renderers ────────────────────────────────────────────────
const renderRichButtons = (p: RichPayload) => {
const btns = p.nativeButtons ?? []
const btnIcon = (b: NativeBtn) => {
if (b.type === 'url') return <Link className="w-3.5 h-3.5 shrink-0" />
if (b.type === 'copy') return <Copy className="w-3.5 h-3.5 shrink-0" />
if (b.type === 'call') return <Phone className="w-3.5 h-3.5 shrink-0" />
return null
}
return (
<div>
{(p.text || p.footer) && (
<div className="px-1 pt-1 pb-1">
{p.text && (
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{renderTextWithLinks(p.text)}
</p>
)}
{p.footer && <p className="text-[12px] text-[#667781] mt-0.5">{p.footer}</p>}
</div>
)}
<div className="border-t border-black/10 mt-1">
{btns.map((b, i) => (
<div
key={i}
className={`flex items-center justify-center gap-1.5 py-2 px-3 text-[13px] font-medium text-[#00a884] ${i > 0 ? 'border-t border-black/10' : ''}`}
>
{btnIcon(b)}
<span>{b.text}</span>
</div>
))}
</div>
{TimestampRow}
</div>
)
}
const renderRichList = (p: RichPayload) => {
const open = listOpen
const setOpen = setListOpen
const list = p.nativeList!
return (
<div>
{(p.text || p.footer) && (
<div className="px-1 pt-1 pb-1">
{p.text && (
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{renderTextWithLinks(p.text)}
</p>
)}
{p.footer && <p className="text-[12px] text-[#667781] mt-0.5">{p.footer}</p>}
</div>
)}
<div className="border-t border-black/10 mt-1">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-center gap-1.5 py-2 px-3 text-[13px] font-medium text-[#00a884]"
>
<List className="w-3.5 h-3.5 shrink-0" />
<span>{list.buttonText}</span>
<ChevronRight className={`w-3.5 h-3.5 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
</button>
{open && (
<div className="border-t border-black/10 bg-black/[0.02] rounded-b-lg">
{list.sections.map((s, si) => (
<div key={si}>
{s.title && <div className="px-3 py-1.5 text-[11px] font-bold text-[#667781] uppercase tracking-wide">{s.title}</div>}
{s.rows.map((r, ri) => (
<div key={ri} className="px-3 py-2 border-t border-black/5">
<div className="text-[13px] font-medium text-[#111b21]">{r.title}</div>
{r.description && <div className="text-[12px] text-[#667781] mt-0.5">{r.description}</div>}
</div>
))}
</div>
))}
</div>
)}
</div>
{TimestampRow}
</div>
)
}
const renderRichCarousel = (p: RichPayload) => {
const cards = p.nativeCarousel?.cards ?? []
return (
<div className="max-w-[320px]">
{p.text && (
<div className="px-1 pt-1 pb-1">
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{renderTextWithLinks(p.text)}
</p>
</div>
)}
<div className="flex gap-2 overflow-x-auto pb-1 snap-x snap-mandatory px-1 scrollbar-hide">
{cards.map((card, ci) => (
<div key={ci} className="snap-start shrink-0 w-[200px] bg-white border border-black/10 rounded-xl overflow-hidden shadow-sm">
{card.image?.url && (
<img
src={card.image.url}
alt={card.title ?? ''}
className="w-full h-28 object-cover"
/>
)}
<div className="p-2">
{card.title && <div className="text-[13px] font-semibold text-[#111b21] truncate">{card.title}</div>}
{card.body && <div className="text-[12px] text-[#667781] mt-0.5 line-clamp-2">{card.body}</div>}
{card.footer && <div className="text-[11px] text-[#8696a0] mt-0.5">{card.footer}</div>}
{(card.buttons ?? []).length > 0 && (
<div className="mt-2 border-t border-black/10 pt-1.5 flex flex-col gap-1">
{card.buttons.map((b, bi) => (
<div key={bi} className="text-center text-[12px] font-medium text-[#00a884] py-0.5">{b.text}</div>
))}
</div>
)}
</div>
</div>
))}
</div>
{TimestampRow}
</div>
)
}
const renderRichPoll = (p: RichPayload) => {
const poll = p.poll!
return (
<div className="min-w-[220px]">
<div className="px-1 pt-1 pb-1">
<div className="flex items-center gap-1.5 text-[12px] text-[#667781] font-medium mb-1.5">
<BarChart2 className="w-3.5 h-3.5" />
<span>ENQUETE</span>
</div>
<p className="text-[14.2px] font-semibold text-[#111b21] mb-2">{poll.name}</p>
<div className="flex flex-col gap-1.5">
{poll.values.map((v, i) => (
<div key={i} className="flex items-center gap-2 py-1.5 px-2 border border-black/10 rounded-lg text-[13px] text-[#111b21] hover:bg-black/5 cursor-pointer transition-colors">
<div className="w-4 h-4 rounded-full border-2 border-[#8696a0] shrink-0" />
{v}
</div>
))}
</div>
{poll.selectableCount && poll.selectableCount > 1 && (
<div className="text-[11px] text-[#8696a0] mt-1.5">Selecione até {poll.selectableCount} opções</div>
)}
</div>
{TimestampRow}
</div>
)
}
const renderMessageContent = () => {
const fullMediaUrl = getMediaUrl(msg.media_url);
// ── Rich message types ──────────────────────────────────────────────
const rp = msg.rich_payload
// Mensagem outgoing com payload rico salvo
if (rp) {
if (msg.type === 'buttons') return renderRichButtons(rp)
if (msg.type === 'list') return renderRichList(rp)
if (msg.type === 'carousel') return renderRichCarousel(rp)
if (msg.type === 'poll' && rp.poll) return renderRichPoll(rp)
// INTERACTIVE (url/copy/call) também usa renderRichButtons
if (msg.type === 'interactive' && rp.nativeButtons) return renderRichButtons(rp)
}
// Resposta de clique de botão / seleção de lista recebida (incoming)
if (msg.type === 'interactive' && !rp && msg.text) {
return (
<div>
<div className="flex items-center gap-1.5 text-[12px] text-[#667781] mb-1">
<ChevronRight className="w-3.5 h-3.5 text-[#00a884]" />
<span className="font-medium text-[#00a884]">Selecionou:</span>
</div>
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{msg.text}
{TimespacerEl}
</p>
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
</div>
)
}
// Mensagem interativa incoming (botões/lista enviados por outro BA)
if (msg.type === 'buttons' && !rp && msg.text) {
return (
<div>
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{renderTextWithLinks(msg.text)}
{TimespacerEl}
</p>
<div className="flex items-center gap-1 text-[11px] text-[#8696a0] mt-1">
<span>📋</span><span>Mensagem com botões</span>
</div>
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
</div>
)
}
switch (msg.type) {
case 'image': {
const hasCaption = Boolean(msg.text?.trim());
return (
<div className="rounded-lg overflow-hidden w-[268px]">
<div
className="relative group/media w-[268px] h-[200px] bg-black/5 cursor-pointer overflow-hidden"
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : handleRedownload()}
>
{fullMediaUrl ? (
<img
src={fullMediaUrl}
alt="Media"
className="w-full h-full object-cover transition-transform group-hover/media:scale-[1.03] block"
/>
) : (
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
{isRedownloading
? <div className="w-8 h-8 border-2 border-slate-400 border-t-transparent rounded-full animate-spin" />
: <><ImageIcon className="w-8 h-8 text-slate-400" /><span className="text-[11px] text-slate-400">Toque para baixar</span></>
}
</div>
)}
{!hasCaption && (
<div className="absolute bottom-1.5 right-2 bg-black/40 backdrop-blur-[2px] rounded-full px-1.5 py-[3px]">
{TimestampWhiteEl}
</div>
)}
</div>
{hasCaption && (
<div className="relative px-1 pt-1.5 pb-0.5">
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{msg.text}
{TimespacerEl}
</p>
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
</div>
)}
</div>
);
}
case 'video': {
const hasCaption = Boolean(msg.text?.trim());
return (
<div className="rounded-lg overflow-hidden w-[268px]">
<div
className="relative w-[268px] h-[200px] bg-black cursor-pointer group/vid overflow-hidden"
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : handleRedownload()}
>
{fullMediaUrl ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 hover:bg-black/30 transition-colors">
<div className="w-12 h-12 bg-white/20 backdrop-blur-md rounded-full flex items-center justify-center text-white shadow-xl border border-white/30 group-hover/vid:scale-110 transition-transform">
<Video className="w-6 h-6 fill-current" />
</div>
</div>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 gap-2">
{isRedownloading
? <div className="w-8 h-8 border-2 border-white/60 border-t-transparent rounded-full animate-spin" />
: <><Video className="w-8 h-8 text-white/60" /><span className="text-[11px] text-white/60">Toque para baixar</span></>
}
</div>
)}
<div className="absolute bottom-0 inset-x-0 h-10 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />
{!hasCaption && (
<div className="absolute bottom-1.5 right-2">
{TimestampWhiteEl}
</div>
)}
</div>
{hasCaption && (
<div className="relative px-1 pt-1.5 pb-0.5">
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{msg.text}
{TimespacerEl}
</p>
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
</div>
)}
</div>
);
}
case 'audio':
case 'ptt':
if (!fullMediaUrl) {
return (
<div>
<div
onClick={handleRedownload}
className="flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] cursor-pointer hover:bg-[#e8eaed] transition-colors"
>
{isRedownloading
? <div className="w-8 h-8 border-2 border-slate-400 border-t-transparent rounded-full animate-spin shrink-0" />
: <div className="w-10 h-10 rounded-full bg-[#8696a0] flex items-center justify-center shrink-0"><Mic className="w-4 h-4 text-white" /></div>
}
<span className="text-[12px] text-[#667781]">{isRedownloading ? 'Baixando...' : 'Toque para baixar áudio'}</span>
</div>
{TimestampRow}
</div>
)
}
return <AudioBubble msg={msg} fullMediaUrl={fullMediaUrl} isOut={isOut} TimestampRow={TimestampRow} />;
case 'document':
return <DocumentBubble msg={msg} fullMediaUrl={fullMediaUrl} TimestampRow={TimestampRow} onRedownload={!fullMediaUrl ? handleRedownload : undefined} isRedownloading={isRedownloading} />;
case 'sticker':
return (
<div>
{fullMediaUrl
? <img src={fullMediaUrl} alt="Sticker" className="max-w-[180px] max-h-[180px]" />
: (
<div
onClick={handleRedownload}
className="flex items-center gap-2 text-[13px] text-[#667781] italic cursor-pointer hover:text-[#111b21] transition-colors"
>
{isRedownloading
? <div className="w-4 h-4 border-2 border-slate-400 border-t-transparent rounded-full animate-spin" />
: <Star className="w-4 h-4" />
}
{isRedownloading ? 'Baixando...' : 'Toque para baixar figurinha'}
</div>
)
}
{TimestampRow}
</div>
);
case 'location':
return (
<div>
<div className="flex items-center gap-2 text-[13px] text-[#667781] italic">
<span className="text-lg">📍</span> Localização compartilhada
</div>
{TimestampRow}
</div>
);
case 'contact':
return (
<div>
<div className="flex items-center gap-2 text-[13px] text-[#667781] italic">
<span className="text-lg">👤</span> {msg.text || 'Contato compartilhado'}
</div>
{TimestampRow}
</div>
);
case 'reaction':
return null;
case 'unsupported':
return (
<div>
<div className="flex items-center gap-2 text-[12px] text-[#8696a0] italic">
<AlertCircle className="w-3.5 h-3.5" /> Mensagem não suportada
</div>
{TimestampRow}
</div>
);
default: {
// text, extendedtext, etc.
if (!msg.text) {
return (
<div>
<div className="flex items-center gap-2 text-[12px] text-[#8696a0] italic">
<AlertCircle className="w-3.5 h-3.5" /> Mensagem não disponível
</div>
{TimestampRow}
</div>
);
}
return (
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{renderTextWithLinks(msg.text)}
<span style={{ float: 'right', marginLeft: 6, position: 'relative', bottom: -3 }}>
{TimestampEl}
</span>
</p>
);
}
}
};
const bubbleClasses = `px-2 py-1.5 rounded-lg shadow-[0_1px_0.5px_rgba(11,20,26,0.13)] relative group overflow-visible transition-all ${isOut
? `bg-[#d9fdd3] ${showTail ? 'rounded-tr-none' : ''}`
: `bg-white ${showTail ? 'rounded-tl-none' : ''}`
}`;
const tailStyles = showTail ? (
<div
className="absolute top-0 w-2.5 h-3.5 pointer-events-none"
style={{
[isOut ? 'right' : 'left']: '-8px',
backgroundColor: isOut ? '#d9fdd3' : '#ffffff',
clipPath: isOut ? 'polygon(0 0, 100% 0, 0 100%)' : 'polygon(100% 0, 0 0, 100% 100%)',
zIndex: 1
}}
/>
) : null;
return (
<motion.div
id={`msg-${msg.message_id}`}
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
className={`flex w-full mb-3 group/row ${isOut ? 'justify-end pr-4' : 'justify-start'}`}
>
{showActionsLeft && (
<div className="opacity-0 group-hover/row:opacity-100 transition-opacity flex flex-col items-center justify-center mr-2 gap-2">
<button onClick={() => onReply(msg)} className="p-1.5 hover:bg-white/5 rounded-full text-slate-500 transition-all hover:scale-110" title="Responder">
<Reply className="w-4 h-4" />
</button>
<button onClick={() => onToggleReactionPicker(msg.message_id)} className="p-1.5 hover:bg-white/5 rounded-full text-slate-500 transition-all hover:scale-110" title="Reagir">
<span className="text-xs">🙂</span>
</button>
</div>
)}
{!isOut && isGroupChat && senderLabel && (
<div className="flex-shrink-0 mr-2 mt-1 self-start">
<Avatar
chat={{
remote_jid: msg.sender_jid || null,
displayName: senderLabel,
phone: null,
instance_name: (selectedChat as any)?.instance_name
}}
size={28}
/>
</div>
)}
<div className="flex flex-col max-w-[75%] relative">
<div className={bubbleClasses}>
{tailStyles}
{/* Authentic WhatsApp Message Menu Button */}
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className={`absolute top-1 right-1 p-1 text-slate-500 opacity-0 group-hover:opacity-100 hover:text-slate-300 transition-opacity z-30`}
>
<ChevronDown className="w-5 h-5" />
</button>
{isGroupChat && senderLabel && !isOut && (
<div className="mb-1.5 text-[11px] font-black tracking-wider text-brand-400 uppercase">
{senderLabel}
</div>
)}
{/* Quoted Message */}
{(msg.quoted_message_id || msg.quoted_id) && (
<div className={`mb-2 p-2 rounded-lg border-l-[4px] border-[#00a884] text-[13px] cursor-pointer ${isOut ? 'bg-black/5' : 'bg-[#f0f2f5]'} hover:bg-black/10 transition-colors`}
onClick={() => {
const targetId = msg.quoted_message_id || msg.quoted_id;
const el = targetId ? document.getElementById(`msg-${targetId}`) : null;
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('bg-[#00a884]/20');
setTimeout(() => el.classList.remove('bg-[#00a884]/20'), 1500);
}
}}
>
<div className="font-bold text-[#00a884] mb-0.5 truncate">
{quotedSenderLabel}
</div>
<div className="text-[#667781] truncate">
{quotedPreviewText}
</div>
</div>
)}
{renderMessageContent()}
{/* Context Menu Dropdown */}
<AnimatePresence>
{isMenuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsMenuOpen(false)} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -10 }}
className={`absolute top-8 ${isOut ? 'right-2' : 'left-2'} w-48 bg-white rounded-lg shadow-2xl border border-gray-100 py-1.5 z-[100] origin-top-right`}
>
{[
{ label: 'Responder', icon: <Reply className="w-4 h-4" />, onClick: () => { onReply(msg); setIsMenuOpen(false); } },
...(isOut && msg.type === 'text' && onEdit ? [{ label: 'Editar', icon: <span className="w-4 h-4 font-bold text-xs"></span>, onClick: () => { onEdit(msg); setIsMenuOpen(false); } }] : []),
{ label: 'Encaminhar', icon: <Forward className="w-4 h-4" />, onClick: () => { onForward?.(msg); setIsMenuOpen(false); } },
{ label: 'Favoritar', icon: <Star className="w-4 h-4" />, onClick: () => { onStar?.(msg); setIsMenuOpen(false); } },
{ label: 'Apagar', icon: <Trash2 className="w-4 h-4" />, onClick: () => { onDelete?.(msg); setIsMenuOpen(false); }, danger: true },
].map((item, i) => (
<button
key={i}
onClick={item.onClick}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm font-bold transition-colors ${item.danger ? 'text-red-400 hover:bg-red-500/10' : 'text-slate-300 hover:bg-white/5'}`}
>
{item.icon}
{item.label}
</button>
))}
</motion.div>
</>
)}
</AnimatePresence>
{/* Reaction Picker Popover */}
<AnimatePresence>
{reactionPickerFor === msg.message_id && (
<>
<div className="fixed inset-0 z-40" onClick={() => onToggleReactionPicker('')} />
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.9 }}
>
<div className={`absolute top-[calc(100%+8px)] ${isOut ? 'right-0 origin-top-right' : 'left-0 origin-top'} z-[100] flex animate-in fade-in zoom-in duration-200`}>
<div className="flex items-center gap-1.5 p-2 bg-[#111b21] rounded-full shadow-2xl border border-white/10 backdrop-blur-xl">
{quickReactions.map((emoji) => (
<button
key={emoji}
onClick={(e) => {
e.stopPropagation();
onReact(msg, emoji);
onToggleReactionPicker('');
}}
className="w-10 h-10 flex items-center justify-center hover:bg-white/5 rounded-full transition-all hover:scale-125 active:scale-95 text-2xl"
>
{emoji}
</button>
))}
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
{/* Visible Reactions Badge */}
{msg.reactions && Object.keys(msg.reactions).length > 0 && (
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className={`mt-1 flex ${isOut ? 'justify-end mr-2' : 'justify-start ml-2'}`}
>
<div className="flex items-center bg-[#111b21] rounded-full px-2.5 py-1 shadow-2xl border border-white/5 gap-1.5 z-10 hover:scale-105 transition-transform cursor-pointer ring-1 ring-white/10 backdrop-blur-xl">
{Object.entries(msg.reactions).slice(0, 3).map(([sender, emoji], idx) => (
<span key={idx} title={sender} className="text-[13px]">{emoji}</span>
))}
{Object.keys(msg.reactions).length > 3 && (
<span className="text-[10px] text-slate-500 font-black">+{Object.keys(msg.reactions).length - 3}</span>
)}
</div>
</motion.div>
)}
</div>
{showActionsRight && (
<div className="opacity-0 group-hover/row:opacity-100 transition-opacity flex flex-col items-center justify-center ml-2 gap-2">
<button onClick={() => onReply(msg)} className="p-1.5 hover:bg-white/5 rounded-full text-slate-500 transition-all hover:scale-110" title="Responder">
<Reply className="w-4 h-4" />
</button>
<button onClick={() => onToggleReactionPicker(msg.message_id)} className="p-1.5 hover:bg-white/5 rounded-full text-slate-500 transition-all hover:scale-110" title="Reagir">
<span className="text-xs">🙃</span>
</button>
</div>
)}
</motion.div>
);
};
export default MessageBubble;