feat(newwhats): prévia via thumbnail Wasabi + guard de mídia irrecuperável
- 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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user