a8d404deb5
Estende o tratamento de erro 400/404 do redownload para todos os tipos de mídia. Quando mediaUnavailable=true (payload ausente no backend), cada tipo exibe sua mensagem específica e desabilita o clique: - Imagem: 'Imagem não disponível' - Vídeo: 'Vídeo não disponível' - Documento: 'Arquivo não disponível' + cursor-default - Sticker: 'Figurinha não disponível' DocumentBubble recebe nova prop mediaUnavailable para controle interno. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
985 lines
48 KiB
TypeScript
985 lines
48 KiB
TypeScript
'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>
|
||
|
||
{/* Speed — antes do waveform, alinhado ao centro vertical */}
|
||
<button
|
||
onClick={cycleSpeed}
|
||
className="text-[11px] font-bold text-[#667781] hover:text-[#111b21] transition-colors leading-none shrink-0 w-7 text-center"
|
||
>
|
||
{speed === 1 ? '1×' : speed === 1.5 ? '1.5×' : '2×'}
|
||
</button>
|
||
|
||
{/* Waveform + tempo */}
|
||
<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>
|
||
<span className="text-[11px] text-[#667781] tabular-nums leading-none">
|
||
{currentTime > 0 ? formatTime(currentTime) : formatTime(duration)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{TimestampRow}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── DocumentBubble ────────────────────────────────────────────────────────────
|
||
|
||
interface DocumentBubbleProps {
|
||
msg: Message
|
||
fullMediaUrl: string | null
|
||
TimestampRow: React.ReactNode
|
||
onRedownload?: () => void
|
||
isRedownloading?: boolean
|
||
mediaUnavailable?: 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, mediaUnavailable }) => {
|
||
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 (mediaUnavailable) return
|
||
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 transition-all min-w-[220px] max-w-[280px] ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed] active:scale-[0.98]'}`}
|
||
>
|
||
{/* 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...' : mediaUnavailable ? 'Arquivo não disponível' : `${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 [mediaUnavailable, setMediaUnavailable] = useState(false);
|
||
|
||
const handleRedownload = useCallback(async () => {
|
||
if (!onRedownloadMedia || isRedownloading || mediaUnavailable) return
|
||
setIsRedownloading(true)
|
||
try {
|
||
await onRedownloadMedia(msg)
|
||
} catch (err: any) {
|
||
const status = err?.response?.status ?? err?.status
|
||
if (status === 400 || status === 404) setMediaUnavailable(true)
|
||
} finally {
|
||
setIsRedownloading(false)
|
||
}
|
||
}, [onRedownloadMedia, msg, isRedownloading, mediaUnavailable])
|
||
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 overflow-hidden ${fullMediaUrl || mediaUnavailable ? '' : 'cursor-pointer'}`}
|
||
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : (mediaUnavailable ? undefined : 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 ${mediaUnavailable ? 'opacity-50' : ''}`}>
|
||
{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">{mediaUnavailable ? 'Imagem não disponível' : '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 overflow-hidden group/vid ${fullMediaUrl || mediaUnavailable ? '' : 'cursor-pointer'}`}
|
||
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : (mediaUnavailable ? undefined : 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 ${mediaUnavailable ? 'opacity-50' : ''}`}>
|
||
{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">{mediaUnavailable ? 'Vídeo não disponível' : '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={mediaUnavailable ? undefined : handleRedownload}
|
||
className={`flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed]'}`}
|
||
>
|
||
{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...' : mediaUnavailable ? 'Áudio não disponível' : '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} mediaUnavailable={mediaUnavailable} />;
|
||
case 'sticker':
|
||
return (
|
||
<div>
|
||
{fullMediaUrl
|
||
? <img src={fullMediaUrl} alt="Sticker" className="max-w-[180px] max-h-[180px]" />
|
||
: (
|
||
<div
|
||
onClick={mediaUnavailable ? undefined : handleRedownload}
|
||
className={`flex items-center gap-2 text-[13px] text-[#667781] italic transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:text-[#111b21]'}`}
|
||
>
|
||
{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...' : mediaUnavailable ? 'Figurinha não disponível' : '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;
|