feat(media): re-download de mídia não baixada ao clicar
- Armazena payload WAMessage (serializado com _b64 para Uint8Array) em messages.mediaPayload no momento da recepção da mensagem - Nova rota POST /instances/:id/redownload-media/:msgId que usa sock.updateMediaMessage para renovar URL expirada + re-upload Wasabi - Frontend: placeholders clicáveis (spinner + texto) para imagem, vídeo, áudio, documento e figurinha sem media_url; atualização automática via socket message:media_ready existente Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { isSameDay, format, isToday, isYesterday } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { normalizeJid, parseSafeDate } from '../utils/inboxUtils';
|
||||
import { Message } from '../types/inboxTypes';
|
||||
import { mediaApi } from '../services/chatApiService';
|
||||
|
||||
// Message interface removed and moved to types.ts
|
||||
|
||||
@@ -57,6 +58,13 @@ const MessageArea: React.FC<MessageAreaProps> = ({
|
||||
|
||||
// ── Media viewer state ────────────────────────────────────────────────────
|
||||
const [viewerMsgId, setViewerMsgId] = useState<string | null>(null)
|
||||
|
||||
// ── Re-download de mídia não baixada ─────────────────────────────────────
|
||||
const handleRedownloadMedia = useCallback(async (msg: Message) => {
|
||||
const instanceId = selectedChat?.instanceId
|
||||
if (!instanceId || !msg.id) return
|
||||
await mediaApi.redownload(instanceId, String(msg.id))
|
||||
}, [selectedChat?.instanceId])
|
||||
const mediaMessages = messages.filter(m => m.type === 'image' || m.type === 'video')
|
||||
|
||||
const handleInternalScroll = useCallback(() => {
|
||||
@@ -200,6 +208,7 @@ const MessageArea: React.FC<MessageAreaProps> = ({
|
||||
quickReactions={quickReactions}
|
||||
selectedChat={selectedChat}
|
||||
onOpenMedia={(m) => setViewerMsgId(m.message_id)}
|
||||
onRedownloadMedia={handleRedownloadMedia}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -161,6 +161,8 @@ interface DocumentBubbleProps {
|
||||
msg: Message
|
||||
fullMediaUrl: string | null
|
||||
TimestampRow: React.ReactNode
|
||||
onRedownload?: () => void
|
||||
isRedownloading?: boolean
|
||||
}
|
||||
|
||||
const DOC_TYPES: Record<string, { bg: string; label: string }> = {
|
||||
@@ -175,7 +177,7 @@ const DOC_TYPES: Record<string, { bg: string; label: string }> = {
|
||||
csv: { bg: '#276749', label: 'CSV' },
|
||||
}
|
||||
|
||||
const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, TimestampRow }) => {
|
||||
const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, TimestampRow, onRedownload, isRedownloading }) => {
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
|
||||
// Derive extension from media_type or media_url
|
||||
@@ -201,7 +203,10 @@ const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, Time
|
||||
const isPdf = ext === 'pdf'
|
||||
|
||||
const handleClick = () => {
|
||||
if (!fullMediaUrl) return
|
||||
if (!fullMediaUrl) {
|
||||
onRedownload?.()
|
||||
return
|
||||
}
|
||||
if (isPdf) {
|
||||
setPreviewOpen(true)
|
||||
} else {
|
||||
@@ -221,9 +226,10 @@ const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, Time
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 shadow-sm"
|
||||
style={{ backgroundColor: docType.bg }}
|
||||
>
|
||||
<span className="text-white text-[10px] font-extrabold tracking-tight leading-none">
|
||||
{docType.label}
|
||||
</span>
|
||||
{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">
|
||||
@@ -231,7 +237,7 @@ const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, Time
|
||||
{filename}
|
||||
</span>
|
||||
<span className="text-[11px] text-[#667781] mt-[2px]">
|
||||
{ext.toUpperCase()}{sizeStr ? ` · ${sizeStr}` : ''}
|
||||
{isRedownloading ? 'Baixando...' : `${ext.toUpperCase()}${sizeStr ? ` · ${sizeStr}` : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
<Download className="w-4 h-4 text-[#8696a0] shrink-0" />
|
||||
@@ -294,6 +300,7 @@ interface MessageBubbleProps {
|
||||
onStar?: (msg: Message) => void;
|
||||
onEdit?: (msg: Message) => void;
|
||||
onOpenMedia?: (msg: Message) => void;
|
||||
onRedownloadMedia?: (msg: Message) => Promise<void>;
|
||||
quickReactions: string[];
|
||||
selectedChat?: any;
|
||||
}
|
||||
@@ -313,12 +320,24 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
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 handleRedownload = useCallback(async () => {
|
||||
if (!onRedownloadMedia || isRedownloading) return
|
||||
setIsRedownloading(true)
|
||||
try {
|
||||
await onRedownloadMedia(msg)
|
||||
} finally {
|
||||
setIsRedownloading(false)
|
||||
}
|
||||
}, [onRedownloadMedia, msg, isRedownloading])
|
||||
const isOut = msg.direction === 'out';
|
||||
const showActionsLeft = isOut;
|
||||
const showActionsRight = !isOut;
|
||||
@@ -596,7 +615,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
<div className="rounded-lg overflow-hidden w-[268px]">
|
||||
<div
|
||||
className="relative group/media w-[268px] h-[200px] bg-black/5 cursor-pointer overflow-hidden"
|
||||
onClick={() => onOpenMedia ? onOpenMedia(msg) : fullMediaUrl && window.open(fullMediaUrl, '_blank')}
|
||||
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : handleRedownload()}
|
||||
>
|
||||
{fullMediaUrl ? (
|
||||
<img
|
||||
@@ -605,8 +624,11 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
className="w-full h-full object-cover transition-transform group-hover/media:scale-[1.03] block"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<ImageIcon className="w-8 h-8 text-slate-400" />
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
|
||||
{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">Toque para baixar</span></>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{!hasCaption && (
|
||||
@@ -633,13 +655,22 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
<div className="rounded-lg overflow-hidden w-[268px]">
|
||||
<div
|
||||
className="relative w-[268px] h-[200px] bg-black cursor-pointer group/vid overflow-hidden"
|
||||
onClick={() => fullMediaUrl && (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank'))}
|
||||
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : 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">
|
||||
{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">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">
|
||||
@@ -661,15 +692,43 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
}
|
||||
case 'audio':
|
||||
case 'ptt':
|
||||
if (!fullMediaUrl) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={handleRedownload}
|
||||
className="flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] cursor-pointer hover:bg-[#e8eaed] transition-colors"
|
||||
>
|
||||
{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...' : '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} />;
|
||||
return <DocumentBubble msg={msg} fullMediaUrl={fullMediaUrl} TimestampRow={TimestampRow} onRedownload={!fullMediaUrl ? handleRedownload : undefined} isRedownloading={isRedownloading} />;
|
||||
case 'sticker':
|
||||
return (
|
||||
<div>
|
||||
{fullMediaUrl
|
||||
? <img src={fullMediaUrl} alt="Sticker" className="max-w-[180px] max-h-[180px]" />
|
||||
: <div className="flex items-center gap-2 text-[13px] text-[#667781] italic"><Star className="w-4 h-4" /> Figurinha</div>
|
||||
: (
|
||||
<div
|
||||
onClick={handleRedownload}
|
||||
className="flex items-center gap-2 text-[13px] text-[#667781] italic cursor-pointer hover:text-[#111b21] transition-colors"
|
||||
>
|
||||
{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...' : 'Toque para baixar figurinha'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{TimestampRow}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user