Files
scoreodonto.com/frontend/views/newwhats/components/ui/Avatar.tsx
T
VPS 4 Builder 3042ddca38 feat(newwhats): frontend do plugin (Inbox/Sessions/Secretária) em tela cheia
- /wa-inbox, /wa-sessions e /wa-secretaria rodam sem a sidebar do scoreodonto
  (isStandaloneView); cada tela tem seu "Voltar" (Secretária ganhou botão na ThinNav).
- SessionsView: banner "Você já possui acesso ao WhatsApp" quando há sessão ativa.
- avatares: usa a URL do CDN (contactAvatarUrl/instance.avatar) direto; Avatar
  exibe sem depender de version; self-heal no onError re-busca a foto atual.
- deps: framer-motion, immer. Dev override com HMR (docker-compose.dev.yml).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:54:24 +02:00

148 lines
5.9 KiB
TypeScript

'use client';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { getAvatarInitials, getAvatarColor } from '../../utils/inboxUtils';
import { getAvatarFromCache, setAvatarInCache } from '../../hooks/useAvatarCache';
import { avatarApi } from '../../services/chatApiService';
interface AvatarProps {
chat: any;
size?: number;
className?: string;
}
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 cacheAttempted = useRef(false);
const refreshTried = useRef(false);
const imageUrl = chat?.avatar_url || null;
const version = chat?.avatar_version || null;
const remoteJid = chat?.remote_jid || '';
const instanceName = chat?.instance_name || '';
// 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;
refreshTried.current = false;
setIsLoaded(false);
if (!imageUrl) {
setShowInitials(true);
setDisplayUrl(null);
return;
}
setShowInitials(false);
// Sem versão (ex.: satélite, cujo ext /inbox não envia avatar_version) →
// usa a URL do CDN direto, sem cache IndexedDB (o cache depende da versão
// para invalidar). A imagem ainda carrega normalmente.
if (!version) {
setDisplayUrl(imageUrl);
cacheAttempted.current = true;
return;
}
// 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 = async () => {
// Self-heal: a URL do CDN do WhatsApp pode ter expirado (404). Tenta UMA
// vez re-buscar a foto atual pela conexão viva do motor antes de desistir.
if (!refreshTried.current && instanceName && remoteJid) {
refreshTried.current = true;
const fresh = await avatarApi.refresh(instanceName, remoteJid);
if (fresh && fresh !== displayUrl) {
setIsLoaded(false);
setDisplayUrl(fresh);
return;
}
}
setShowInitials(true);
setDisplayUrl(null);
};
const initials = useMemo(() => getAvatarInitials(chat), [chat]);
const bgColor = useMemo(
() => getAvatarColor(chat?.remote_jid || chat?.phone || chat?.displayName || 'anon'),
[chat]
);
return (
<div
className={`relative flex-shrink-0 rounded-full overflow-hidden bg-gray-100 flex items-center justify-center border border-black/5 ${className}`}
style={{ width: size, height: size }}
>
<AnimatePresence mode="wait">
{!showInitials && displayUrl ? (
<motion.img
key={displayUrl}
src={displayUrl}
alt=""
loading="lazy"
decoding="async"
crossOrigin="anonymous"
initial={{ opacity: 0 }}
animate={{ opacity: isLoaded ? 1 : 0 }}
transition={{ duration: 0.3 }}
onLoad={handleLoad}
onError={handleError}
className="w-full h-full object-cover"
/>
) : (
<motion.div
key="initials"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="w-full h-full flex items-center justify-center text-white font-semibold"
style={{ backgroundColor: bgColor, fontSize: size * 0.4 }}
>
{initials}
</motion.div>
)}
</AnimatePresence>
{/* 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>
);
}