>([]);
+ 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);
+ };
+ });
- {#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]}
@@ -512,12 +665,25 @@
cancelarEdicao();
}
}}
+ aria-label="Editar mensagem"
+ aria-describedby="edicao-help"
>
+
Pressione Ctrl+Enter para salvar ou Escape para cancelar
-
{: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
@@ -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"
>
✏️
@@ -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"
>
🗑️
@@ -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
@@ -711,7 +881,22 @@
{/if}
- {:else if !mensagens?.data}
+
+
+ {#if carregandoMais}
+
+
+ Carregando mensagens anteriores...
+
+ {/if}
+
+
+ {#if !hasMore && todasMensagens.length > 0}
+
+ Não há mais mensagens
+
+ {/if}
+ {:else if !mensagensQuery?.data && todasMensagens.length === 0}
diff --git a/apps/web/src/lib/components/chat/NotificationBell.svelte b/apps/web/src/lib/components/chat/NotificationBell.svelte
index fdbb8ec..946bc30 100644
--- a/apps/web/src/lib/components/chat/NotificationBell.svelte
+++ b/apps/web/src/lib/components/chat/NotificationBell.svelte
@@ -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);
}
}
diff --git a/apps/web/src/lib/components/chat/PresenceManager.svelte b/apps/web/src/lib/components/chat/PresenceManager.svelte
index 0227d5d..0c8e1ff 100644
--- a/apps/web/src/lib/components/chat/PresenceManager.svelte
+++ b/apps/web/src/lib/components/chat/PresenceManager.svelte
@@ -17,6 +17,47 @@
let heartbeatInterval: ReturnType | null = null;
let inactivityTimeout: ReturnType | null = null;
let lastActivity = Date.now();
+ let lastStatusUpdate = 0;
+ let pendingStatusUpdate: ReturnType | 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) {
diff --git a/apps/web/src/lib/components/chat/UserAvatar.svelte b/apps/web/src/lib/components/chat/UserAvatar.svelte
index 1774bdc..0972e06 100644
--- a/apps/web/src/lib/components/chat/UserAvatar.svelte
+++ b/apps/web/src/lib/components/chat/UserAvatar.svelte
@@ -1,13 +1,55 @@