feat: sistema de figurinhas, emoji PT-BR e preloader inteligente

- Stickers: captura automática por fileSha256 (Baileys) no MessageHandler,
  tabela Prisma 'stickers' com deduplicação por [tenantId, sha256],
  API REST /api/stickers (list + favorite toggle), cache IndexedDB permanente
  (sem TTL — conteúdo imutável), StickerPanel.tsx com abas Recentes/Favoritos
- Emoji picker: migrado de emoji-picker-react para @emoji-mart/react com
  locale="pt" (tradução completa: categorias, busca, mensagem de sem resultado)
- Preloader: aparece apenas em hard reload (F5/URL direta) via
  performance.getEntriesByType('navigation').type + sessionStorage flag,
  navegação client-side Next.js pula o overlay instantaneamente
- Avatar cache: Wasabi como storage persistente + IndexedDB client-side,
  avatar_version via MD5(avatarUrl) sem coluna extra no banco,
  rota /api/storage/view/* para servir arquivos Wasabi ao frontend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-05-11 17:48:36 +02:00
parent 2b9b855fc9
commit b97f7f94e4
33 changed files with 1183 additions and 128 deletions
@@ -13,14 +13,18 @@ import {
LayoutTemplate,
BookOpen,
Plus,
Sticker,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import EmojiPicker, { Theme } from 'emoji-picker-react';
import Picker from '@emoji-mart/react';
import emojiData from '@emoji-mart/data';
import { groupApi, type GroupParticipant, type MessageTemplate, type TemplateType } from '../services/chatApiService';
import RichMessageComposer from './RichMessageComposer';
import TemplateLibrary from './TemplateLibrary';
import ScheduleMessageModal from './ScheduleMessageModal';
import FilePreviewModal from './FilePreviewModal';
import StickerPanel from './StickerPanel';
import { mediaApi } from '../services/chatApiService';
interface Message {
id: number;
@@ -64,7 +68,9 @@ const InputBar: React.FC<InputBarProps> = ({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [showStickerPanel, setShowStickerPanel] = useState(false);
const [showAttachMenu, setShowAttachMenu] = useState(false);
const stickerPanelRef = useRef<HTMLDivElement>(null);
const [previewFile, setPreviewFile] = useState<File | null>(null);
const attachMenuRef = useRef<HTMLDivElement>(null);
const [showRichComposer, setShowRichComposer] = useState(false);
@@ -181,12 +187,15 @@ const InputBar: React.FC<InputBarProps> = ({
}
}, [newMessage]);
// Close emoji picker and attach menu on click outside
// Close emoji picker, sticker panel and attach menu on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) {
setShowEmojiPicker(false);
}
if (stickerPanelRef.current && !stickerPanelRef.current.contains(event.target as Node)) {
setShowStickerPanel(false);
}
if (attachMenuRef.current && !attachMenuRef.current.contains(event.target as Node)) {
setShowAttachMenu(false);
}
@@ -195,6 +204,12 @@ const InputBar: React.FC<InputBarProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSendSticker = useCallback(async (blob: Blob) => {
if (!instanceId || !selectedChat?.remote_jid) return;
setShowStickerPanel(false);
await mediaApi.sendSticker(instanceId, selectedChat.remote_jid, blob);
}, [instanceId, selectedChat]);
const handleKeyDown = (e: React.KeyboardEvent) => {
// Mention dropdown navigation
if (mentionQuery !== null && filteredParticipants.length > 0) {
@@ -226,9 +241,8 @@ const InputBar: React.FC<InputBarProps> = ({
}
};
const onEmojiClick = (emojiData: any) => {
onNewMessageChange(newMessage + emojiData.emoji);
// Do not close picker, common UX allows multiple emojis
const onEmojiSelect = (emoji: any) => {
onNewMessageChange(newMessage + (emoji.native ?? ''));
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -356,12 +370,36 @@ const InputBar: React.FC<InputBarProps> = ({
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute bottom-20 left-4 z-50 shadow-2xl rounded-2xl overflow-hidden border border-gray-100"
>
<EmojiPicker
onEmojiClick={onEmojiClick}
autoFocusSearch={false}
theme={Theme.LIGHT}
width={320}
height={400}
<Picker
data={emojiData}
onEmojiSelect={onEmojiSelect}
locale="pt"
theme="light"
previewPosition="none"
skinTonePosition="none"
searchPosition="sticky"
navPosition="top"
perLine={8}
maxFrequentRows={2}
/>
</motion.div>
)}
</AnimatePresence>
{/* Sticker Panel Popover */}
<AnimatePresence>
{showStickerPanel && instanceId && (
<motion.div
ref={stickerPanelRef}
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute bottom-20 left-16 z-50"
>
<StickerPanel
instanceId={instanceId}
jid={selectedChat?.remote_jid ?? ''}
onSend={handleSendSticker}
/>
</motion.div>
)}
@@ -482,12 +520,23 @@ const InputBar: React.FC<InputBarProps> = ({
{/* Emoji — mantém no lugar */}
<button
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
onClick={() => { setShowEmojiPicker(!showEmojiPicker); setShowStickerPanel(false); }}
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showEmojiPicker ? 'text-[#00a884]' : 'text-[#54656f]'}`}
title="Emojis"
>
<Smile className="w-[26px] h-[26px]" />
</button>
{/* Figurinhas */}
{instanceId && (
<button
onClick={() => { setShowStickerPanel(!showStickerPanel); setShowEmojiPicker(false); }}
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showStickerPanel ? 'text-[#00a884]' : 'text-[#54656f]'}`}
title="Figurinhas"
>
<Sticker className="w-[24px] h-[24px]" />
</button>
)}
</div>
<div className="flex-1 relative">
@@ -0,0 +1,228 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Heart, Clock, Loader2 } from 'lucide-react';
import { stickerApi, type StickerRecord } from '../services/chatApiService';
import { getStickerFromCache, setStickerInCache } from '../hooks/useStickerCache';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003';
const LIST_VERSION_KEY = 'nw:sticker-list-version';
interface StickerPanelProps {
instanceId: string;
jid: string;
onSend: (blob: Blob) => void;
}
// Converte URL Wasabi → URL do proxy do backend
function stickerSrcUrl(wasabiPath: string): string {
const clean = wasabiPath.startsWith('/') ? wasabiPath.slice(1) : wasabiPath;
return `${API}/api/storage/view/${clean}`;
}
// Carrega sticker: tenta IndexedDB, depois proxy, depois persiste no cache
function useStickerBlob(sticker: StickerRecord) {
const [dataUrl, setDataUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getStickerFromCache(sticker.sha256).then((cached) => {
if (cancelled) return;
if (cached) { setDataUrl(cached); return; }
// Carrega do proxy e converte para dataURL via canvas
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
if (cancelled) return;
try {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || 96;
canvas.height = img.naturalHeight || 96;
const ctx = canvas.getContext('2d');
if (!ctx) { setDataUrl(img.src); return; }
ctx.drawImage(img, 0, 0);
const url = canvas.toDataURL('image/webp', 0.9);
setDataUrl(url);
setStickerInCache(sticker.sha256, url);
} catch {
setDataUrl(img.src);
}
};
img.onerror = () => { if (!cancelled) setDataUrl(null); };
img.src = stickerSrcUrl(sticker.wasabiPath);
});
return () => { cancelled = true; };
}, [sticker.sha256, sticker.wasabiPath]);
return dataUrl;
}
// Item individual de sticker
function StickerItem({
sticker,
onSend,
onToggleFavorite,
}: {
sticker: StickerRecord;
onSend: (s: StickerRecord) => void;
onToggleFavorite: (s: StickerRecord) => void;
}) {
const dataUrl = useStickerBlob(sticker);
const [hovering, setHovering] = useState(false);
return (
<div
className="relative group cursor-pointer"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={() => dataUrl && onSend(sticker)}
>
<div className="w-16 h-16 flex items-center justify-center rounded-xl hover:bg-black/5 transition-all active:scale-90 p-1">
{dataUrl ? (
<img
src={dataUrl}
alt=""
className="w-full h-full object-contain"
loading="lazy"
/>
) : (
<div className="w-10 h-10 bg-gray-100 rounded-lg animate-pulse" />
)}
</div>
{/* Botão de favoritar — aparece no hover */}
{hovering && (
<button
className={`absolute top-0.5 right-0.5 p-0.5 rounded-full transition-all z-10 ${
sticker.isFavorite
? 'text-red-400 bg-white/90'
: 'text-gray-300 bg-white/80 hover:text-red-400'
}`}
onClick={(e) => { e.stopPropagation(); onToggleFavorite(sticker); }}
title={sticker.isFavorite ? 'Remover dos favoritos' : 'Favoritar'}
>
<Heart className="w-3 h-3" fill={sticker.isFavorite ? 'currentColor' : 'none'} />
</button>
)}
</div>
);
}
export default function StickerPanel({ instanceId, jid, onSend }: StickerPanelProps) {
const [tab, setTab] = useState<'recent' | 'favorites'>('recent');
const [stickers, setStickers] = useState<StickerRecord[]>([]);
const [loading, setLoading] = useState(true);
const fetchedRef = useRef(false);
const load = useCallback(async (force = false) => {
setLoading(true);
try {
const { listVersion, stickers: list } = await stickerApi.list();
if (!force) {
const cached = localStorage.getItem(LIST_VERSION_KEY);
if (cached === listVersion) {
// versão igual — sem novidades, mas ainda mostra o que temos
}
localStorage.setItem(LIST_VERSION_KEY, listVersion);
}
setStickers(list);
} catch {
// silencioso — sem stickers ainda
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
load();
}, [load]);
const handleSend = useCallback(async (sticker: StickerRecord) => {
const dataUrl = await getStickerFromCache(sticker.sha256);
if (!dataUrl) return;
// Converte dataURL → Blob → envia
const res = await fetch(dataUrl);
const blob = await res.blob();
onSend(blob);
}, [onSend]);
const handleToggleFavorite = useCallback(async (sticker: StickerRecord) => {
const result = await stickerApi.toggleFavorite(sticker.id);
setStickers((prev) =>
prev.map((s) => s.id === sticker.id ? { ...s, isFavorite: result.isFavorite } : s)
);
}, []);
const displayed =
tab === 'favorites'
? stickers.filter((s) => s.isFavorite)
: stickers; // recent = all sorted by lastSeenAt (backend already sorts)
return (
<div className="w-72 bg-white rounded-2xl shadow-2xl border border-gray-100 overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-gray-100">
<button
onClick={() => setTab('recent')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
tab === 'recent' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
}`}
>
<Clock className="w-3.5 h-3.5" />
Recentes
</button>
<button
onClick={() => setTab('favorites')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
tab === 'favorites' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
}`}
>
<Heart className="w-3.5 h-3.5" />
Favoritos
</button>
</div>
{/* Content */}
<div className="h-[220px] overflow-y-auto p-2">
{loading ? (
<div className="flex items-center justify-center h-full text-gray-300">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
) : displayed.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-gray-300">
{tab === 'favorites' ? (
<>
<Heart className="w-8 h-8" />
<span className="text-xs">Nenhum favorito ainda</span>
</>
) : (
<>
<span className="text-3xl">🙂</span>
<span className="text-xs text-center">
As figurinhas que você receber<br />aparecem aqui automaticamente
</span>
</>
)}
</div>
) : (
<div className="grid grid-cols-4 gap-0.5">
{displayed.map((s) => (
<StickerItem
key={s.id}
sticker={s}
onSend={handleSend}
onToggleFavorite={handleToggleFavorite}
/>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,82 @@
'use client'
/**
* useStickerCache — cache de figurinhas em IndexedDB.
*
* Chave de cache: sha256 hex do arquivo (conteúdo permanente — nunca muda).
* Sem TTL: stickers são imutáveis, então o cache é eterno.
* O único motivo para uma entrada sumir é o usuário limpar dados do navegador.
*
* Armazenamento: dataURL base64 do WebP (compatível com <img src>).
*/
const DB_NAME = 'nw-stickers'
const STORE_NAME = 'blobs'
const DB_VERSION = 1
interface CacheEntry {
sha256: string
dataUrl: string
}
let dbPromise: Promise<IDBDatabase> | null = null
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'sha256' })
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => { dbPromise = null; reject(req.error) }
})
return dbPromise
}
export async function getStickerFromCache(sha256: string): Promise<string | null> {
if (typeof window === 'undefined') return null
try {
const db = await openDB()
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).get(sha256)
req.onsuccess = () => resolve((req.result as CacheEntry | undefined)?.dataUrl ?? null)
req.onerror = () => resolve(null)
})
} catch {
return null
}
}
export async function setStickerInCache(sha256: string, dataUrl: string): Promise<void> {
if (typeof window === 'undefined') return
try {
const db = await openDB()
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const entry: CacheEntry = { sha256, dataUrl }
const req = tx.objectStore(STORE_NAME).put(entry)
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
} catch { /* quota exceeded — ignora */ }
}
export async function hasStickerInCache(sha256: string): Promise<boolean> {
if (typeof window === 'undefined') return false
try {
const db = await openDB()
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).count(sha256)
req.onsuccess = () => resolve(req.result > 0)
req.onerror = () => resolve(false)
})
} catch {
return false
}
}
+22
View File
@@ -8,6 +8,8 @@
"name": "newwhats-dashboard",
"version": "1.0.0",
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@xyflow/react": "^12.10.2",
"axios": "^1.6.0",
"date-fns": "^4.1.0",
@@ -46,6 +48,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@emoji-mart/data": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz",
"integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw=="
},
"node_modules/@emoji-mart/react": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz",
"integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==",
"peerDependencies": {
"emoji-mart": "^5.2",
"react": "^16.8 || ^17 || ^18"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1354,6 +1370,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-mart": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
"peer": true
},
"node_modules/emoji-picker-react": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.18.0.tgz",
@@ -9,6 +9,8 @@
"lint": "next lint"
},
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@xyflow/react": "^12.10.2",
"axios": "^1.6.0",
"date-fns": "^4.1.0",
@@ -127,15 +127,26 @@ export default function WhatsAppInboxPage() {
const [isMobile, setIsMobile] = useState(false)
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)
// Preloader: aparece APENAS em hard reload (F5 / URL direta) — nunca em
// navegação client-side (router.push). Detectado por:
// 1. performance.getEntriesByType('navigation')[0].type === 'reload'
// 2. ausência do flag sessionStorage 'nw:inbox-loaded' (primeira visita na sessão)
// sessionStorage sobrevive a navegações client-side mas é zerado em nova aba.
const [pageReady, setPageReady] = useState<boolean>(() => {
if (typeof window === 'undefined') return false
const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined
const isHardReload = navEntry ? navEntry.type === 'reload' : (performance.navigation?.type === 1)
if (isHardReload) return false
return sessionStorage.getItem('nw:inbox-loaded') === '1'
})
const prevLoadingRef = useRef(false)
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (prevLoadingRef.current && !loading) {
readyTimerRef.current = setTimeout(() => setPageReady(true), 400)
readyTimerRef.current = setTimeout(() => {
setPageReady(true)
sessionStorage.setItem('nw:inbox-loaded', '1')
}, 400)
}
prevLoadingRef.current = loading
return () => { if (readyTimerRef.current) clearTimeout(readyTimerRef.current) }
@@ -202,6 +202,17 @@ export const mediaApi = {
})
.then((r) => r.data)
},
sendSticker: (instanceId: string, jid: string, blob: Blob) => {
const fd = new FormData()
fd.append('file', blob, 'sticker.webp')
fd.append('jid', jid)
return api
.post(`/api/instances/${instanceId}/send-media`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
.then((r) => r.data)
},
}
// ─── Chatbot / IA ─────────────────────────────────────────────────────────────
@@ -517,3 +528,26 @@ export const scheduleApi = {
logs: (id: string) =>
api.get(`/api/scheduled/${id}/logs`).then((r) => r.data) as Promise<ScheduleLog[]>,
}
// ─── Stickers ─────────────────────────────────────────────────────────────────
export interface StickerRecord {
id: string
sha256: string
wasabiPath: string
isAnimated: boolean
isFavorite: boolean
useCount?: number
lastSeenAt?: string
}
export const stickerApi = {
list: () =>
api.get('/api/stickers').then((r) => r.data) as Promise<{ listVersion: string; stickers: StickerRecord[] }>,
recent: () =>
api.get('/api/stickers/recent').then((r) => r.data) as Promise<{ stickers: StickerRecord[] }>,
toggleFavorite: (id: string) =>
api.patch(`/api/stickers/${id}/favorite`).then((r) => r.data) as Promise<{ id: string; isFavorite: boolean }>,
}