3042ddca38
- /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>
148 lines
5.9 KiB
TypeScript
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>
|
|
);
|
|
}
|