diff --git a/frontend/views/newwhats/components/MessageBubble.tsx b/frontend/views/newwhats/components/MessageBubble.tsx index 120d3b8..7fd3351 100644 --- a/frontend/views/newwhats/components/MessageBubble.tsx +++ b/frontend/views/newwhats/components/MessageBubble.tsx @@ -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 = ({ @@ -333,11 +334,38 @@ const MessageBubble: React.FC = ({ 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 . 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({renderTextWithLinks(src.slice(i))}); break; } + if (at > i) out.push({renderTextWithLinks(src.slice(i, at))}); + out.push({src.slice(at, at + t.length)}); + 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 = ({ 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 = ({ Selecionou:

- {msg.text} + {renderTextHL(msg.text)} {TimespacerEl}

{TimestampEl}
@@ -610,7 +638,7 @@ const MessageBubble: React.FC = ({ return (

- {renderTextWithLinks(msg.text)} + {renderTextHL(msg.text)} {TimespacerEl}

@@ -632,7 +660,12 @@ const MessageBubble: React.FC = ({ > {fullMediaUrl ? ( .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 = ({ {hasCaption && (

- {msg.text} + {renderTextHL(msg.text)} {TimespacerEl}

{TimestampEl}
@@ -701,7 +734,7 @@ const MessageBubble: React.FC = ({ {hasCaption && (

- {msg.text} + {renderTextHL(msg.text)} {TimespacerEl}

{TimestampEl}
@@ -798,7 +831,7 @@ const MessageBubble: React.FC = ({ } return (

- {renderTextWithLinks(msg.text)} + {renderTextHL(msg.text)} {TimestampEl} @@ -830,7 +863,10 @@ const MessageBubble: React.FC = ({ 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 && (

diff --git a/frontend/views/newwhats/hooks/inbox/useNewInboxBridge.ts b/frontend/views/newwhats/hooks/inbox/useNewInboxBridge.ts index 010ad5a..284bcec 100644 --- a/frontend/views/newwhats/hooks/inbox/useNewInboxBridge.ts +++ b/frontend/views/newwhats/hooks/inbox/useNewInboxBridge.ts @@ -76,6 +76,9 @@ function messagesToLegacy(msgs: ReturnType['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, + presenceByJid: chatStore.presenceByJid as Record, isFavoriteChat: (_: NewWhatsChat) => false, } }