From 8b3b368b565ce6e220d1c0c9aae349e18d1c515b Mon Sep 17 00:00:00 2001 From: VPS 4 Deploy Agent Date: Mon, 11 May 2026 07:56:40 +0200 Subject: [PATCH] feat(inbox): preview de arquivo estilo WhatsApp + delay no preloader para avatares --- .../frontend/components/FilePreviewModal.tsx | 176 ++++++++++++++++++ .../frontend/components/InputBar.tsx | 22 ++- .../frontend/hooks/inbox/useChatWindow.ts | 4 +- .../frontend/pages/inbox/index.tsx | 8 +- 4 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 clube67/newwhats.local/frontend/components/FilePreviewModal.tsx diff --git a/clube67/newwhats.local/frontend/components/FilePreviewModal.tsx b/clube67/newwhats.local/frontend/components/FilePreviewModal.tsx new file mode 100644 index 0000000..bfe28ed --- /dev/null +++ b/clube67/newwhats.local/frontend/components/FilePreviewModal.tsx @@ -0,0 +1,176 @@ +'use client'; + +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, Send, FileText, Film, Music, Archive, File } from 'lucide-react'; + +interface FilePreviewModalProps { + file: File | null; + onConfirm: (file: File, caption: string) => Promise; + onCancel: () => void; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function getFileIcon(type: string) { + if (type.startsWith('video/')) return ; + if (type.startsWith('audio/')) return ; + if (type.includes('pdf') || type.includes('document') || type.includes('word')) + return ; + if (type.includes('zip') || type.includes('compressed') || type.includes('tar')) + return ; + return ; +} + +function getFileLabel(type: string): string { + if (type.startsWith('image/')) return 'Imagem'; + if (type.startsWith('video/')) return 'Vídeo'; + if (type.startsWith('audio/')) return 'Áudio'; + if (type.includes('pdf')) return 'PDF'; + return 'Arquivo'; +} + +export default function FilePreviewModal({ file, onConfirm, onCancel }: FilePreviewModalProps) { + const [caption, setCaption] = useState(''); + const [sending, setSending] = useState(false); + const [objectUrl, setObjectUrl] = useState(null); + const captionRef = useRef(null); + + const isImage = file?.type.startsWith('image/'); + const isVideo = file?.type.startsWith('video/'); + + // Create / revoke object URL + useEffect(() => { + if (!file) { setObjectUrl(null); return; } + const url = URL.createObjectURL(file); + setObjectUrl(url); + setCaption(''); + setSending(false); + setTimeout(() => captionRef.current?.focus(), 200); + return () => URL.revokeObjectURL(url); + }, [file]); + + // ESC cancels + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onCancel(); }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [onCancel]); + + const handleSend = useCallback(async () => { + if (!file || sending) return; + setSending(true); + try { + await onConfirm(file, caption.trim()); + } finally { + setSending(false); + } + }, [file, caption, sending, onConfirm]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( + + {file && ( + + {/* ── Top bar ── */} +
+ + + {file ? getFileLabel(file.type) : ''} + +
{/* spacer */} +
+ + {/* ── Preview area ── */} +
+ {isImage && objectUrl && ( + + )} + + {isVideo && objectUrl && ( + + )} + + {!isImage && !isVideo && file && ( + + {getFileIcon(file.type)} +
+

+ {file.name} +

+

{formatFileSize(file.size)}

+
+
+ )} +
+ + {/* ── Caption + Send bar ── */} +
+ setCaption(e.target.value)} + onKeyDown={handleKeyDown} + disabled={sending} + className="flex-1 bg-[#2a3942] text-[#e9edef] placeholder:text-[#8696a0] text-sm rounded-full px-4 py-2.5 outline-none border-none focus:ring-0 disabled:opacity-50" + /> + +
+ + )} + + ); +} diff --git a/clube67/newwhats.local/frontend/components/InputBar.tsx b/clube67/newwhats.local/frontend/components/InputBar.tsx index a763fb4..ec6ea53 100644 --- a/clube67/newwhats.local/frontend/components/InputBar.tsx +++ b/clube67/newwhats.local/frontend/components/InputBar.tsx @@ -20,6 +20,7 @@ import { groupApi, type GroupParticipant, type MessageTemplate, type TemplateTyp import RichMessageComposer from './RichMessageComposer'; import TemplateLibrary from './TemplateLibrary'; import ScheduleMessageModal from './ScheduleMessageModal'; +import FilePreviewModal from './FilePreviewModal'; interface Message { id: number; @@ -34,7 +35,7 @@ interface InputBarProps { onNewMessageChange: (val: string) => void; onSend: (e?: React.FormEvent) => void; onPresence: () => void; - onSendMedia: (file: File) => Promise; + onSendMedia: (file: File, caption?: string) => Promise; onSendAudio: (blob: Blob) => Promise; replyingTo: Message | null; onCancelReply: () => void; @@ -64,6 +65,7 @@ const InputBar: React.FC = ({ const fileInputRef = useRef(null); const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showAttachMenu, setShowAttachMenu] = useState(false); + const [previewFile, setPreviewFile] = useState(null); const attachMenuRef = useRef(null); const [showRichComposer, setShowRichComposer] = useState(false); const [showTemplateLibrary, setShowTemplateLibrary] = useState(false); @@ -229,14 +231,21 @@ const InputBar: React.FC = ({ // Do not close picker, common UX allows multiple emojis }; - const handleFileChange = async (e: React.ChangeEvent) => { + const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { - await onSendMedia(file); + setPreviewFile(file); if (fileInputRef.current) fileInputRef.current.value = ''; } }; + const handlePreviewConfirm = async (file: File, caption: string) => { + await onSendMedia(file, caption); + setPreviewFile(null); + }; + + const handlePreviewCancel = () => setPreviewFile(null); + const startRecording = async () => { if (!micAvailable) { setMicError('Áudio requer HTTPS. Acesse via https:// para gravar.'); @@ -625,6 +634,13 @@ const InputBar: React.FC = ({
+ {/* ── Modal de preview de arquivo ─────────────────────── */} + + {/* ── Modais de mensagem rica ────────────────────────────── */} { + const handleSendMedia = useCallback(async (file: File, caption?: string) => { if (!selectedChat || !activeInstanceId) return try { - await mediaApi.sendMedia(activeInstanceId, selectedChat.remote_jid, file, undefined, undefined) + await mediaApi.sendMedia(activeInstanceId, selectedChat.remote_jid, file, caption || undefined, undefined) } catch (err) { console.error('Erro ao enviar mídia:', err) } diff --git a/clube67/newwhats.local/frontend/pages/inbox/index.tsx b/clube67/newwhats.local/frontend/pages/inbox/index.tsx index 96bd482..6871150 100644 --- a/clube67/newwhats.local/frontend/pages/inbox/index.tsx +++ b/clube67/newwhats.local/frontend/pages/inbox/index.tsx @@ -128,11 +128,17 @@ export default function WhatsAppInboxPage() { const [isNewConvoModalOpen, setIsNewConvoModalOpen] = useState(false) // Preloader: começa visível e some após o primeiro ciclo loading true→false + // O delay de 400ms dá tempo ao browser iniciar o carregamento dos avatares + // antes do overlay sumir, evitando o flash de skeletons pulsando. const [pageReady, setPageReady] = useState(false) const prevLoadingRef = useRef(false) + const readyTimerRef = useRef | null>(null) useEffect(() => { - if (prevLoadingRef.current && !loading) setPageReady(true) + if (prevLoadingRef.current && !loading) { + readyTimerRef.current = setTimeout(() => setPageReady(true), 400) + } prevLoadingRef.current = loading + return () => { if (readyTimerRef.current) clearTimeout(readyTimerRef.current) } }, [loading]) const optionsRef = useRef(null)