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[];
|
quickReactions: string[];
|
||||||
selectedChat?: any;
|
selectedChat?: any;
|
||||||
canDelete?: boolean; // false esconde a ação "Apagar" (sem permissão)
|
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> = ({
|
const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||||
@@ -333,11 +334,38 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
selectedChat,
|
selectedChat,
|
||||||
canDelete = true,
|
canDelete = true,
|
||||||
showTail = false,
|
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 [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
||||||
const [listOpen, setListOpen] = useState(false);
|
const [listOpen, setListOpen] = useState(false);
|
||||||
const [isRedownloading, setIsRedownloading] = 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 () => {
|
const handleRedownload = useCallback(async () => {
|
||||||
if (!onRedownloadMedia || isRedownloading || mediaUnavailable) return
|
if (!onRedownloadMedia || isRedownloading || mediaUnavailable) return
|
||||||
@@ -346,7 +374,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
await onRedownloadMedia(msg)
|
await onRedownloadMedia(msg)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const status = err?.response?.status ?? err?.status
|
const status = err?.response?.status ?? err?.status
|
||||||
if (status === 400 || status === 404) setMediaUnavailable(true)
|
if (status === 400 || status === 404) setMediaFailed(true)
|
||||||
} finally {
|
} finally {
|
||||||
setIsRedownloading(false)
|
setIsRedownloading(false)
|
||||||
}
|
}
|
||||||
@@ -597,7 +625,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
<span className="font-medium text-[#00a884]">Selecionou:</span>
|
<span className="font-medium text-[#00a884]">Selecionou:</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
|
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
|
||||||
{msg.text}
|
{renderTextHL(msg.text)}
|
||||||
{TimespacerEl}
|
{TimespacerEl}
|
||||||
</p>
|
</p>
|
||||||
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
|
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
|
||||||
@@ -610,7 +638,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
|
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
|
||||||
{renderTextWithLinks(msg.text)}
|
{renderTextHL(msg.text)}
|
||||||
{TimespacerEl}
|
{TimespacerEl}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-1 text-[11px] text-[#8696a0] mt-1">
|
<div className="flex items-center gap-1 text-[11px] text-[#8696a0] mt-1">
|
||||||
@@ -632,7 +660,12 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
>
|
>
|
||||||
{fullMediaUrl ? (
|
{fullMediaUrl ? (
|
||||||
<img
|
<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"
|
alt="Media"
|
||||||
className="w-full h-full object-cover transition-transform group-hover/media:scale-[1.03] block"
|
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 && (
|
{hasCaption && (
|
||||||
<div className="relative px-1 pt-1.5 pb-0.5">
|
<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]">
|
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
|
||||||
{msg.text}
|
{renderTextHL(msg.text)}
|
||||||
{TimespacerEl}
|
{TimespacerEl}
|
||||||
</p>
|
</p>
|
||||||
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
|
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
|
||||||
@@ -701,7 +734,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
{hasCaption && (
|
{hasCaption && (
|
||||||
<div className="relative px-1 pt-1.5 pb-0.5">
|
<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]">
|
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
|
||||||
{msg.text}
|
{renderTextHL(msg.text)}
|
||||||
{TimespacerEl}
|
{TimespacerEl}
|
||||||
</p>
|
</p>
|
||||||
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
|
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
|
||||||
@@ -798,7 +831,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
|
<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 }}>
|
<span style={{ float: 'right', marginLeft: 6, position: 'relative', bottom: -3 }}>
|
||||||
{TimestampEl}
|
{TimestampEl}
|
||||||
</span>
|
</span>
|
||||||
@@ -830,7 +863,10 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
id={`msg-${msg.message_id}`}
|
id={`msg-${msg.message_id}`}
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
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 && (
|
{showActionsLeft && (
|
||||||
<div className="opacity-0 group-hover/row:opacity-100 transition-opacity flex flex-col items-center justify-center mr-2 gap-2">
|
<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(),
|
status: m.status.toLowerCase(),
|
||||||
type: m.type.toLowerCase(),
|
type: m.type.toLowerCase(),
|
||||||
media_url: m.mediaUrl,
|
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,
|
transcription: (m as any).transcription ?? null,
|
||||||
mime_type: m.mimeType,
|
mime_type: m.mimeType,
|
||||||
reply_to_id: m.replyToId,
|
reply_to_id: m.replyToId,
|
||||||
@@ -163,6 +166,8 @@ export function useNewInboxBridge() {
|
|||||||
const handleSelectChat = useCallback(async (chat: NewWhatsChat | null) => {
|
const handleSelectChat = useCallback(async (chat: NewWhatsChat | null) => {
|
||||||
if (!chat) { useChatStore.getState().setActiveChat(null); return }
|
if (!chat) { useChatStore.getState().setActiveChat(null); return }
|
||||||
await chatStore.selectChat(String(chat.id))
|
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])
|
}, [chatStore])
|
||||||
|
|
||||||
const handleSendText = useCallback(async (text: string, mentions?: string[], replyToMessageId?: string) => {
|
const handleSendText = useCallback(async (text: string, mentions?: string[], replyToMessageId?: string) => {
|
||||||
@@ -216,6 +221,19 @@ export function useNewInboxBridge() {
|
|||||||
useChatStore.getState().patchChat(chatId, { unreadCount: 0 })
|
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) => {
|
const handleSearchMessages = useCallback(async (q: string) => {
|
||||||
if (!instanceStore.activeInstanceId || q.length < 2) return []
|
if (!instanceStore.activeInstanceId || q.length < 2) return []
|
||||||
return messageApi.search(instanceStore.activeInstanceId, q)
|
return messageApi.search(instanceStore.activeInstanceId, q)
|
||||||
@@ -258,11 +276,12 @@ export function useNewInboxBridge() {
|
|||||||
handleArchiveChat,
|
handleArchiveChat,
|
||||||
handlePinChat,
|
handlePinChat,
|
||||||
handleDeleteChat,
|
handleDeleteChat,
|
||||||
|
handleDeleteMessage,
|
||||||
handleMarkRead,
|
handleMarkRead,
|
||||||
handleSearchMessages,
|
handleSearchMessages,
|
||||||
handleMenuAction,
|
handleMenuAction,
|
||||||
|
|
||||||
presenceByJid: {} as Record<string, any>,
|
presenceByJid: chatStore.presenceByJid as Record<string, any>,
|
||||||
isFavoriteChat: (_: NewWhatsChat) => false,
|
isFavoriteChat: (_: NewWhatsChat) => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user