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

@@ -188,7 +188,10 @@
placeholder="Buscar usuários (nome, email, matrícula)..."
class="input input-bordered w-full pl-10"
bind:value={searchQuery}
aria-label="Buscar usuários ou conversas"
aria-describedby="search-help"
/>
<span id="search-help" class="sr-only">Digite para buscar usuários por nome, email ou matrícula</span>
<Search
class="text-base-content/50 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
strokeWidth={1.5}
@@ -244,6 +247,8 @@
: 'cursor-pointer'}"
onclick={() => handleClickUsuario(usuario)}
disabled={processando}
aria-label="Abrir conversa com {usuario.nome}"
aria-describedby="usuario-status-{usuario._id}"
>
<!-- Ícone de mensagem -->
<div
@@ -260,6 +265,7 @@
fotoPerfilUrl={usuario.fotoPerfilUrl}
nome={usuario.nome}
size="md"
userId={usuario._id}
/>
<!-- Status badge -->
<div class="absolute right-0 bottom-0">
@@ -290,6 +296,9 @@
{usuario.statusMensagem || usuario.email}
</p>
</div>
<span id="usuario-status-{usuario._id}" class="sr-only">
Status: {getStatusLabel(usuario.statusPresenca)}
</span>
</div>
</button>
{/each}

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}

View File

@@ -25,7 +25,8 @@
XCircle,
Phone,
Video,
ChevronDown
ChevronDown,
Search
} from 'lucide-svelte';
//import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
@@ -46,6 +47,11 @@
let showNotificacaoModal = $state(false);
let iniciandoChamada = $state(false);
let chamadaAtiva = $state<Id<'chamadas'> | null>(null);
let showSearch = $state(false);
let searchQuery = $state('');
let searchResults = $state<Array<any>>([]);
let searching = $state(false);
let selectedSearchResult = $state<number>(-1);
// Estados para modal de erro
let showErrorModal = $state(false);
@@ -276,6 +282,7 @@
fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl}
nome={conversa()?.outroUsuario?.nome || 'Usuário'}
size="md"
userId={conversa()?.outroUsuario?._id}
/>
{:else}
<div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-full text-xl">
@@ -361,6 +368,32 @@
<!-- Botões de ação -->
<div class="flex items-center gap-1">
<!-- Botão de Busca -->
<button
type="button"
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
style="background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.2);"
onclick={(e) => {
e.stopPropagation();
showSearch = !showSearch;
if (!showSearch) {
searchQuery = '';
searchResults = [];
}
}}
aria-label="Buscar mensagens"
title="Buscar mensagens"
aria-expanded={showSearch}
>
<div
class="absolute inset-0 bg-green-500/0 transition-colors duration-300 group-hover:bg-green-500/10"
></div>
<Search
class="relative z-10 h-5 w-5 text-green-500 transition-transform group-hover:scale-110"
strokeWidth={2}
/>
</button>
<!-- Botões de Chamada -->
{#if !chamadaAtual && !chamadaAtiva}
<div class="dropdown dropdown-end">
@@ -596,6 +629,110 @@
</div>
</div>
<!-- Barra de Busca (quando ativa) -->
{#if showSearch}
<div
class="border-base-300 bg-base-200 flex items-center gap-2 border-b px-4 py-2"
onclick={(e) => e.stopPropagation()}
>
<Search class="text-base-content/50 h-4 w-4" strokeWidth={2} />
<input
type="text"
placeholder="Buscar mensagens nesta conversa..."
class="input input-sm input-bordered flex-1"
bind:value={searchQuery}
onkeydown={handleSearchKeyDown}
aria-label="Buscar mensagens"
aria-describedby="search-results-info"
/>
<button
type="button"
class="btn btn-sm btn-ghost"
onclick={() => {
showSearch = false;
searchQuery = '';
searchResults = [];
}}
aria-label="Fechar busca"
>
<X class="h-4 w-4" />
</button>
</div>
<!-- Resultados da Busca -->
{#if searchQuery.trim().length >= 2}
<div
class="border-base-300 bg-base-200 max-h-64 overflow-y-auto border-b"
role="listbox"
aria-label="Resultados da busca"
id="search-results"
>
{#if searching}
<div class="flex items-center justify-center p-4">
<span class="loading loading-spinner loading-sm"></span>
<span class="text-base-content/50 ml-2 text-sm">Buscando...</span>
</div>
{:else if searchResults.length > 0}
<p id="search-results-info" class="sr-only">
{searchResults.length} resultado{searchResults.length !== 1 ? 's' : ''} encontrado{searchResults.length !== 1
? 's'
: ''}
</p>
{#each searchResults as resultado, index (resultado._id)}
<button
type="button"
class="hover:bg-base-300 flex w-full items-start gap-3 px-4 py-3 text-left transition-colors {index ===
selectedSearchResult
? 'bg-primary/10'
: ''}"
onclick={() => {
window.dispatchEvent(
new CustomEvent('scrollToMessage', {
detail: { mensagemId: resultado._id }
})
);
showSearch = false;
searchQuery = '';
}}
role="option"
aria-selected={index === selectedSearchResult}
aria-label="Mensagem de {resultado.remetente?.nome || 'Usuário'}"
>
<div class="bg-primary/20 flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full">
{#if resultado.remetente?.fotoPerfilUrl}
<img
src={resultado.remetente.fotoPerfilUrl}
alt={resultado.remetente.nome}
class="h-full w-full object-cover"
/>
{:else}
<span class="text-xs font-semibold">
{resultado.remetente?.nome?.charAt(0).toUpperCase() || 'U'}
</span>
{/if}
</div>
<div class="min-w-0 flex-1">
<p class="text-base-content mb-1 text-xs font-semibold">
{resultado.remetente?.nome || 'Usuário'}
</p>
<p class="text-base-content/70 line-clamp-2 text-xs">
{resultado.conteudo}
</p>
<p class="text-base-content/50 mt-1 text-xs">
{new Date(resultado.enviadaEm).toLocaleString('pt-BR')}
</p>
</div>
</button>
{/each}
{:else if searchQuery.trim().length >= 2}
<div class="p-4 text-center">
<p class="text-base-content/50 text-sm">Nenhuma mensagem encontrada</p>
</div>
{/if}
</div>
{/if}
{/if}
<!-- Mensagens -->
<div class="min-h-0 flex-1 overflow-hidden">
<MessageList conversaId={conversaId as Id<'conversas'>} />

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { onMount } from 'svelte';
import { Wifi, WifiOff, AlertCircle } from 'lucide-svelte';
const client = useConvexClient();
let isOnline = $state(true);
let convexConnected = $state(true);
let showIndicator = $state(false);
// Detectar status de conexão com internet
function updateOnlineStatus() {
isOnline = navigator.onLine;
showIndicator = !isOnline || !convexConnected;
}
// Detectar status de conexão com Convex
function updateConvexStatus() {
// Verificar se o client está conectado
// O Convex client expõe o status de conexão
const connectionState = (client as any).connectionState?.();
convexConnected = connectionState === 'Connected' || connectionState === 'Connecting';
showIndicator = !isOnline || !convexConnected;
}
onMount(() => {
// Verificar status inicial
updateOnlineStatus();
updateConvexStatus();
// Listeners para mudanças de conexão
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
// Verificar status do Convex periodicamente
const interval = setInterval(() => {
updateConvexStatus();
}, 5000);
return () => {
window.removeEventListener('online', updateOnlineStatus);
window.removeEventListener('offline', updateOnlineStatus);
clearInterval(interval);
};
});
// Observar mudanças no client do Convex
$effect(() => {
// Tentar acessar o estado de conexão do Convex
try {
const connectionState = (client as any).connectionState?.();
if (connectionState !== undefined) {
convexConnected = connectionState === 'Connected' || connectionState === 'Connecting';
showIndicator = !isOnline || !convexConnected;
}
} catch {
// Se não conseguir acessar, assumir conectado
convexConnected = true;
}
});
</script>
{#if showIndicator}
<div
class="fixed bottom-4 left-4 z-[99998] flex items-center gap-2 rounded-lg px-3 py-2 shadow-lg transition-all"
class:bg-error={!isOnline || !convexConnected}
class:bg-warning={isOnline && !convexConnected}
class:text-white={!isOnline || !convexConnected}
role="status"
aria-live="polite"
aria-label={!isOnline
? 'Sem conexão com a internet'
: !convexConnected
? 'Reconectando ao servidor'
: 'Conectado'}
>
{#if !isOnline}
<WifiOff class="h-4 w-4" />
<span class="text-sm font-medium">Sem conexão</span>
{:else if !convexConnected}
<AlertCircle class="h-4 w-4" />
<span class="text-sm font-medium">Reconectando...</span>
{:else}
<Wifi class="h-4 w-4" />
<span class="text-sm font-medium">Conectado</span>
{/if}
</div>
{/if}

View File

@@ -28,10 +28,42 @@
const client = useConvexClient();
const conversas = useQuery(api.chat.listarConversas, {});
// Constantes de validação
const MAX_MENSAGEM_LENGTH = 5000; // Limite de caracteres por mensagem
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
// Tipos de arquivo permitidos
const TIPOS_PERMITIDOS = {
imagens: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'],
documentos: [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain',
'text/csv'
],
arquivos: [
'application/zip',
'application/x-rar-compressed',
'application/x-7z-compressed',
'application/x-tar',
'application/gzip'
]
};
const TODOS_TIPOS_PERMITIDOS = [
...TIPOS_PERMITIDOS.imagens,
...TIPOS_PERMITIDOS.documentos,
...TIPOS_PERMITIDOS.arquivos
];
let mensagem = $state('');
let textarea: HTMLTextAreaElement;
let enviando = $state(false);
let uploadingFile = $state(false);
let uploadProgress = $state(0); // Progresso do upload (0-100)
let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null;
let showEmojiPicker = $state(false);
let mensagemRespondendo: {
@@ -42,6 +74,8 @@
let showMentionsDropdown = $state(false);
let mentionQuery = $state('');
let mentionStartPos = $state(0);
let selectedMentionIndex = $state(0); // Índice do participante selecionado no dropdown
let mensagemMuitoLonga = $state(false);
// Emojis mais usados
const emojis = [
@@ -134,6 +168,19 @@
// Auto-resize do textarea e detectar menções
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
// Validar tamanho da mensagem
if (mensagem.length > MAX_MENSAGEM_LENGTH) {
mensagemMuitoLonga = true;
// Limitar ao tamanho máximo
mensagem = mensagem.substring(0, MAX_MENSAGEM_LENGTH);
if (textarea) {
textarea.value = mensagem;
}
} else {
mensagemMuitoLonga = false;
}
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
@@ -160,11 +207,13 @@
// Indicador de digitação (debounce de 1s)
if (digitacaoTimeout) {
clearTimeout(digitacaoTimeout);
digitacaoTimeout = null;
}
digitacaoTimeout = setTimeout(() => {
if (mensagem.trim()) {
client.mutation(api.chat.indicarDigitacao, { conversaId });
}
digitacaoTimeout = null;
}, 1000);
}
@@ -175,6 +224,7 @@
mensagem = antes + `@${nome} ` + depois;
showMentionsDropdown = false;
mentionQuery = '';
selectedMentionIndex = 0; // Resetar índice selecionado
if (textarea) {
textarea.focus();
const newPos = antes.length + nome.length + 2;
@@ -187,6 +237,12 @@
async function handleEnviar() {
const texto = mensagem.trim();
if (!texto || enviando) return;
// Validar tamanho antes de enviar
if (texto.length > MAX_MENSAGEM_LENGTH) {
alert(`Mensagem muito longa. O limite é de ${MAX_MENSAGEM_LENGTH} caracteres.`);
return;
}
// Extrair menções do texto (@nome)
const mencoesIds: Id<'usuarios'>[] = [];
@@ -270,22 +326,43 @@
window.addEventListener('responderMensagem', handler);
return () => {
window.removeEventListener('responderMensagem', handler);
// Limpar timeout de digitação ao desmontar
if (digitacaoTimeout) {
clearTimeout(digitacaoTimeout);
digitacaoTimeout = null;
}
};
});
function handleKeyDown(e: KeyboardEvent) {
// Navegar dropdown de menções
if (showMentionsDropdown && participantesFiltrados().length > 0) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
const participantes = participantesFiltrados();
if (e.key === 'ArrowDown') {
e.preventDefault();
// Implementação simples: selecionar primeiro participante
if (e.key === 'Enter') {
inserirMencao(participantesFiltrados()[0]);
selectedMentionIndex = Math.min(selectedMentionIndex + 1, participantes.length - 1);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
selectedMentionIndex = Math.max(selectedMentionIndex - 1, 0);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
if (participantes[selectedMentionIndex]) {
inserirMencao(participantes[selectedMentionIndex]);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
showMentionsDropdown = false;
selectedMentionIndex = 0;
return;
}
}
@@ -302,41 +379,111 @@
const file = input.files?.[0];
if (!file) return;
// Validar tamanho (max 10MB)
if (file.size > 10 * 1024 * 1024) {
alert('Arquivo muito grande. O tamanho máximo é 10MB.');
// Validar tamanho
if (file.size > MAX_FILE_SIZE) {
alert(`Arquivo muito grande. O tamanho máximo é ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB.`);
input.value = '';
return;
}
// Validar tipo de arquivo
if (!TODOS_TIPOS_PERMITIDOS.includes(file.type)) {
alert(
`Tipo de arquivo não permitido. Tipos aceitos:\n- Imagens: JPEG, PNG, GIF, WebP, SVG\n- Documentos: PDF, Word, Excel, PowerPoint, TXT, CSV\n- Arquivos: ZIP, RAR, 7Z, TAR, GZIP`
);
input.value = '';
return;
}
// Validar extensão do arquivo (segurança adicional)
const extensao = file.name.split('.').pop()?.toLowerCase();
const extensoesPermitidas = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'svg',
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'csv',
'zip',
'rar',
'7z',
'tar',
'gz'
];
if (extensao && !extensoesPermitidas.includes(extensao)) {
alert(`Extensão de arquivo não permitida: .${extensao}`);
input.value = '';
return;
}
// Sanitizar nome do arquivo (remover caracteres perigosos)
const nomeSanitizado = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
try {
uploadingFile = true;
uploadProgress = 0;
// 1. Obter upload URL
const uploadUrl = await client.mutation(api.chat.uploadArquivoChat, {
conversaId
});
// 2. Upload do arquivo
const result = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file
// 2. Upload do arquivo com progresso
const xhr = new XMLHttpRequest();
// Promise para aguardar upload completo
const uploadPromise = new Promise<string>((resolve, reject) => {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
uploadProgress = Math.round((e.loaded / e.total) * 100);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
resolve(response.storageId);
} catch {
reject(new Error('Resposta inválida do servidor'));
}
} else {
reject(new Error(`Falha no upload: ${xhr.statusText}`));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Erro de rede durante upload'));
});
xhr.addEventListener('abort', () => {
reject(new Error('Upload cancelado'));
});
xhr.open('POST', uploadUrl);
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);
});
if (!result.ok) {
throw new Error('Falha no upload');
}
const { storageId } = await result.json();
const storageId = await uploadPromise;
// 3. Enviar mensagem com o arquivo
const tipo: 'imagem' | 'arquivo' = file.type.startsWith('image/') ? 'imagem' : 'arquivo';
await client.mutation(api.chat.enviarMensagem, {
conversaId,
conteudo: tipo === 'imagem' ? '' : file.name,
conteudo: tipo === 'imagem' ? '' : nomeSanitizado,
tipo,
arquivoId: storageId,
arquivoNome: file.name,
arquivoNome: nomeSanitizado,
arquivoTamanho: file.size,
arquivoTipo: file.type
});
@@ -383,31 +530,48 @@
<div class="flex items-end gap-2">
<!-- Botão de anexar arquivo MODERNO -->
<label
class="group relative flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
style="background: rgba(102, 126, 234, 0.1); border: 1px solid rgba(102, 126, 234, 0.2);"
title="Anexar arquivo"
>
<input
type="file"
class="hidden"
onchange={handleFileUpload}
disabled={uploadingFile || enviando}
accept="*/*"
/>
<div
class="bg-primary/0 group-hover:bg-primary/10 absolute inset-0 transition-colors duration-300"
></div>
{#if uploadingFile}
<span class="loading loading-spinner loading-sm relative z-10"></span>
{:else}
<!-- Ícone de clipe moderno -->
<Paperclip
class="text-primary relative z-10 h-5 w-5 transition-transform group-hover:scale-110"
strokeWidth={2}
<div class="relative shrink-0">
<label
class="group relative flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
style="background: rgba(102, 126, 234, 0.1); border: 1px solid rgba(102, 126, 234, 0.2);"
title="Anexar arquivo"
aria-label="Anexar arquivo"
>
<input
type="file"
class="hidden"
onchange={handleFileUpload}
disabled={uploadingFile || enviando}
accept="*/*"
aria-label="Selecionar arquivo para anexar"
/>
<div
class="bg-primary/0 group-hover:bg-primary/10 absolute inset-0 transition-colors duration-300"
></div>
{#if uploadingFile}
<span class="loading loading-spinner loading-sm relative z-10"></span>
{:else}
<!-- Ícone de clipe moderno -->
<Paperclip
class="text-primary relative z-10 h-5 w-5 transition-transform group-hover:scale-110"
strokeWidth={2}
/>
{/if}
</label>
<!-- Barra de progresso do upload -->
{#if uploadingFile && uploadProgress > 0}
<div
class="absolute -bottom-1 left-0 right-0 h-1 rounded-full bg-base-200"
style="z-index: 20;"
>
<div
class="h-full rounded-full bg-primary transition-all duration-300"
style="width: {uploadProgress}%;"
></div>
</div>
{/if}
</label>
</div>
<!-- Botão de EMOJI MODERNO -->
<div class="relative shrink-0">
@@ -418,6 +582,8 @@
onclick={() => (showEmojiPicker = !showEmojiPicker)}
disabled={enviando || uploadingFile}
aria-label="Adicionar emoji"
aria-expanded={showEmojiPicker}
aria-haspopup="true"
title="Adicionar emoji"
>
<div
@@ -434,13 +600,18 @@
<div
class="bg-base-100 border-base-300 absolute bottom-full left-0 z-50 mb-2 rounded-xl border p-3 shadow-2xl"
style="width: 280px; max-height: 200px; overflow-y-auto;"
role="dialog"
aria-label="Selecionar emoji"
id="emoji-picker"
>
<div class="grid grid-cols-10 gap-1">
<div class="grid grid-cols-10 gap-1" role="grid">
{#each emojis as emoji}
<button
type="button"
class="hover:bg-base-200 cursor-pointer rounded p-1 text-2xl transition-transform hover:scale-125"
onclick={() => adicionarEmoji(emoji)}
aria-label="Adicionar emoji {emoji}"
role="gridcell"
>
{emoji}
</button>
@@ -458,21 +629,43 @@
oninput={handleInput}
onkeydown={handleKeyDown}
placeholder="Digite uma mensagem... (use @ para mencionar)"
class="textarea textarea-bordered max-h-[120px] min-h-[44px] w-full resize-none pr-10"
class="textarea textarea-bordered max-h-[120px] min-h-[44px] w-full resize-none pr-10 {mensagemMuitoLonga
? 'textarea-error'
: ''}"
rows="1"
disabled={enviando || uploadingFile}
maxlength={MAX_MENSAGEM_LENGTH}
aria-label="Campo de mensagem"
aria-describedby="mensagem-help"
aria-invalid={mensagemMuitoLonga}
></textarea>
{#if mensagemMuitoLonga || mensagem.length > MAX_MENSAGEM_LENGTH * 0.9}
<div class="absolute bottom-1 right-2 text-xs {mensagem.length > MAX_MENSAGEM_LENGTH
? 'text-error'
: 'text-base-content/50'}">
{mensagem.length}/{MAX_MENSAGEM_LENGTH}
</div>
{/if}
<!-- Dropdown de Menções -->
{#if showMentionsDropdown && participantesFiltrados().length > 0 && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
<div
class="bg-base-100 border-base-300 absolute bottom-full left-0 z-50 mb-2 max-h-48 w-64 overflow-y-auto rounded-lg border shadow-xl"
role="listbox"
aria-label="Lista de participantes para mencionar"
id="mentions-dropdown"
>
{#each participantesFiltrados() as participante (participante._id)}
{#each participantesFiltrados() as participante, index (participante._id)}
<button
type="button"
class="hover:bg-base-200 flex w-full items-center gap-2 px-4 py-2 text-left transition-colors"
class="hover:bg-base-200 flex w-full items-center gap-2 px-4 py-2 text-left transition-colors {index === selectedMentionIndex
? 'bg-primary/20'
: ''}"
onclick={() => inserirMencao(participante)}
role="option"
aria-selected={index === selectedMentionIndex}
aria-label="Mencionar {participante.nome}"
id="mention-option-{index}"
>
<div
class="bg-primary/20 flex h-8 w-8 items-center justify-center overflow-hidden rounded-full"
@@ -502,14 +695,15 @@
</div>
<!-- Botão de enviar MODERNO -->
<button
type="button"
class="group relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={handleEnviar}
disabled={!mensagem.trim() || enviando || uploadingFile}
aria-label="Enviar"
>
<button
type="button"
class="group relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={handleEnviar}
disabled={!mensagem.trim() || enviando || uploadingFile}
aria-label="Enviar mensagem"
aria-describedby="mensagem-help"
>
<div
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/10"
></div>
@@ -525,7 +719,7 @@
</div>
<!-- Informação sobre atalhos -->
<p class="text-base-content/50 mt-2 text-center text-xs">
💡 Enter para enviar • Shift+Enter para quebrar linha • 😊 Clique no emoji
<p id="mensagem-help" class="text-base-content/50 mt-2 text-center text-xs" role="note">
💡 Enter para enviar • Shift+Enter para quebrar linha • 😊 Clique no emoji • Use @ para mencionar
</p>
</div>

View File

@@ -6,6 +6,7 @@
import { ptBR } from 'date-fns/locale';
import { onMount, tick } from 'svelte';
import { File, CheckCircle2, CheckCircle, MessageSquare, Bell, X } from 'lucide-svelte';
import { notificacaoAtiva } from '$lib/stores/chatStore';
interface Props {
conversaId: Id<'conversas'>;
@@ -14,10 +15,45 @@
let { conversaId }: Props = $props();
const client = useConvexClient();
const mensagens = useQuery(api.chat.obterMensagens, {
// Estados para paginação
let cursor = $state<Id<'mensagens'> | null>(null);
let todasMensagens = $state<Array<any>>([]);
let carregandoMais = $state(false);
let hasMore = $state(true);
// Query para obter mensagens com paginação
const mensagensQuery = useQuery(api.chat.obterMensagens, {
conversaId,
limit: 50
limit: 50,
cursor: cursor || undefined
});
// Atualizar lista de mensagens quando a query mudar
$effect(() => {
if (mensagensQuery?.data) {
const resultado = mensagensQuery.data as { mensagens: any[]; hasMore: boolean; nextCursor: Id<'mensagens'> | null };
if (cursor === null) {
// Primeira carga: substituir todas as mensagens
todasMensagens = resultado.mensagens || [];
} else {
// Carregamento adicional: adicionar no início (mensagens mais antigas)
todasMensagens = [...(resultado.mensagens || []), ...todasMensagens];
}
hasMore = resultado.hasMore || false;
carregandoMais = false;
}
});
// Resetar quando mudar de conversa
$effect(() => {
cursor = null;
todasMensagens = [];
hasMore = true;
});
const digitando = useQuery(api.chat.obterDigitando, { conversaId });
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId });
const conversas = useQuery(api.chat.listarConversas, {});
@@ -54,8 +90,8 @@
mensagensCarregadas = true;
// Marcar todas as mensagens atuais como já visualizadas (não tocar beep ao abrir)
if (mensagens?.data && mensagens.data.length > 0) {
mensagens.data.forEach((msg) => {
if (todasMensagens.length > 0) {
todasMensagens.forEach((msg) => {
mensagensNotificadas.add(String(msg._id));
});
salvarMensagensNotificadas();
@@ -147,16 +183,44 @@
}
}
// Função para carregar mais mensagens (scroll infinito)
async function carregarMaisMensagens() {
if (carregandoMais || !hasMore || !mensagensQuery?.data) return;
const resultado = mensagensQuery.data as { mensagens: any[]; hasMore: boolean; nextCursor: Id<'mensagens'> | null };
if (!resultado.nextCursor) return;
carregandoMais = true;
cursor = resultado.nextCursor;
// Aguardar um pouco para a query atualizar
await new Promise(resolve => setTimeout(resolve, 100));
}
// Detectar quando usuário rola para o topo para carregar mais mensagens
function handleScroll(e: Event) {
const target = e.target as HTMLDivElement;
// Considerar "no final" se estiver a menos de 150px do final
const distanciaDoFinal = target.scrollHeight - target.scrollTop - target.clientHeight;
const isAtBottom = distanciaDoFinal < 150;
shouldScrollToBottom = isAtBottom;
// Se está próximo do topo (menos de 200px), carregar mais mensagens
if (target.scrollTop < 200 && hasMore && !carregandoMais) {
carregarMaisMensagens();
}
}
// Auto-scroll para a última mensagem quando novas mensagens chegam
// E detectar novas mensagens para tocar som e mostrar popup
$effect(() => {
if (mensagens?.data && messagesContainer) {
const currentCount = mensagens.data.length;
if (todasMensagens.length > 0 && messagesContainer) {
const currentCount = todasMensagens.length;
const isNewMessage = currentCount > lastMessageCount;
// Detectar nova mensagem de outro usuário
if (isNewMessage && mensagens.data.length > 0 && usuarioAtualId) {
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
if (isNewMessage && todasMensagens.length > 0 && usuarioAtualId) {
const ultimaMensagem = todasMensagens[todasMensagens.length - 1];
const mensagemId = String(ultimaMensagem._id);
const remetenteIdStr = ultimaMensagem.remetenteId
? String(ultimaMensagem.remetenteId).trim()
@@ -164,16 +228,33 @@
? String(ultimaMensagem.remetente._id).trim()
: null;
// Verificar se outra notificação já está ativa para esta mensagem
const notificacaoAtual = $notificacaoAtiva;
const conversaIdStr = String(conversaId).trim();
const jaTemNotificacaoAtiva =
notificacaoAtual &&
notificacaoAtual.conversaId === conversaIdStr &&
notificacaoAtual.mensagemId === mensagemId;
// Se é uma nova mensagem de outro usuário (não minha) E ainda não foi notificada
// E não há outra notificação ativa para esta mensagem
if (
remetenteIdStr &&
remetenteIdStr !== usuarioAtualId &&
!mensagensNotificadas.has(mensagemId)
!mensagensNotificadas.has(mensagemId) &&
!jaTemNotificacaoAtiva
) {
// Marcar como notificada antes de tocar som (evita duplicação)
mensagensNotificadas.add(mensagemId);
salvarMensagensNotificadas();
// Registrar notificação ativa no store global
notificacaoAtiva.set({
conversaId: conversaIdStr,
mensagemId,
componente: 'messageList'
});
// Tocar som de notificação (apenas uma vez)
tocarSomNotificacao();
@@ -186,19 +267,55 @@
};
showNotificationPopup = true;
// Ocultar popup após 5 segundos
// Ocultar popup após 5 segundos - garantir limpeza
if (notificationTimeout) {
clearTimeout(notificationTimeout);
notificationTimeout = null;
}
notificationTimeout = setTimeout(() => {
showNotificationPopup = false;
notificationMessage = null;
notificationTimeout = null;
// Limpar notificação ativa do store
notificacaoAtiva.set(null);
}, 5000);
}
}
if (isNewMessage || shouldScrollToBottom) {
// Usar requestAnimationFrame para garantir que o DOM foi atualizado
// Scroll automático inteligente: só rolar se:
// 1. É uma nova mensagem E o usuário está no final (ou perto)
// 2. OU o usuário já estava no final antes
if (isNewMessage) {
// Verificar se está no final antes de fazer scroll
if (messagesContainer) {
const distanciaDoFinal =
messagesContainer.scrollHeight -
messagesContainer.scrollTop -
messagesContainer.clientHeight;
const estaNoFinal = distanciaDoFinal < 150;
// Só fazer scroll se estiver no final ou se for minha própria mensagem
const ultimaMensagem = todasMensagens[todasMensagens.length - 1];
const remetenteIdStr = ultimaMensagem.remetenteId
? String(ultimaMensagem.remetenteId).trim()
: ultimaMensagem.remetente?._id
? String(ultimaMensagem.remetente._id).trim()
: null;
const ehMinhaMensagem = remetenteIdStr && remetenteIdStr === usuarioAtualId;
if (estaNoFinal || ehMinhaMensagem || shouldScrollToBottom) {
// Usar requestAnimationFrame para garantir que o DOM foi atualizado
requestAnimationFrame(() => {
tick().then(() => {
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
});
});
}
}
} else if (shouldScrollToBottom) {
// Se não é nova mensagem mas o usuário estava no final, manter no final
requestAnimationFrame(() => {
tick().then(() => {
if (messagesContainer) {
@@ -210,12 +327,20 @@
lastMessageCount = currentCount;
}
// Cleanup: limpar timeout quando o effect for desmontado
return () => {
if (notificationTimeout) {
clearTimeout(notificationTimeout);
notificationTimeout = null;
}
};
});
// Marcar como lida quando mensagens carregam
$effect(() => {
if (mensagens?.data && mensagens.data.length > 0 && usuarioAtualId) {
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
if (todasMensagens.length > 0 && usuarioAtualId) {
const ultimaMensagem = todasMensagens[todasMensagens.length - 1];
const remetenteIdStr = ultimaMensagem.remetenteId
? String(ultimaMensagem.remetenteId).trim()
: ultimaMensagem.remetente?._id
@@ -302,7 +427,9 @@
function handleScroll(e: Event) {
const target = e.target as HTMLDivElement;
const isAtBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 100;
// Considerar "no final" se estiver a menos de 150px do final
const distanciaDoFinal = target.scrollHeight - target.scrollTop - target.clientHeight;
const isAtBottom = distanciaDoFinal < 150;
shouldScrollToBottom = isAtBottom;
}
@@ -435,6 +562,32 @@
return false;
}
// Escutar evento de scroll para mensagem específica (da busca)
onMount(() => {
const handler = (e: Event) => {
const customEvent = e as CustomEvent<{ mensagemId: Id<'mensagens'> }>;
const mensagemId = customEvent.detail.mensagemId;
// Encontrar elemento da mensagem e fazer scroll
if (messagesContainer) {
const mensagemElement = messagesContainer.querySelector(`[data-mensagem-id="${mensagemId}"]`);
if (mensagemElement) {
mensagemElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Destacar mensagem temporariamente
mensagemElement.classList.add('bg-yellow-200', 'dark:bg-yellow-900');
setTimeout(() => {
mensagemElement.classList.remove('bg-yellow-200', 'dark:bg-yellow-900');
}, 2000);
}
}
};
window.addEventListener('scrollToMessage', handler);
return () => {
window.removeEventListener('scrollToMessage', handler);
};
});
</script>
<div
@@ -442,8 +595,8 @@
bind:this={messagesContainer}
onscroll={handleScroll}
>
{#if mensagens?.data && mensagens.data.length > 0}
{@const gruposPorDia = agruparMensagensPorDia(mensagens.data)}
{#if todasMensagens.length > 0}
{@const gruposPorDia = agruparMensagensPorDia(todasMensagens)}
{#each Object.entries(gruposPorDia) as [dia, mensagensDia]}
<!-- Separador de dia -->
<div class="my-4 flex items-center justify-center">
@@ -512,12 +665,25 @@
cancelarEdicao();
}
}}
aria-label="Editar mensagem"
aria-describedby="edicao-help"
></textarea>
<p id="edicao-help" class="sr-only">Pressione Ctrl+Enter para salvar ou Escape para cancelar</p>
<div class="flex justify-end gap-2">
<button class="btn btn-xs btn-ghost" onclick={cancelarEdicao}>
<button
class="btn btn-xs btn-ghost"
onclick={cancelarEdicao}
aria-label="Cancelar edição"
>
Cancelar
</button>
<button class="btn btn-xs btn-primary" onclick={salvarEdicao}> Salvar </button>
<button
class="btn btn-xs btn-primary"
onclick={salvarEdicao}
aria-label="Salvar edição"
>
Salvar
</button>
</div>
</div>
{:else if mensagem.deletada}
@@ -625,6 +791,7 @@
class="text-base-content/50 hover:text-primary mt-1 text-xs transition-colors"
onclick={() => responderMensagem(mensagem)}
title="Responder"
aria-label="Responder à mensagem de {mensagem.remetente?.nome || 'usuário'}"
>
↪️ Responder
</button>
@@ -663,6 +830,7 @@
class="text-base-content/50 hover:text-primary text-xs transition-colors"
onclick={() => editarMensagem(mensagem)}
title="Editar mensagem"
aria-label="Editar esta mensagem"
>
✏️
</button>
@@ -670,6 +838,7 @@
class="text-base-content/50 hover:text-error text-xs transition-colors"
onclick={() => deletarMensagem(mensagem._id, false)}
title="Deletar mensagem"
aria-label="Deletar esta mensagem"
>
🗑️
</button>
@@ -679,6 +848,7 @@
class="text-base-content/50 hover:text-error text-xs transition-colors"
onclick={() => deletarMensagem(mensagem._id, true)}
title="Deletar mensagem (como administrador)"
aria-label="Deletar esta mensagem como administrador"
>
🗑️ Admin
</button>
@@ -711,7 +881,22 @@
</p>
</div>
{/if}
{:else if !mensagens?.data}
<!-- Indicador de carregamento de mais mensagens -->
{#if carregandoMais}
<div class="my-4 flex items-center justify-center">
<span class="loading loading-spinner loading-sm"></span>
<span class="text-base-content/50 ml-2 text-xs">Carregando mensagens anteriores...</span>
</div>
{/if}
<!-- Indicador de fim das mensagens -->
{#if !hasMore && todasMensagens.length > 0}
<div class="my-4 flex items-center justify-center">
<span class="text-base-content/50 text-xs">Não há mais mensagens</span>
</div>
{/if}
{:else if !mensagensQuery?.data && todasMensagens.length === 0}
<!-- Loading -->
<div class="flex h-full items-center justify-center">
<span class="loading loading-spinner loading-lg"></span>

View File

@@ -82,19 +82,25 @@
});
notificacoesAusencias = notifsAusencias || [];
} catch (queryError: unknown) {
// Silenciar erro se a função não estiver disponível ainda (Convex não sincronizado)
// Silenciar erros de timeout e função não encontrada
const errorMessage =
queryError instanceof Error ? queryError.message : String(queryError);
if (!errorMessage.includes('Could not find public function')) {
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
const isFunctionNotFound = errorMessage.includes('Could not find public function');
if (!isTimeout && !isFunctionNotFound) {
console.error('Erro ao buscar notificações de ausências:', queryError);
}
notificacoesAusencias = [];
}
}
} catch (e) {
// Erro geral - silenciar se for sobre função não encontrada
// Erro geral - silenciar se for sobre função não encontrada ou timeout
const errorMessage = e instanceof Error ? e.message : String(e);
if (!errorMessage.includes('Could not find public function')) {
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
const isFunctionNotFound = errorMessage.includes('Could not find public function');
if (!isTimeout && !isFunctionNotFound) {
console.error('Erro ao buscar notificações de ausências:', e);
}
}

View File

@@ -17,6 +17,47 @@
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
let lastActivity = Date.now();
let lastStatusUpdate = 0;
let pendingStatusUpdate: ReturnType<typeof setTimeout> | null = null;
const STATUS_UPDATE_THROTTLE = 5000; // 5 segundos entre atualizações
// Função auxiliar para atualizar status com throttle e tratamento de erro
async function atualizarStatusPresencaSeguro(status: 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao') {
if (!usuarioAutenticado) return;
const now = Date.now();
// Throttle: só atualizar se passou tempo suficiente desde a última atualização
if (now - lastStatusUpdate < STATUS_UPDATE_THROTTLE) {
// Cancelar atualização pendente se houver
if (pendingStatusUpdate) {
clearTimeout(pendingStatusUpdate);
}
// Agendar atualização para depois do throttle
pendingStatusUpdate = setTimeout(() => {
atualizarStatusPresencaSeguro(status);
}, STATUS_UPDATE_THROTTLE - (now - lastStatusUpdate));
return;
}
// Limpar atualização pendente se houver
if (pendingStatusUpdate) {
clearTimeout(pendingStatusUpdate);
pendingStatusUpdate = null;
}
lastStatusUpdate = now;
try {
await client.mutation(api.chat.atualizarStatusPresenca, { status });
} catch (error) {
// Silenciar erros de timeout - não são críticos para a funcionalidade
const errorMessage = error instanceof Error ? error.message : String(error);
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
if (!isTimeout) {
console.error('Erro ao atualizar status de presença:', error);
}
}
}
// Detectar atividade do usuário
function handleActivity() {
@@ -33,7 +74,7 @@
inactivityTimeout = setTimeout(
() => {
if (usuarioAutenticado) {
client.mutation(api.chat.atualizarStatusPresenca, { status: 'ausente' });
atualizarStatusPresencaSeguro('ausente');
}
},
5 * 60 * 1000
@@ -45,7 +86,7 @@
if (!usuarioAutenticado) return;
// Configurar como online ao montar (apenas se autenticado)
client.mutation(api.chat.atualizarStatusPresenca, { status: 'online' });
atualizarStatusPresencaSeguro('online');
// Heartbeat a cada 30 segundos (apenas se autenticado)
heartbeatInterval = setInterval(() => {
@@ -61,7 +102,7 @@
// Se houve atividade nos últimos 5 minutos, manter online
if (timeSinceLastActivity < 5 * 60 * 1000) {
client.mutation(api.chat.atualizarStatusPresenca, { status: 'online' });
atualizarStatusPresencaSeguro('online');
}
}, 30 * 1000);
@@ -82,10 +123,10 @@
if (document.hidden) {
// Aba ficou inativa
client.mutation(api.chat.atualizarStatusPresenca, { status: 'ausente' });
atualizarStatusPresencaSeguro('ausente');
} else {
// Aba ficou ativa
client.mutation(api.chat.atualizarStatusPresenca, { status: 'online' });
atualizarStatusPresencaSeguro('online');
handleActivity();
}
}
@@ -94,9 +135,15 @@
// Cleanup
return () => {
// Limpar atualização pendente
if (pendingStatusUpdate) {
clearTimeout(pendingStatusUpdate);
pendingStatusUpdate = null;
}
// Marcar como offline ao desmontar (apenas se autenticado)
if (usuarioAutenticado) {
client.mutation(api.chat.atualizarStatusPresenca, { status: 'offline' });
atualizarStatusPresencaSeguro('offline');
}
if (heartbeatInterval) {

View File

@@ -1,13 +1,55 @@
<script lang="ts">
import { User } from 'lucide-svelte';
import { getCachedAvatar } from '$lib/utils/avatarCache';
import { onMount } from 'svelte';
interface Props {
fotoPerfilUrl?: string | null;
nome: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
userId?: string; // ID do usuário para cache
}
let { fotoPerfilUrl, nome, size = 'md' }: Props = $props();
let { fotoPerfilUrl, nome, size = 'md', userId }: Props = $props();
let cachedAvatarUrl = $state<string | null>(null);
let loading = $state(true);
onMount(async () => {
if (fotoPerfilUrl) {
loading = true;
try {
cachedAvatarUrl = await getCachedAvatar(fotoPerfilUrl, userId);
} catch (error) {
console.warn('Erro ao carregar avatar:', error);
cachedAvatarUrl = null;
} finally {
loading = false;
}
} else {
loading = false;
}
});
// Atualizar quando fotoPerfilUrl mudar
$effect(() => {
if (fotoPerfilUrl) {
loading = true;
getCachedAvatar(fotoPerfilUrl, userId)
.then((url) => {
cachedAvatarUrl = url;
loading = false;
})
.catch((error) => {
console.warn('Erro ao carregar avatar:', error);
cachedAvatarUrl = null;
loading = false;
});
} else {
cachedAvatarUrl = null;
loading = false;
}
});
const sizeClasses = {
xs: 'w-8 h-8',
@@ -30,11 +72,25 @@
<div
class={`${sizeClasses[size]} bg-base-200 text-base-content/50 flex items-center justify-center overflow-hidden rounded-full`}
>
{#if fotoPerfilUrl}
{#if loading}
<span class="loading loading-spinner loading-xs"></span>
{:else if cachedAvatarUrl}
<img
src={cachedAvatarUrl}
alt={`Foto de perfil de ${nome}`}
class="h-full w-full object-cover"
loading="lazy"
onerror={() => {
cachedAvatarUrl = null;
}}
/>
{:else if fotoPerfilUrl}
<!-- Fallback: usar URL original se cache falhar -->
<img
src={fotoPerfilUrl}
alt={`Foto de perfil de ${nome}`}
class="h-full w-full object-cover"
loading="lazy"
/>
{:else}
<User size={iconSizes[size]} />

View File

@@ -308,7 +308,7 @@
async function measureLatency(): Promise<number> {
const start = performance.now();
try {
await fetch(window.location.origin + '/favicon.ico', {
await fetch(window.location.origin + '/favicon.png', {
method: 'HEAD',
cache: 'no-cache'
});

View File

@@ -11,6 +11,14 @@ export const chatMinimizado = writable<boolean>(false);
// Store para o contador de notificações
export const notificacoesCount = writable<number>(0);
// Store para coordenar notificações entre componentes (evitar duplicação)
// Quando uma notificação é mostrada em um componente, os outros devem ignorar
export const notificacaoAtiva = writable<{
conversaId: string;
mensagemId: string;
componente: 'widget' | 'messageList';
} | null>(null);
// Funções auxiliares
export function abrirChat() {
chatAberto.set(true);

View File

@@ -0,0 +1,142 @@
/**
* Cache de avatares para reduzir requisições repetidas
* Usa Cache API do navegador e cache em memória
*/
interface AvatarCacheEntry {
url: string;
timestamp: number;
}
// Cache em memória (útil durante a sessão)
const memoryCache = new Map<string, AvatarCacheEntry>();
// Nome do cache no Cache API
const CACHE_NAME = 'sgse-avatars-v1';
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 dias
/**
* Obtém avatar do cache ou faz requisição
*/
export async function getCachedAvatar(
avatarUrl: string | null | undefined,
userId?: string
): Promise<string | null> {
if (!avatarUrl) return null;
// Usar userId como chave se disponível, senão usar a URL
const cacheKey = userId || avatarUrl;
// Verificar cache em memória primeiro
const memoryEntry = memoryCache.get(cacheKey);
if (memoryEntry && Date.now() - memoryEntry.timestamp < CACHE_DURATION) {
return memoryEntry.url;
}
// Verificar Cache API do navegador
if (typeof window !== 'undefined' && 'caches' in window) {
try {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(avatarUrl);
if (cachedResponse) {
const blob = await cachedResponse.blob();
const url = URL.createObjectURL(blob);
// Atualizar cache em memória
memoryCache.set(cacheKey, {
url,
timestamp: Date.now()
});
return url;
}
} catch (error) {
console.warn('Erro ao acessar cache de avatares:', error);
}
}
// Se não está no cache, fazer requisição e armazenar
try {
const response = await fetch(avatarUrl);
if (!response.ok) return null;
const blob = await response.blob();
const url = URL.createObjectURL(blob);
// Armazenar no cache em memória
memoryCache.set(cacheKey, {
url,
timestamp: Date.now()
});
// Armazenar no Cache API
if (typeof window !== 'undefined' && 'caches' in window) {
try {
const cache = await caches.open(CACHE_NAME);
await cache.put(avatarUrl, new Response(blob));
} catch (error) {
console.warn('Erro ao armazenar avatar no cache:', error);
}
}
return url;
} catch (error) {
console.warn('Erro ao carregar avatar:', error);
return null;
}
}
/**
* Limpa o cache de avatares
*/
export async function clearAvatarCache(): Promise<void> {
memoryCache.clear();
if (typeof window !== 'undefined' && 'caches' in window) {
try {
await caches.delete(CACHE_NAME);
} catch (error) {
console.warn('Erro ao limpar cache de avatares:', error);
}
}
}
/**
* Limpa avatares antigos do cache (mais de 7 dias)
*/
export async function cleanOldAvatars(): Promise<void> {
const now = Date.now();
// Limpar cache em memória
for (const [key, entry] of memoryCache.entries()) {
if (now - entry.timestamp > CACHE_DURATION) {
URL.revokeObjectURL(entry.url);
memoryCache.delete(key);
}
}
// Limpar Cache API (manter apenas os últimos 100)
if (typeof window !== 'undefined' && 'caches' in window) {
try {
const cache = await caches.open(CACHE_NAME);
const keys = await cache.keys();
// Se há mais de 100 avatares, remover os mais antigos
if (keys.length > 100) {
const toDelete = keys.slice(0, keys.length - 100);
await Promise.all(toDelete.map((key) => cache.delete(key)));
}
} catch (error) {
console.warn('Erro ao limpar cache antigo:', error);
}
}
}
// Limpar cache antigo periodicamente (a cada hora)
if (typeof window !== 'undefined') {
setInterval(() => {
cleanOldAvatars();
}, 60 * 60 * 1000);
}

View File

@@ -434,3 +434,5 @@ export function adicionarRodape(doc: jsPDF): void {
}

View File

@@ -77,7 +77,7 @@ async function measureNetworkLatency(): Promise<number> {
const start = performance.now();
// Fazer uma requisição pequena para medir latência
await fetch(window.location.origin + '/favicon.ico', {
await fetch(window.location.origin + '/favicon.png', {
method: 'HEAD',
cache: 'no-cache'
});

View File

@@ -97,6 +97,11 @@ export async function gerarPDFComSelecao(
yPosition = adicionarDadosFuncionario(doc, yPosition, funcionario, dataInicio, dataFim);
}
// SEÇÃO: TABELA PRINCIPAL DE REGISTROS (PRIMEIRO)
if (sections.registrosPonto) {
yPosition = gerarTabelaRegistrosPDF(doc, yPosition, dias, configPonto, sections);
}
// Resumo do Período
yPosition = adicionarResumoPeriodo(doc, yPosition, resumo, formatarHoras, formatarMinutos);
@@ -106,11 +111,6 @@ export async function gerarPDFComSelecao(
// Legenda
yPosition = adicionarLegenda(doc, yPosition);
// SEÇÃO: TABELA PRINCIPAL DE REGISTROS
if (sections.registrosPonto) {
yPosition = gerarTabelaRegistrosPDF(doc, yPosition, dias, configPonto, sections);
}
// SEÇÃO: BANCO DE HORAS
if (sections.bancoHoras) {
yPosition = await gerarSecaoBancoHorasPDF(

View File

@@ -73,3 +73,5 @@

View File

@@ -132,6 +132,12 @@
const funcionarios = $derived(funcionariosQuery?.data || []);
const registros = $derived(registrosQuery?.data || []);
const estatisticas = $derived(estatisticasQuery?.data);
// Obter nome do funcionário selecionado
const funcionarioSelecionadoNome = $derived.by(() => {
if (!funcionarioIdFiltro) return null;
return funcionarios.find((f) => f._id === funcionarioIdFiltro)?.nome || null;
});
const config = $derived(configQuery?.data);
// Debug: Log dos dados recebidos
@@ -585,12 +591,7 @@
// Funções de formatação importadas de $lib/utils/ponto/formatacao
// Usar função centralizada formatarDataDDMMAAAA da lib/utils/ponto.ts
// Obter nome do funcionário selecionado
const funcionarioSelecionadoNome = $derived.by(() => {
if (!funcionarioIdFiltro) return null;
return funcionarios.find((f) => f._id === funcionarioIdFiltro)?.nome || null;
});
// funcionarioSelecionadoNome movido para cima, logo após a definição de funcionarios
// Funções de cálculo importadas de $lib/utils/ponto/calculos
// calcularSaldosParciais, calcularSaldoDiario, calcularSaldosPorPar, calcularSaldoComparativoPorPar

View File

@@ -73,3 +73,5 @@