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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'>} />
|
||||
|
||||
90
apps/web/src/lib/components/chat/ConnectionIndicator.svelte
Normal file
90
apps/web/src/lib/components/chat/ConnectionIndicator.svelte
Normal 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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]} />
|
||||
|
||||
Reference in New Issue
Block a user