feat(newwhats): prévia via thumbnail Wasabi + guard de mídia irrecuperável
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m42s

- MessageBubble: carrega <url>.thumb.webp na prévia (fallback ao full via onError)
- mediaUnavailable proativo via flag media_recoverable (evita 400 em mídia legada)
- useNewInboxBridge: mapeia mediaRecoverable -> media_recoverable

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-04 19:05:42 +02:00
parent 8b1801bd9c
commit 09edeb9558
2 changed files with 65 additions and 10 deletions
@@ -311,6 +311,7 @@ interface MessageBubbleProps {
quickReactions: string[];
selectedChat?: any;
canDelete?: boolean; // false esconde a ação "Apagar" (sem permissão)
highlightTerm?: string; // termo da busca a realçar (amarelo) no texto
}
const MessageBubble: React.FC<MessageBubbleProps> = ({
@@ -333,11 +334,38 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
selectedChat,
canDelete = true,
showTail = false,
highlightTerm,
}) => {
// Renderiza o texto com links E realça o termo da busca (amarelo). Fatia o texto
// pelo termo (case-insensitive); trechos normais passam por renderTextWithLinks,
// o trecho casado vira <mark>. Sem termo/sem match → comportamento normal.
const renderTextHL = (text?: string | null): React.ReactNode => {
const t = (highlightTerm ?? '').trim();
const src = text ?? '';
if (!t) return renderTextWithLinks(src);
const lower = src.toLowerCase();
const lt = t.toLowerCase();
if (!lower.includes(lt)) return renderTextWithLinks(src);
const out: React.ReactNode[] = [];
let i = 0, k = 0;
while (i < src.length) {
const at = lower.indexOf(lt, i);
if (at === -1) { out.push(<React.Fragment key={k++}>{renderTextWithLinks(src.slice(i))}</React.Fragment>); break; }
if (at > i) out.push(<React.Fragment key={k++}>{renderTextWithLinks(src.slice(i, at))}</React.Fragment>);
out.push(<mark key={k++} className="bg-yellow-300 text-inherit rounded-[3px] px-0.5">{src.slice(at, at + t.length)}</mark>);
i = at + t.length;
}
return <>{out}</>;
};
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const [listOpen, setListOpen] = useState(false);
const [isRedownloading, setIsRedownloading] = useState(false);
const [mediaUnavailable, setMediaUnavailable] = useState(false);
const [mediaFailed, setMediaFailed] = useState(false);
// Proativo: mídia legada irrecuperável (backend marcou media_recoverable=false) já
// nasce indisponível — não oferece "Toque para baixar" nem dispara o 400 de download.
// Combinado com o reativo (mediaFailed), setado se um re-download retornar 400/404.
const mediaUnavailable = mediaFailed || (msg as any).media_recoverable === false;
const handleRedownload = useCallback(async () => {
if (!onRedownloadMedia || isRedownloading || mediaUnavailable) return
@@ -346,7 +374,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
await onRedownloadMedia(msg)
} catch (err: any) {
const status = err?.response?.status ?? err?.status
if (status === 400 || status === 404) setMediaUnavailable(true)
if (status === 400 || status === 404) setMediaFailed(true)
} finally {
setIsRedownloading(false)
}
@@ -597,7 +625,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
<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}
{renderTextHL(msg.text)}
{TimespacerEl}
</p>
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
@@ -610,7 +638,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
return (
<div>
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{renderTextWithLinks(msg.text)}
{renderTextHL(msg.text)}
{TimespacerEl}
</p>
<div className="flex items-center gap-1 text-[11px] text-[#8696a0] mt-1">
@@ -632,7 +660,12 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
>
{fullMediaUrl ? (
<img
src={fullMediaUrl}
// Prévia rápida: carrega o thumbnail (<url>.thumb.webp, gerado no
// Wasabi); se não existir (mídia antiga/CDN), cai pro full via onError.
src={(msg.media_url && !msg.media_url.startsWith('http'))
? (getMediaUrl(msg.media_url + '.thumb.webp') || fullMediaUrl)
: fullMediaUrl}
onError={(e) => { const t = e.currentTarget as HTMLImageElement; if (t.dataset.full !== '1') { t.dataset.full = '1'; t.src = fullMediaUrl!; } }}
alt="Media"
className="w-full h-full object-cover transition-transform group-hover/media:scale-[1.03] block"
/>
@@ -653,7 +686,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
{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}
{renderTextHL(msg.text)}
{TimespacerEl}
</p>
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
@@ -701,7 +734,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
{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}
{renderTextHL(msg.text)}
{TimespacerEl}
</p>
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
@@ -798,7 +831,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
}
return (
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{renderTextWithLinks(msg.text)}
{renderTextHL(msg.text)}
<span style={{ float: 'right', marginLeft: 6, position: 'relative', bottom: -3 }}>
{TimestampEl}
</span>
@@ -830,7 +863,10 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
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'}`}
// Cada linha é um stacking context (transform do framer-motion). Sem z-index,
// mensagens posteriores no DOM cobrem o menu/reações desta. Ao abrir, elevamos
// ESTA linha acima das demais para o menu sobrepor tudo.
className={`flex w-full mb-3 group/row ${isOut ? 'justify-end pr-4' : 'justify-start'} ${(isMenuOpen || reactionPickerFor === msg.message_id) ? 'relative z-30' : ''}`}
>
{showActionsLeft && (
<div className="opacity-0 group-hover/row:opacity-100 transition-opacity flex flex-col items-center justify-center mr-2 gap-2">
@@ -76,6 +76,9 @@ function messagesToLegacy(msgs: ReturnType<typeof useChatStore.getState>['messag
status: m.status.toLowerCase(),
type: m.type.toLowerCase(),
media_url: m.mediaUrl,
// false só para mídias legadas sem WAMessage salvo (fromMe irrecuperável); default
// true para não bloquear mensagens vindas por outros caminhos (socket, etc.).
media_recoverable: (m as any).mediaRecoverable ?? true,
transcription: (m as any).transcription ?? null,
mime_type: m.mimeType,
reply_to_id: m.replyToId,
@@ -163,6 +166,8 @@ export function useNewInboxBridge() {
const handleSelectChat = useCallback(async (chat: NewWhatsChat | null) => {
if (!chat) { useChatStore.getState().setActiveChat(null); return }
await chatStore.selectChat(String(chat.id))
// Assina a presença do contato (composing/recording) — só assim o motor emite.
chatApi.subscribePresence(String(chat.id)).catch(() => {})
}, [chatStore])
const handleSendText = useCallback(async (text: string, mentions?: string[], replyToMessageId?: string) => {
@@ -216,6 +221,19 @@ export function useNewInboxBridge() {
useChatStore.getState().patchChat(chatId, { unreadCount: 0 })
}, [])
// Apaga a mensagem PARA TODOS (revoke no WhatsApp). Confirma antes; em erro (ex.:
// mensagem não é sua / instância offline) mostra o motivo. messageId = id interno.
const handleDeleteMessage = useCallback(async (chatId: string, messageId: string) => {
if (!chatId || !messageId) return
if (typeof window !== 'undefined' && !window.confirm('Apagar esta mensagem para todos? Esta ação não pode ser desfeita.')) return
try {
await useChatStore.getState().deleteMessage(chatId, messageId)
} catch (e: any) {
const m = e?.response?.data?.error || e?.message || 'Não foi possível apagar a mensagem.'
if (typeof window !== 'undefined') window.alert(m)
}
}, [])
const handleSearchMessages = useCallback(async (q: string) => {
if (!instanceStore.activeInstanceId || q.length < 2) return []
return messageApi.search(instanceStore.activeInstanceId, q)
@@ -258,11 +276,12 @@ export function useNewInboxBridge() {
handleArchiveChat,
handlePinChat,
handleDeleteChat,
handleDeleteMessage,
handleMarkRead,
handleSearchMessages,
handleMenuAction,
presenceByJid: {} as Record<string, any>,
presenceByJid: chatStore.presenceByJid as Record<string, any>,
isFavoriteChat: (_: NewWhatsChat) => false,
}
}