feat: enhance chat components with improved accessibility features, including ARIA attributes for search and user status, and implement message length validation and file type checks in message input handling

This commit is contained in:
2025-12-08 23:16:05 -03:00
parent e46738c5bf
commit 1810cbabe2
22 changed files with 1364 additions and 249 deletions

View File

@@ -7,35 +7,52 @@
minimizarChat,
maximizarChat,
abrirChat,
abrirConversa
abrirConversa,
notificacaoAtiva
} from '$lib/stores/chatStore';
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import ChatList from './ChatList.svelte';
import ChatWindow from './ChatWindow.svelte';
import { MessageCircle, MessageSquare, Minus, Maximize2, X, Bell } from 'lucide-svelte';
import ConnectionIndicator from './ConnectionIndicator.svelte';
import { MessageSquare, Minus, Maximize2, X, Bell } from 'lucide-svelte';
import { SvelteSet } from 'svelte/reactivity';
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
// Query para verificar o ID do usuário logado (usar como referência)
// Query otimizada: usar apenas uma query para obter usuário atual
// Priorizar obterPerfil pois retorna mais informações úteis
const meuPerfilQuery = useQuery(api.usuarios.obterPerfil, {});
// Usuário atual
const currentUser = useQuery(api.auth.getCurrentUser, {});
// Derivar ID do usuário de forma otimizada (usar perfil primeiro, fallback para currentUser)
const meuId = $derived(() => {
if (meuPerfilQuery?.data?._id) {
return String(meuPerfilQuery.data._id).trim();
}
if (currentUser?.data?._id) {
return String(currentUser.data._id).trim();
}
return null;
});
let isOpen = $derived(false);
let isMinimized = $derived(false);
let activeConversation = $state<string | null>(null);
// Função para obter a URL do avatar/foto do usuário logado
// Função para obter a URL do avatar/foto do usuário logado (otimizada)
const avatarUrlDoUsuario = $derived(() => {
// Priorizar perfil (tem mais informações)
const perfil = meuPerfilQuery?.data;
if (perfil?.fotoPerfilUrl) {
return perfil.fotoPerfilUrl;
}
// Fallback para currentUser
const usuario = currentUser?.data;
if (!usuario) return null;
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
if (usuario.fotoPerfilUrl) {
if (usuario?.fotoPerfilUrl) {
return usuario.fotoPerfilUrl;
}
@@ -52,6 +69,7 @@
let dragThreshold = $state(5); // Distância mínima em pixels para considerar arrastar
let hasMoved = $state(false); // Flag para verificar se houve movimento durante o arrastar
let shouldPreventClick = $state(false); // Flag para prevenir clique após arrastar
let isDoubleClicking = $state(false); // Flag para prevenir clique simples após duplo clique
// Suporte a gestos touch (swipe)
let touchStart = $state<{ x: number; y: number; time: number } | null>(null);
@@ -405,47 +423,29 @@
}
}
// Throttle para evitar execuções muito frequentes do effect
let ultimaExecucaoNotificacao = $state(0);
const THROTTLE_NOTIFICACAO_MS = 1000; // 1 segundo entre execuções
$effect(() => {
if (todasConversas?.data && currentUser?.data?._id) {
const agora = Date.now();
const tempoDesdeUltimaExecucao = agora - ultimaExecucaoNotificacao;
// Throttle: só executar se passou tempo suficiente
if (tempoDesdeUltimaExecucao < THROTTLE_NOTIFICACAO_MS && ultimaExecucaoNotificacao > 0) {
return;
}
if (todasConversas?.data && meuId()) {
ultimaExecucaoNotificacao = agora;
const conversas = todasConversas.data as ConversaComTimestamp[];
const meuIdAtual = meuId();
// Encontrar conversas com novas mensagens
// Obter ID do usuário logado de forma robusta
// Prioridade: usar query do Convex (mais confiável) > authStore
const usuarioLogado = currentUser?.data;
const perfilConvex = meuPerfilQuery?.data;
// Usar ID do Convex se disponível, caso contrário usar authStore
let meuId: string | null = null;
if (perfilConvex && perfilConvex._id) {
// Usar ID retornado pela query do Convex (mais confiável)
meuId = String(perfilConvex._id).trim();
} else if (usuarioLogado && usuarioLogado._id) {
// Fallback para authStore
meuId = String(usuarioLogado._id).trim();
}
if (!meuId) {
console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado:', {
currentUser: !!usuarioLogado,
currentUserId: usuarioLogado?._id,
convexPerfil: !!perfilConvex,
convexId: perfilConvex?._id
});
if (!meuIdAtual) {
console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado');
return;
}
// Log para debug (apenas em desenvolvimento)
if (import.meta.env.DEV) {
console.log('🔍 [ChatWidget] Usuário logado identificado:', {
id: meuId,
fonte: perfilConvex ? 'Convex Query' : 'CurrentUser',
nome: usuarioLogado?.nome || perfilConvex?.nome,
email: usuarioLogado?.email
});
}
conversas.forEach((conv) => {
if (!conv.ultimaMensagemTimestamp) return;
@@ -455,21 +455,8 @@
? String(conv.ultimaMensagemRemetenteId).trim()
: null;
// Log para debug da comparação (apenas em desenvolvimento)
if (import.meta.env.DEV && remetenteIdStr) {
const ehMinhaMensagem = remetenteIdStr === meuId;
if (ehMinhaMensagem) {
console.log('✅ [ChatWidget] Mensagem identificada como própria (ignorada):', {
conversaId: conv._id,
meuId,
remetenteId: remetenteIdStr,
mensagem: conv.ultimaMensagem?.substring(0, 50)
});
}
}
// Se a mensagem foi enviada pelo próprio usuário, ignorar completamente
if (remetenteIdStr && remetenteIdStr === meuId) {
if (remetenteIdStr && remetenteIdStr === meuIdAtual) {
// Mensagem enviada pelo próprio usuário - não tocar beep nem mostrar notificação
// Marcar como notificada para evitar processamento futuro
const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`;
@@ -490,14 +477,29 @@
const conversaIdStr = String(conv._id).trim();
const estaConversaEstaAberta = conversaAtivaId === conversaIdStr;
// Verificar se outra notificação já está ativa para esta mensagem
const notificacaoAtual = $notificacaoAtiva;
const jaTemNotificacaoAtiva =
notificacaoAtual &&
notificacaoAtual.conversaId === conversaIdStr &&
notificacaoAtual.mensagemId === mensagemId;
// Só mostrar notificação se:
// 1. O chat não está aberto OU
// 2. O chat está aberto mas não estamos vendo essa conversa específica
if (!isOpen || !estaConversaEstaAberta) {
// 3. E não há outra notificação ativa para esta mensagem
if ((!isOpen || !estaConversaEstaAberta) && !jaTemNotificacaoAtiva) {
// Marcar como notificada ANTES de mostrar notificação (evita duplicação)
mensagensNotificadasGlobal.add(mensagemId);
salvarMensagensNotificadasGlobal();
// Registrar notificação ativa no store global
notificacaoAtiva.set({
conversaId: conversaIdStr,
mensagemId,
componente: 'widget'
});
// Tocar som de notificação (apenas uma vez)
tocarSomNotificacaoGlobal();
@@ -509,13 +511,17 @@
};
showGlobalNotificationPopup = true;
// Ocultar popup após 5 segundos
// Ocultar popup após 5 segundos - garantir limpeza
if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout);
globalNotificationTimeout = null;
}
globalNotificationTimeout = setTimeout(() => {
showGlobalNotificationPopup = false;
globalNotificationMessage = null;
globalNotificationTimeout = null;
// Limpar notificação ativa do store
notificacaoAtiva.set(null);
}, 5000);
} else {
// Chat está aberto e estamos vendo essa conversa - marcar como visualizada
@@ -525,6 +531,14 @@
}
});
}
// Cleanup: limpar timeout quando o effect for desmontado
return () => {
if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout);
globalNotificationTimeout = null;
}
};
});
function handleToggle() {
@@ -583,6 +597,56 @@
maximizarChat();
}
// Handler para duplo clique no botão flutuante - abre e maximiza
function handleDoubleClick() {
// Marcar que estamos processando um duplo clique
isDoubleClicking = true;
// Se o chat estiver fechado ou minimizado, abrir e maximizar
if (!isOpen || isMinimized) {
abrirChat();
// Aguardar um pouco para garantir que o chat foi aberto antes de maximizar
setTimeout(() => {
if (position) {
// Salvar tamanho e posição atuais antes de maximizar
previousSize = { ...windowSize };
previousPosition = { ...position };
// Maximizar completamente
const winWidth =
windowDimensions.width ||
(typeof window !== 'undefined' ? window.innerWidth : DEFAULT_WIDTH);
const winHeight =
windowDimensions.height ||
(typeof window !== 'undefined' ? window.innerHeight : DEFAULT_HEIGHT);
windowSize = {
width: winWidth,
height: winHeight
};
position = {
x: 0,
y: 0
};
isMaximized = true;
saveSize();
ajustarPosicao();
maximizarChat();
}
// Resetar flag após processar
setTimeout(() => {
isDoubleClicking = false;
}, 300);
}, 50);
} else {
// Se já estiver aberto, apenas maximizar
handleMaximize();
setTimeout(() => {
isDoubleClicking = false;
}, 300);
}
}
// Funcionalidade de arrastar
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0 || !position) return; // Apenas botão esquerdo
@@ -929,6 +993,12 @@
}}
ontouchstart={handleTouchStart}
onclick={(e) => {
// Prevenir clique simples se estamos processando um duplo clique
if (isDoubleClicking) {
e.preventDefault();
e.stopPropagation();
return;
}
// Só executar toggle se não houve movimento durante o arrastar
if (!shouldPreventClick && !hasMoved && !isTouching) {
handleToggle();
@@ -939,7 +1009,16 @@
shouldPreventClick = false; // Resetar após prevenir
}
}}
aria-label="Abrir chat"
ondblclick={(e) => {
// Prevenir que o clique simples seja executado após o duplo clique
e.preventDefault();
e.stopPropagation();
// Executar maximização apenas se não houve movimento
if (!shouldPreventClick && !hasMoved && !isTouching) {
handleDoubleClick();
}
}}
aria-label="Abrir chat (duplo clique para maximizar)"
>
<!-- Anel de brilho rotativo melhorado com múltiplas camadas -->
<div
@@ -948,8 +1027,21 @@
></div>
<!-- Segunda camada para efeito de profundidade -->
<div
class="absolute inset-0 rounded-full opacity-0 transition-opacity duration-700 group-hover:opacity-60"
class="absolute inset-0 rounded-lg opacity-0 transition-opacity duration-700 group-hover:opacity-60 cursor-pointer"
style="background: conic-gradient(from 180deg, transparent 0%, rgba(255,255,255,0.2) 30%, transparent 60%); animation: rotate 4s linear infinite reverse; transform-origin: center;"
onclick={(e) => {
// Propagar o clique para o elemento pai
e.stopPropagation();
if (!isDoubleClicking && !shouldPreventClick && !hasMoved && !isTouching) {
handleToggle();
}
}}
ondblclick={(e) => {
e.stopPropagation();
if (!shouldPreventClick && !hasMoved && !isTouching) {
handleDoubleClick();
}
}}
></div>
<!-- Efeito de brilho pulsante durante arrasto -->
{#if isDragging || isTouching}
@@ -960,8 +1052,8 @@
{/if}
<!-- Ícone de chat moderno com efeito 3D -->
<MessageCircle
class="relative z-10 h-7 w-7 text-white transition-all duration-500 group-hover:scale-110"
<MessageSquare
class="relative z-10 h-10 w-10 text-white transition-all duration-500 group-hover:scale-110"
style="filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));"
strokeWidth={2}
/>
@@ -1057,7 +1149,7 @@
{#if avatarUrlDoUsuario()}
<img
src={avatarUrlDoUsuario()}
alt={currentUser?.data?.nome || 'Usuário'}
alt={meuPerfilQuery?.data?.nome || currentUser?.data?.nome || 'Usuário'}
class="h-full w-full object-cover"
/>
{:else}
@@ -1223,6 +1315,9 @@
</div>
{/if}
<!-- Indicador de Conexão -->
<ConnectionIndicator />
<!-- Popup Global de Notificação de Nova Mensagem (quando chat está fechado/minimizado) -->
{#if showGlobalNotificationPopup && globalNotificationMessage}
{@const notificationMsg = globalNotificationMessage}