perf(inbox): cache de avatares no Wasabi e IndexedDB

Solução 3 — Wasabi:
- Após download do CDN do WhatsApp, faz upload fire-and-forget para
  Wasabi com path determinístico (avatars/{tenant}/{instance}/{jid}.jpg)
- Armazena o path relativo em contact.avatarUrl no banco
- Na próxima requisição, serve bytes direto do Wasabi sem tocar no CDN do WA
- Adiciona customPath em StorageUploadPayload e getBuffer() na interface
  StorageProviderInterface/StorageProviderRegistry e plugin

Solução 4 — IndexedDB:
- Backend expõe avatarVersion (MD5 curto do avatarUrl) no response /api/chats
- Campo propagado por chatStore → chatToLegacy → NewWhatsChat
- Hook useAvatarCache (IndexedDB nativo, sem deps) armazena dataUrl do avatar
  com chave jid:version — invalida automaticamente quando o avatar muda no banco
- Avatar.tsx serve do IndexedDB antes de disparar qualquer request HTTP;
  converte via canvas e persiste no IndexedDB na primeira carga bem-sucedida

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-05-11 16:02:08 +02:00
parent cdc32aa9b3
commit 2b9b855fc9
10 changed files with 268 additions and 67 deletions
@@ -1,11 +1,9 @@
'use client';
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
getAvatarInitials,
getAvatarColor,
} from '../../utils/inboxUtils';
import { getAvatarInitials, getAvatarColor } from '../../utils/inboxUtils';
import { getAvatarFromCache, setAvatarInCache } from '../../hooks/useAvatarCache';
interface AvatarProps {
chat: any;
@@ -14,30 +12,73 @@ interface AvatarProps {
}
export default function Avatar({ chat, size = 46, className = '' }: AvatarProps) {
const [displayUrl, setDisplayUrl] = useState<string | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
const [showInitials, setShowInitials] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const cacheAttempted = useRef(false);
// chat.avatar_url é preenchido upstream (useNewInboxBridge, phonebook,
// ContactPicker) com a URL do proxy SÓ quando o banco confirma que existe
// foto. Null aqui = sem foto → Avatar pula a requisição e mostra iniciais.
const imageUrl = chat?.avatar_url || null;
const imageUrl = chat?.avatar_url || null;
const version = chat?.avatar_version || null;
const remoteJid = chat?.remote_jid || '';
// Quando jid ou version muda, tenta servir do cache IndexedDB antes de fazer request HTTP.
// Se não houver cache (primeira vez), usa a URL do proxy normalmente.
useEffect(() => {
cacheAttempted.current = false;
setIsLoaded(false);
setShowInitials(!imageUrl);
}, [chat?.remote_jid, imageUrl]);
const currentUrl = useMemo(() => {
if (showInitials || !imageUrl) return null;
return imageUrl;
}, [showInitials, imageUrl]);
if (!imageUrl || !version) {
setShowInitials(true);
setDisplayUrl(null);
return;
}
const handleImageError = () => {
setShowInitials(false);
// Tenta cache IndexedDB — resultado síncrono do ponto de vista do usuário
getAvatarFromCache(remoteJid, version).then((cached) => {
if (cached) {
setDisplayUrl(cached);
setIsLoaded(true); // já temos os bytes — sem skeleton
} else {
setDisplayUrl(imageUrl); // busca pelo proxy normalmente
}
cacheAttempted.current = true;
}).catch(() => {
setDisplayUrl(imageUrl);
cacheAttempted.current = true;
});
}, [remoteJid, version, imageUrl]);
const handleLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
setIsLoaded(true);
// Salva no IndexedDB após primeira carga bem-sucedida do proxy.
// Só salva se a versão é conhecida e o displayUrl não é já um dataUrl (evita re-cache).
if (!version || !remoteJid || !displayUrl || displayUrl.startsWith('data:')) return;
try {
const img = e.currentTarget;
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || size;
canvas.height = img.naturalHeight || size;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(img, 0, 0);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
setAvatarInCache(remoteJid, version, dataUrl);
} catch { /* CORS ou canvas tainted — ignora, cache não é crítico */ }
};
const handleError = () => {
setShowInitials(true);
setDisplayUrl(null);
};
const initials = useMemo(() => getAvatarInitials(chat), [chat]);
const bgColor = useMemo(() => getAvatarColor(chat?.remote_jid || chat?.phone || chat?.displayName || 'anon'), [chat]);
const bgColor = useMemo(
() => getAvatarColor(chat?.remote_jid || chat?.phone || chat?.displayName || 'anon'),
[chat]
);
return (
<div
@@ -45,18 +86,19 @@ export default function Avatar({ chat, size = 46, className = '' }: AvatarProps)
style={{ width: size, height: size }}
>
<AnimatePresence mode="wait">
{!showInitials && currentUrl ? (
{!showInitials && displayUrl ? (
<motion.img
key={currentUrl}
src={currentUrl}
key={displayUrl}
src={displayUrl}
alt=""
loading="lazy"
decoding="async"
crossOrigin="anonymous"
initial={{ opacity: 0 }}
animate={{ opacity: isLoaded ? 1 : 0 }}
transition={{ duration: 0.3 }}
onLoad={() => setIsLoaded(true)}
onError={handleImageError}
onLoad={handleLoad}
onError={handleError}
className="w-full h-full object-cover"
/>
) : (
@@ -72,8 +114,8 @@ export default function Avatar({ chat, size = 46, className = '' }: AvatarProps)
)}
</AnimatePresence>
{/* Skeleton / Placeholder while loading first image */}
{!isLoaded && !showInitials && (
{/* Skeleton apenas quando buscando pelo proxy (não quando servindo do cache) */}
{!isLoaded && !showInitials && displayUrl && !displayUrl.startsWith('data:') && (
<div className="absolute inset-0 bg-gray-200 animate-pulse" />
)}
</div>
@@ -42,6 +42,8 @@ function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]):
? getAvatarProxyUrl({ instance_name: c.instanceId, remote_jid: c.jid }, 'contact')
: null
const avatar_version: string | null = c.contactAvatarVersion ?? null
return {
id: c.id as any,
instance_name: c.instanceId,
@@ -49,6 +51,7 @@ function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]):
phone: formattedPhone,
lead_name: c.contactName,
avatar_url,
avatar_version,
bio: isGroup ? 'Detalhes do Grupo' : (formattedPhone || 'Clique para informações'),
displayName,
subject: c.protocolNumber ? `#${c.protocolNumber}` : (isGroup ? null : formattedPhone),
@@ -0,0 +1,89 @@
'use client'
/**
* useAvatarCache — cache de avatares em IndexedDB.
*
* Chave de cache: `${jid}:${version}` (version = hash curto do avatarUrl no banco).
* Quando a versão muda (novo avatar no banco), a chave muda e o cache invalida
* automaticamente — sem TTL fixo, sem stale data visível ao usuário.
*
* Armazenamento: dataURL base64 do JPEG/WebP (compatível com <img src>).
* Limpeza: entradas com mais de 30 dias são removidas na abertura do DB.
*/
const DB_NAME = 'nw-avatars'
const STORE_NAME = 'blobs'
const DB_VERSION = 1
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 // 30 dias
interface CacheEntry {
key: string // `${jid}:${version}`
dataUrl: string
savedAt: number
}
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: 'key' })
}
}
req.onsuccess = () => {
const db = req.result
pruneOldEntries(db)
resolve(db)
}
req.onerror = () => { dbPromise = null; reject(req.error) }
})
return dbPromise
}
function pruneOldEntries(db: IDBDatabase): void {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const cutoff = Date.now() - MAX_AGE_MS
const req = store.openCursor()
req.onsuccess = () => {
const cursor = req.result
if (!cursor) return
if ((cursor.value as CacheEntry).savedAt < cutoff) cursor.delete()
cursor.continue()
}
}
export async function getAvatarFromCache(jid: string, version: string): Promise<string | null> {
if (typeof window === 'undefined') return null
try {
const db = await openDB()
const key = `${jid}:${version}`
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).get(key)
req.onsuccess = () => resolve((req.result as CacheEntry | undefined)?.dataUrl ?? null)
req.onerror = () => resolve(null)
})
} catch {
return null
}
}
export async function setAvatarInCache(jid: string, version: string, dataUrl: string): Promise<void> {
if (typeof window === 'undefined') return
try {
const db = await openDB()
const key = `${jid}:${version}`
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const entry: CacheEntry = { key, dataUrl, savedAt: Date.now() }
const req = tx.objectStore(STORE_NAME).put(entry)
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
} catch { /* quota exceeded ou IDB indisponível — ignora silenciosamente */ }
}
@@ -22,9 +22,10 @@ function mapBackendChat(raw: any): Chat {
createdAt: raw.createdAt ?? null,
isPinned: raw.isPinned ?? false,
isArchived: raw.isArchived ?? false,
contactName: raw.contact?.name ?? raw.contactName ?? null,
contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null,
contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null,
contactName: raw.contact?.name ?? raw.contactName ?? null,
contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null,
contactAvatarVersion: raw.contact?.avatarVersion ?? raw.contactAvatarVersion ?? null,
contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null,
scoreReputacao: raw.contact?.scoreReputacao ?? raw.scoreReputacao ?? 0,
flagRestricao: raw.contact?.flagRestricao ?? raw.flagRestricao ?? false,
lastMessageBody: raw.lastMessage?.body ?? raw.lastMessageBody ?? null,
@@ -52,6 +53,7 @@ export interface Chat {
isArchived: boolean
contactName: string | null
contactAvatarUrl: string | null
contactAvatarVersion: string | null
contactPhone: string | null
scoreReputacao: number
flagRestricao: boolean
@@ -79,6 +79,7 @@ export interface NewWhatsChat {
phone: string
lead_name: string | null
avatar_url: string | null
avatar_version: string | null
bio: string | null
displayName: string | null
subject: string | null