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:
@@ -232,6 +232,7 @@ model Message {
|
|||||||
senderJid String? // JID do remetente real (grupos: key.participant)
|
senderJid String? // JID do remetente real (grupos: key.participant)
|
||||||
replyToId String? // ID da mensagem citada (reply)
|
replyToId String? // ID da mensagem citada (reply)
|
||||||
richPayload Json? // Payload rico original (buttons/list/carousel/poll)
|
richPayload Json? // Payload rico original (buttons/list/carousel/poll)
|
||||||
|
mediaPayload Json? // Inner WAMessage payload para re-download de mídia
|
||||||
status MessageStatus @default(PENDING)
|
status MessageStatus @default(PENDING)
|
||||||
timestamp DateTime
|
timestamp DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -824,12 +824,43 @@ class MessageHandler {
|
|||||||
messageId: savedMsgId,
|
messageId: savedMsgId,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
});
|
});
|
||||||
|
// Persiste sticker na biblioteca (deduplicado por sha256)
|
||||||
|
if (contentType === 'stickerMessage') {
|
||||||
|
this.persistSticker(msg, mediaUrl).catch(() => { });
|
||||||
|
}
|
||||||
logger_1.logger.debug({ savedMsgId, filePath }, 'Mídia salva');
|
logger_1.logger.debug({ savedMsgId, filePath }, 'Mídia salva');
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger_1.logger.error({ err, savedMsgId }, 'Falha ao baixar mídia');
|
logger_1.logger.error({ err, savedMsgId }, 'Falha ao baixar mídia');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async persistSticker(msg, wasabiPath) {
|
||||||
|
const stickerMsg = msg.message?.stickerMessage;
|
||||||
|
if (!stickerMsg)
|
||||||
|
return;
|
||||||
|
const sha256Bytes = stickerMsg.fileSha256;
|
||||||
|
if (!sha256Bytes || sha256Bytes.length === 0)
|
||||||
|
return;
|
||||||
|
const sha256 = Buffer.from(sha256Bytes).toString('hex');
|
||||||
|
const isAnimated = stickerMsg.isAnimated ?? false;
|
||||||
|
await prisma_1.prisma.sticker.upsert({
|
||||||
|
where: { tenantId_sha256: { tenantId: this.tenantId, sha256 } },
|
||||||
|
create: {
|
||||||
|
tenantId: this.tenantId,
|
||||||
|
instanceId: this.instanceId,
|
||||||
|
sha256,
|
||||||
|
wasabiPath,
|
||||||
|
isAnimated,
|
||||||
|
useCount: 1,
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
useCount: { increment: 1 },
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
instanceId: this.instanceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// UTILITÁRIOS
|
// UTILITÁRIOS
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -590,6 +590,10 @@ export class MessageHandler {
|
|||||||
replyToId,
|
replyToId,
|
||||||
status: fromMe ? 'SENT' : 'PENDING',
|
status: fromMe ? 'SENT' : 'PENDING',
|
||||||
timestamp: new Date((messageTimestamp as number) * 1000),
|
timestamp: new Date((messageTimestamp as number) * 1000),
|
||||||
|
// Armazena payload da mídia para permitir re-download posterior
|
||||||
|
...(this.isMediaMessage(contentType) ? {
|
||||||
|
mediaPayload: this.buildMediaPayload(key, message) as any,
|
||||||
|
} : {}),
|
||||||
},
|
},
|
||||||
update: {}, // Se já existe (duplicata), não sobrescreve
|
update: {}, // Se já existe (duplicata), não sobrescreve
|
||||||
})
|
})
|
||||||
@@ -940,6 +944,48 @@ export class MessageHandler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// PAYLOAD DE MÍDIA (serialização para re-download)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constrói o payload serializado da mensagem de mídia para armazenamento.
|
||||||
|
* Uint8Array e Buffer são convertidos para { _b64: base64 } para sobreviver ao JSON.
|
||||||
|
* jpegThumbnail é descartado para economizar espaço (não necessário para re-download).
|
||||||
|
*/
|
||||||
|
buildMediaPayload(key: any, innerMessage: any): Record<string, unknown> {
|
||||||
|
const stripped = this.stripThumbnails(innerMessage)
|
||||||
|
return {
|
||||||
|
key: { remoteJid: key.remoteJid, fromMe: key.fromMe, id: key.id },
|
||||||
|
message: this.serializeForStorage(stripped),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stripThumbnails(obj: any): any {
|
||||||
|
if (!obj || typeof obj !== 'object') return obj
|
||||||
|
const result: any = {}
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
if (k === 'jpegThumbnail' || k === 'thumbnailDirectPath' || k === 'thumbnailSha256' || k === 'thumbnailEncSha256') continue
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeForStorage(val: unknown): unknown {
|
||||||
|
if (val instanceof Uint8Array || Buffer.isBuffer(val as any)) {
|
||||||
|
return { _b64: Buffer.from(val as Uint8Array).toString('base64') }
|
||||||
|
}
|
||||||
|
if (Array.isArray(val)) return val.map(v => this.serializeForStorage(v))
|
||||||
|
if (val !== null && typeof val === 'object') {
|
||||||
|
// Long de protobuf — converte para número
|
||||||
|
if (typeof (val as any).toNumber === 'function') return (val as any).toNumber()
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(val as Record<string, unknown>).map(([k, v]) => [k, this.serializeForStorage(v)])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// UTILITÁRIOS
|
// UTILITÁRIOS
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import path from 'path'
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import { Router, type Request, type Response } from 'express'
|
import { Router, type Request, type Response } from 'express'
|
||||||
import multer from 'multer'
|
import multer from 'multer'
|
||||||
|
import { downloadMediaMessage, getContentType } from './engine'
|
||||||
import { prisma } from '../../infra/database/prisma'
|
import { prisma } from '../../infra/database/prisma'
|
||||||
import { logger } from '../../config/logger'
|
import { logger } from '../../config/logger'
|
||||||
import { storageProvider } from '../../core/StorageProvider'
|
import { storageProvider } from '../../core/StorageProvider'
|
||||||
@@ -285,5 +286,109 @@ export function buildMediaRoutes(manager: WhatsAppConnectionManager): Router {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── POST /api/instances/:instanceId/redownload-media/:messageDbId ─────────
|
||||||
|
// Re-solicita ao WhatsApp a mídia de uma mensagem cujo download falhou.
|
||||||
|
// Usa o payload WAMessage salvo em messages.mediaPayload para reconstruir
|
||||||
|
// a requisição com updateMediaMessage (renova URL expirada se necessário).
|
||||||
|
router.post('/redownload-media/:messageDbId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.tenantId!
|
||||||
|
const instanceId = req.params['instanceId'] as string
|
||||||
|
const messageDbId = req.params['messageDbId'] as string
|
||||||
|
|
||||||
|
const msg = await prisma.message.findFirst({
|
||||||
|
where: { id: messageDbId, instanceId, tenantId },
|
||||||
|
select: { id: true, mediaPayload: true, type: true, chatId: true, mediaUrl: true, messageId: true },
|
||||||
|
})
|
||||||
|
if (!msg) { res.status(404).json({ error: 'Mensagem não encontrada' }); return }
|
||||||
|
if (msg.mediaUrl) { res.json({ mediaUrl: msg.mediaUrl }); return }
|
||||||
|
if (!msg.mediaPayload) { res.status(400).json({ error: 'Payload de mídia não disponível para esta mensagem' }); return }
|
||||||
|
|
||||||
|
const sock = manager.getSocket(instanceId)
|
||||||
|
if (!sock) { res.status(503).json({ error: 'Instância não conectada' }); return }
|
||||||
|
|
||||||
|
// Desserializa payload: { _b64: base64 } → Buffer/Uint8Array
|
||||||
|
const waMsg = deserializePayload(msg.mediaPayload as Record<string, unknown>) as any
|
||||||
|
|
||||||
|
const buffer = await downloadMediaMessage(
|
||||||
|
waMsg,
|
||||||
|
'buffer',
|
||||||
|
{},
|
||||||
|
{ logger: logger as any, reuploadRequest: sock.updateMediaMessage as any }
|
||||||
|
)
|
||||||
|
if (!buffer) { res.status(500).json({ error: 'Nenhum buffer retornado pelo WhatsApp' }); return }
|
||||||
|
|
||||||
|
const contentType = getContentType(waMsg.message) ?? 'imageMessage'
|
||||||
|
const ext = EXT_MAP[contentType] ?? 'bin'
|
||||||
|
const mime = MIME_FOR_TYPE[contentType] ?? 'application/octet-stream'
|
||||||
|
const fileName = `${messageDbId}.${ext}`
|
||||||
|
|
||||||
|
let mediaUrl = `/media/${instanceId}/${fileName}`
|
||||||
|
if (storageProvider.isRegistered()) {
|
||||||
|
try {
|
||||||
|
const result = await storageProvider.uploadFile({
|
||||||
|
category: 'whatsapp',
|
||||||
|
usuarioSis: tenantId,
|
||||||
|
sessionJID: instanceId,
|
||||||
|
file: { originalname: fileName, buffer: buffer as Buffer, mimetype: mime },
|
||||||
|
})
|
||||||
|
mediaUrl = result.path
|
||||||
|
} catch (uploadErr) {
|
||||||
|
logger.warn({ uploadErr, messageDbId }, 'Upload Wasabi falhou no re-download, usando path local')
|
||||||
|
const filePath = path.resolve('./media', instanceId, fileName)
|
||||||
|
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||||
|
await fs.writeFile(filePath, buffer as Buffer)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const filePath = path.resolve('./media', instanceId, fileName)
|
||||||
|
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||||
|
await fs.writeFile(filePath, buffer as Buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.message.update({ where: { id: messageDbId }, data: { mediaUrl } })
|
||||||
|
|
||||||
|
manager.getIO().to(`chat:${msg.chatId}`).emit('message:media_ready', {
|
||||||
|
messageId: messageDbId,
|
||||||
|
mediaUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info({ messageDbId, contentType }, 'Mídia re-baixada com sucesso')
|
||||||
|
res.json({ mediaUrl })
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error({ err }, 'Erro ao re-baixar mídia')
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao re-baixar mídia' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const EXT_MAP: Record<string, string> = {
|
||||||
|
imageMessage: 'jpg',
|
||||||
|
videoMessage: 'mp4',
|
||||||
|
audioMessage: 'ogg',
|
||||||
|
documentMessage: 'bin',
|
||||||
|
stickerMessage: 'webp',
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_FOR_TYPE: Record<string, string> = {
|
||||||
|
imageMessage: 'image/jpeg',
|
||||||
|
videoMessage: 'video/mp4',
|
||||||
|
audioMessage: 'audio/ogg',
|
||||||
|
documentMessage: 'application/octet-stream',
|
||||||
|
stickerMessage: 'image/webp',
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserializePayload(val: unknown): unknown {
|
||||||
|
if (Array.isArray(val)) return val.map(deserializePayload)
|
||||||
|
if (val !== null && typeof val === 'object') {
|
||||||
|
const obj = val as Record<string, unknown>
|
||||||
|
if ('_b64' in obj && typeof obj._b64 === 'string') {
|
||||||
|
return Buffer.from(obj._b64, 'base64')
|
||||||
|
}
|
||||||
|
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, deserializePayload(v)]))
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { isSameDay, format, isToday, isYesterday } from 'date-fns';
|
|||||||
import { ptBR } from 'date-fns/locale';
|
import { ptBR } from 'date-fns/locale';
|
||||||
import { normalizeJid, parseSafeDate } from '../utils/inboxUtils';
|
import { normalizeJid, parseSafeDate } from '../utils/inboxUtils';
|
||||||
import { Message } from '../types/inboxTypes';
|
import { Message } from '../types/inboxTypes';
|
||||||
|
import { mediaApi } from '../services/chatApiService';
|
||||||
|
|
||||||
// Message interface removed and moved to types.ts
|
// Message interface removed and moved to types.ts
|
||||||
|
|
||||||
@@ -57,6 +58,13 @@ const MessageArea: React.FC<MessageAreaProps> = ({
|
|||||||
|
|
||||||
// ── Media viewer state ────────────────────────────────────────────────────
|
// ── Media viewer state ────────────────────────────────────────────────────
|
||||||
const [viewerMsgId, setViewerMsgId] = useState<string | null>(null)
|
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 mediaMessages = messages.filter(m => m.type === 'image' || m.type === 'video')
|
||||||
|
|
||||||
const handleInternalScroll = useCallback(() => {
|
const handleInternalScroll = useCallback(() => {
|
||||||
@@ -200,6 +208,7 @@ const MessageArea: React.FC<MessageAreaProps> = ({
|
|||||||
quickReactions={quickReactions}
|
quickReactions={quickReactions}
|
||||||
selectedChat={selectedChat}
|
selectedChat={selectedChat}
|
||||||
onOpenMedia={(m) => setViewerMsgId(m.message_id)}
|
onOpenMedia={(m) => setViewerMsgId(m.message_id)}
|
||||||
|
onRedownloadMedia={handleRedownloadMedia}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ interface DocumentBubbleProps {
|
|||||||
msg: Message
|
msg: Message
|
||||||
fullMediaUrl: string | null
|
fullMediaUrl: string | null
|
||||||
TimestampRow: React.ReactNode
|
TimestampRow: React.ReactNode
|
||||||
|
onRedownload?: () => void
|
||||||
|
isRedownloading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOC_TYPES: Record<string, { bg: string; label: string }> = {
|
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' },
|
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)
|
const [previewOpen, setPreviewOpen] = useState(false)
|
||||||
|
|
||||||
// Derive extension from media_type or media_url
|
// 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 isPdf = ext === 'pdf'
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (!fullMediaUrl) return
|
if (!fullMediaUrl) {
|
||||||
|
onRedownload?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isPdf) {
|
if (isPdf) {
|
||||||
setPreviewOpen(true)
|
setPreviewOpen(true)
|
||||||
} else {
|
} 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"
|
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 shadow-sm"
|
||||||
style={{ backgroundColor: docType.bg }}
|
style={{ backgroundColor: docType.bg }}
|
||||||
>
|
>
|
||||||
<span className="text-white text-[10px] font-extrabold tracking-tight leading-none">
|
{isRedownloading
|
||||||
{docType.label}
|
? <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
</span>
|
: <span className="text-white text-[10px] font-extrabold tracking-tight leading-none">{docType.label}</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
{/* Filename + meta */}
|
{/* Filename + meta */}
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
@@ -231,7 +237,7 @@ const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, Time
|
|||||||
{filename}
|
{filename}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-[#667781] mt-[2px]">
|
<span className="text-[11px] text-[#667781] mt-[2px]">
|
||||||
{ext.toUpperCase()}{sizeStr ? ` · ${sizeStr}` : ''}
|
{isRedownloading ? 'Baixando...' : `${ext.toUpperCase()}${sizeStr ? ` · ${sizeStr}` : ''}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Download className="w-4 h-4 text-[#8696a0] shrink-0" />
|
<Download className="w-4 h-4 text-[#8696a0] shrink-0" />
|
||||||
@@ -294,6 +300,7 @@ interface MessageBubbleProps {
|
|||||||
onStar?: (msg: Message) => void;
|
onStar?: (msg: Message) => void;
|
||||||
onEdit?: (msg: Message) => void;
|
onEdit?: (msg: Message) => void;
|
||||||
onOpenMedia?: (msg: Message) => void;
|
onOpenMedia?: (msg: Message) => void;
|
||||||
|
onRedownloadMedia?: (msg: Message) => Promise<void>;
|
||||||
quickReactions: string[];
|
quickReactions: string[];
|
||||||
selectedChat?: any;
|
selectedChat?: any;
|
||||||
}
|
}
|
||||||
@@ -313,12 +320,24 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
onStar,
|
onStar,
|
||||||
onEdit,
|
onEdit,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
|
onRedownloadMedia,
|
||||||
quickReactions,
|
quickReactions,
|
||||||
selectedChat,
|
selectedChat,
|
||||||
showTail = false,
|
showTail = false,
|
||||||
}) => {
|
}) => {
|
||||||
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 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 isOut = msg.direction === 'out';
|
||||||
const showActionsLeft = isOut;
|
const showActionsLeft = isOut;
|
||||||
const showActionsRight = !isOut;
|
const showActionsRight = !isOut;
|
||||||
@@ -596,7 +615,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
<div className="rounded-lg overflow-hidden w-[268px]">
|
<div className="rounded-lg overflow-hidden w-[268px]">
|
||||||
<div
|
<div
|
||||||
className="relative group/media w-[268px] h-[200px] bg-black/5 cursor-pointer overflow-hidden"
|
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 ? (
|
{fullMediaUrl ? (
|
||||||
<img
|
<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"
|
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">
|
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
|
||||||
<ImageIcon className="w-8 h-8 text-slate-400" />
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasCaption && (
|
{!hasCaption && (
|
||||||
@@ -633,13 +655,22 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
<div className="rounded-lg overflow-hidden w-[268px]">
|
<div className="rounded-lg overflow-hidden w-[268px]">
|
||||||
<div
|
<div
|
||||||
className="relative w-[268px] h-[200px] bg-black cursor-pointer group/vid overflow-hidden"
|
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="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">
|
<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" />
|
<Video className="w-6 h-6 fill-current" />
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<div className="absolute bottom-0 inset-x-0 h-10 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />
|
||||||
{!hasCaption && (
|
{!hasCaption && (
|
||||||
<div className="absolute bottom-1.5 right-2">
|
<div className="absolute bottom-1.5 right-2">
|
||||||
@@ -661,15 +692,43 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
}
|
}
|
||||||
case 'audio':
|
case 'audio':
|
||||||
case 'ptt':
|
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} />;
|
return <AudioBubble msg={msg} fullMediaUrl={fullMediaUrl} isOut={isOut} TimestampRow={TimestampRow} />;
|
||||||
case 'document':
|
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':
|
case 'sticker':
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{fullMediaUrl
|
{fullMediaUrl
|
||||||
? <img src={fullMediaUrl} alt="Sticker" className="max-w-[180px] max-h-[180px]" />
|
? <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}
|
{TimestampRow}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -213,6 +213,9 @@ export const mediaApi = {
|
|||||||
})
|
})
|
||||||
.then((r) => r.data)
|
.then((r) => r.data)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
redownload: (instanceId: string, messageDbId: string): Promise<{ mediaUrl: string }> =>
|
||||||
|
api.post(`/api/instances/${instanceId}/redownload-media/${messageDbId}`).then((r) => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Chatbot / IA ─────────────────────────────────────────────────────────────
|
// ─── Chatbot / IA ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user