diff --git a/apps/web/src/lib/components/chat/ChatList.svelte b/apps/web/src/lib/components/chat/ChatList.svelte index 370a325..8735892 100644 --- a/apps/web/src/lib/components/chat/ChatList.svelte +++ b/apps/web/src/lib/components/chat/ChatList.svelte @@ -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" /> + Digite para buscar usuários por nome, email ou matrícula handleClickUsuario(usuario)} disabled={processando} + aria-label="Abrir conversa com {usuario.nome}" + aria-describedby="usuario-status-{usuario._id}" >
@@ -290,6 +296,9 @@ {usuario.statusMensagem || usuario.email}

+ + Status: {getStatusLabel(usuario.statusPresenca)} +
{/each} diff --git a/apps/web/src/lib/components/chat/ChatWidget.svelte b/apps/web/src/lib/components/chat/ChatWidget.svelte index 1a68c18..25bb0cf 100644 --- a/apps/web/src/lib/components/chat/ChatWidget.svelte +++ b/apps/web/src/lib/components/chat/ChatWidget.svelte @@ -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(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)" >
{ + // Propagar o clique para o elemento pai + e.stopPropagation(); + if (!isDoubleClicking && !shouldPreventClick && !hasMoved && !isTouching) { + handleToggle(); + } + }} + ondblclick={(e) => { + e.stopPropagation(); + if (!shouldPreventClick && !hasMoved && !isTouching) { + handleDoubleClick(); + } + }} >
{#if isDragging || isTouching} @@ -960,8 +1052,8 @@ {/if} - @@ -1057,7 +1149,7 @@ {#if avatarUrlDoUsuario()} {currentUser?.data?.nome {:else} @@ -1223,6 +1315,9 @@ {/if} + + + {#if showGlobalNotificationPopup && globalNotificationMessage} {@const notificationMsg = globalNotificationMessage} diff --git a/apps/web/src/lib/components/chat/ChatWindow.svelte b/apps/web/src/lib/components/chat/ChatWindow.svelte index 9fb05f3..1fad7cf 100644 --- a/apps/web/src/lib/components/chat/ChatWindow.svelte +++ b/apps/web/src/lib/components/chat/ChatWindow.svelte @@ -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 | null>(null); + let showSearch = $state(false); + let searchQuery = $state(''); + let searchResults = $state>([]); + let searching = $state(false); + let selectedSearchResult = $state(-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}
@@ -361,6 +368,32 @@
+ + + {#if !chamadaAtual && !chamadaAtiva}
+ + {#if showSearch} +
e.stopPropagation()} + > + + + +
+ + + {#if searchQuery.trim().length >= 2} +
+ {#if searching} +
+ + Buscando... +
+ {:else if searchResults.length > 0} +

+ {searchResults.length} resultado{searchResults.length !== 1 ? 's' : ''} encontrado{searchResults.length !== 1 + ? 's' + : ''} +

+ {#each searchResults as resultado, index (resultado._id)} + + {/each} + {:else if searchQuery.trim().length >= 2} +
+

Nenhuma mensagem encontrada

+
+ {/if} +
+ {/if} + {/if} +
} /> diff --git a/apps/web/src/lib/components/chat/ConnectionIndicator.svelte b/apps/web/src/lib/components/chat/ConnectionIndicator.svelte new file mode 100644 index 0000000..8ca81f3 --- /dev/null +++ b/apps/web/src/lib/components/chat/ConnectionIndicator.svelte @@ -0,0 +1,90 @@ + + +{#if showIndicator} +
+ {#if !isOnline} + + Sem conexão + {:else if !convexConnected} + + Reconectando... + {:else} + + Conectado + {/if} +
+{/if} + diff --git a/apps/web/src/lib/components/chat/MessageInput.svelte b/apps/web/src/lib/components/chat/MessageInput.svelte index ceb7244..b6562f3 100644 --- a/apps/web/src/lib/components/chat/MessageInput.svelte +++ b/apps/web/src/lib/components/chat/MessageInput.svelte @@ -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 | 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((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 @@
- +
@@ -418,6 +582,8 @@ onclick={() => (showEmojiPicker = !showEmojiPicker)} disabled={enviando || uploadingFile} aria-label="Adicionar emoji" + aria-expanded={showEmojiPicker} + aria-haspopup="true" title="Adicionar emoji" >