refactor: enhance chat components with type safety and response functionality

- Updated type definitions in ChatWindow and MessageList components for better type safety.
- Improved MessageInput to handle message responses, including a preview feature for replying to messages.
- Enhanced the chat message handling logic to support message references and improve user interaction.
- Refactored notification utility functions to support push notifications and rate limiting for email sending.
- Updated backend schema to accommodate new features related to message responses and notifications.
This commit is contained in:
2025-11-04 20:36:01 -03:00
parent 15374276d5
commit 12db52a8a7
23 changed files with 3195 additions and 503 deletions

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { onMount } from "svelte";
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { authStore } from "$lib/stores/auth.svelte";
import {
registrarServiceWorker,
solicitarPushSubscription,
subscriptionToJSON,
removerPushSubscription,
} from "$lib/utils/notifications";
const client = useConvexClient();
onMount(async () => {
// Aguardar usuário estar autenticado
const checkAuth = setInterval(async () => {
if (authStore.usuario) {
clearInterval(checkAuth);
await registrarPushSubscription();
}
}, 500);
// Limpar intervalo após 30 segundos (timeout)
setTimeout(() => {
clearInterval(checkAuth);
}, 30000);
return () => {
clearInterval(checkAuth);
};
});
async function registrarPushSubscription() {
try {
// Solicitar subscription
const subscription = await solicitarPushSubscription();
if (!subscription) {
console.log(" Push subscription não disponível ou permissão negada");
return;
}
// Converter para formato serializável
const subscriptionData = subscriptionToJSON(subscription);
// Registrar no backend
const resultado = await client.mutation(api.pushNotifications.registrarPushSubscription, {
endpoint: subscriptionData.endpoint,
keys: subscriptionData.keys,
userAgent: navigator.userAgent,
});
if (resultado.sucesso) {
console.log("✅ Push subscription registrada com sucesso");
} else {
console.error("❌ Erro ao registrar push subscription:", resultado.erro);
}
} catch (error) {
console.error("❌ Erro ao configurar push notifications:", error);
}
}
// Remover subscription ao fazer logout
$effect(() => {
if (!authStore.usuario) {
removerPushSubscription().then(() => {
console.log("Push subscription removida");
});
}
});
</script>
<!-- Componente invisível - apenas lógica -->

View File

@@ -28,7 +28,7 @@
return null;
}
const encontrada = conversas.data.find((c: any) => c._id === conversaId);
const encontrada = conversas.data.find((c: { _id: string }) => c._id === conversaId);
console.log("✅ [ChatWindow] Conversa encontrada:", encontrada);
return encontrada;
});
@@ -54,10 +54,10 @@
return "👤";
}
function getStatusConversa(): any {
function getStatusConversa(): "online" | "offline" | "ausente" | "externo" | "em_reuniao" | null {
const c = conversa();
if (c && c.tipo === "individual" && c.outroUsuario) {
return c.outroUsuario.statusPresenca || "offline";
return (c.outroUsuario.statusPresenca as "online" | "offline" | "ausente" | "externo" | "em_reuniao") || "offline";
}
return null;
}
@@ -169,20 +169,20 @@
</div>
<!-- Mensagens -->
<div class="flex-1 overflow-hidden">
<MessageList conversaId={conversaId as any} />
<div class="flex-1 overflow-hidden min-h-0">
<MessageList conversaId={conversaId as Id<"conversas">} />
</div>
<!-- Input -->
<div class="border-t border-base-300">
<MessageInput conversaId={conversaId as any} />
<div class="border-t border-base-300 flex-shrink-0">
<MessageInput conversaId={conversaId as Id<"conversas">} />
</div>
</div>
<!-- Modal de Agendamento -->
{#if showScheduleModal}
<ScheduleMessageModal
conversaId={conversaId as any}
conversaId={conversaId as Id<"conversas">}
onClose={() => (showScheduleModal = false)}
/>
{/if}

View File

@@ -18,6 +18,7 @@
let uploadingFile = $state(false);
let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null;
let showEmojiPicker = $state(false);
let mensagemRespondendo: { id: Id<"mensagens">; conteudo: string; remetente: string } | null = $state(null);
// Emojis mais usados
const emojis = [
@@ -62,6 +63,7 @@
conversaId,
conteudo: texto,
tipo: "texto",
respostaPara: mensagemRespondendo?.id,
});
try {
@@ -70,11 +72,13 @@
conversaId,
conteudo: texto,
tipo: "texto",
respostaPara: mensagemRespondendo?.id,
});
console.log("✅ [MessageInput] Mensagem enviada com sucesso! ID:", result);
mensagem = "";
mensagemRespondendo = null;
if (textarea) {
textarea.style.height = "auto";
}
@@ -86,6 +90,34 @@
}
}
function cancelarResposta() {
mensagemRespondendo = null;
}
// Escutar evento de resposta
onMount(() => {
const handler = (e: Event) => {
const customEvent = e as CustomEvent<{ mensagemId: Id<"mensagens"> }>;
// Buscar informações da mensagem para exibir preview
client.query(api.chat.obterMensagens, { conversaId, limit: 100 }).then((mensagens) => {
const msg = mensagens.find((m: any) => m._id === customEvent.detail.mensagemId);
if (msg) {
mensagemRespondendo = {
id: msg._id,
conteudo: msg.conteudo.substring(0, 100),
remetente: msg.remetente?.nome || "Usuário",
};
textarea?.focus();
}
});
};
window.addEventListener("responderMensagem", handler);
return () => {
window.removeEventListener("responderMensagem", handler);
};
});
function handleKeyDown(e: KeyboardEvent) {
// Enter sem Shift = enviar
if (e.key === "Enter" && !e.shiftKey) {
@@ -154,6 +186,24 @@
</script>
<div class="p-4">
<!-- Preview da mensagem respondendo -->
{#if mensagemRespondendo}
<div class="mb-2 p-2 bg-base-200 rounded-lg flex items-center justify-between">
<div class="flex-1">
<p class="text-xs font-medium text-base-content/70">Respondendo a {mensagemRespondendo.remetente}</p>
<p class="text-xs text-base-content/50 truncate">{mensagemRespondendo.conteudo}</p>
</div>
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={cancelarResposta}
title="Cancelar resposta"
>
</button>
</div>
{/if}
<div class="flex items-end gap-2">
<!-- Botão de anexar arquivo MODERNO -->
<label

View File

@@ -5,6 +5,7 @@
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { onMount, tick } from "svelte";
import { authStore } from "$lib/stores/auth.svelte";
interface Props {
conversaId: Id<"conversas">;
@@ -18,33 +19,43 @@
let messagesContainer: HTMLDivElement;
let shouldScrollToBottom = true;
let lastMessageCount = 0;
// DEBUG: Log quando mensagens mudam
$effect(() => {
console.log("💬 [MessageList] Mensagens atualizadas:", {
conversaId,
count: mensagens?.data?.length || 0,
mensagens: mensagens?.data,
});
});
// Obter ID do usuário atual
const usuarioAtualId = $derived(authStore.usuario?._id);
// Auto-scroll para a última mensagem
// Auto-scroll para a última mensagem quando novas mensagens chegam
$effect(() => {
if (mensagens?.data && shouldScrollToBottom && messagesContainer) {
tick().then(() => {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
});
if (mensagens?.data && messagesContainer) {
const currentCount = mensagens.data.length;
const isNewMessage = currentCount > lastMessageCount;
if (isNewMessage || shouldScrollToBottom) {
// Usar requestAnimationFrame para garantir que o DOM foi atualizado
requestAnimationFrame(() => {
tick().then(() => {
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
});
});
}
lastMessageCount = currentCount;
}
});
// Marcar como lida quando mensagens carregam
$effect(() => {
if (mensagens?.data && mensagens.data.length > 0) {
if (mensagens?.data && mensagens.data.length > 0 && usuarioAtualId) {
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
client.mutation(api.chat.marcarComoLida, {
conversaId,
mensagemId: ultimaMensagem._id as any,
});
// Só marcar como lida se não for minha mensagem
if (ultimaMensagem.remetente?._id !== usuarioAtualId) {
client.mutation(api.chat.marcarComoLida, {
conversaId,
mensagemId: ultimaMensagem._id,
});
}
}
});
@@ -64,8 +75,46 @@
}
}
function agruparMensagensPorDia(msgs: any[]): Record<string, any[]> {
const grupos: Record<string, any[]> = {};
interface Mensagem {
_id: Id<"mensagens">;
remetente?: {
_id: Id<"usuarios">;
nome: string;
} | null;
conteudo: string;
tipo: "texto" | "arquivo" | "imagem";
enviadaEm: number;
editadaEm?: number;
deletada?: boolean;
agendadaPara?: number;
respostaPara?: Id<"mensagens">;
mensagemOriginal?: {
_id: Id<"mensagens">;
conteudo: string;
remetente: {
_id: Id<"usuarios">;
nome: string;
} | null;
deletada: boolean;
} | null;
reagiuPor?: Array<{
usuarioId: Id<"usuarios">;
emoji: string;
}>;
arquivoUrl?: string | null;
arquivoNome?: string;
arquivoTamanho?: number;
linkPreview?: {
url: string;
titulo?: string;
descricao?: string;
imagem?: string;
site?: string;
} | null;
}
function agruparMensagensPorDia(msgs: Mensagem[]): Record<string, Mensagem[]> {
const grupos: Record<string, Mensagem[]> = {};
for (const msg of msgs) {
const dia = formatarDiaMensagem(msg.enviadaEm);
if (!grupos[dia]) {
@@ -83,14 +132,14 @@
shouldScrollToBottom = isAtBottom;
}
async function handleReagir(mensagemId: string, emoji: string) {
async function handleReagir(mensagemId: Id<"mensagens">, emoji: string) {
await client.mutation(api.chat.reagirMensagem, {
mensagemId: mensagemId as any,
mensagemId,
emoji,
});
}
function getEmojisReacao(mensagem: any): Array<{ emoji: string; count: number }> {
function getEmojisReacao(mensagem: Mensagem): Array<{ emoji: string; count: number }> {
if (!mensagem.reagiuPor || mensagem.reagiuPor.length === 0) return [];
const emojiMap: Record<string, number> = {};
@@ -100,6 +149,64 @@
return Object.entries(emojiMap).map(([emoji, count]) => ({ emoji, count }));
}
let mensagemEditando: Mensagem | null = $state(null);
let novoConteudoEditado = $state("");
async function editarMensagem(mensagem: Mensagem) {
mensagemEditando = mensagem;
novoConteudoEditado = mensagem.conteudo;
}
async function salvarEdicao() {
if (!mensagemEditando || !novoConteudoEditado.trim()) return;
try {
const resultado = await client.mutation(api.chat.editarMensagem, {
mensagemId: mensagemEditando._id,
novoConteudo: novoConteudoEditado.trim(),
});
if (resultado.sucesso) {
mensagemEditando = null;
novoConteudoEditado = "";
} else {
alert(resultado.erro || "Erro ao editar mensagem");
}
} catch (error) {
console.error("Erro ao editar mensagem:", error);
alert("Erro ao editar mensagem");
}
}
function cancelarEdicao() {
mensagemEditando = null;
novoConteudoEditado = "";
}
async function deletarMensagem(mensagemId: Id<"mensagens">) {
if (!confirm("Tem certeza que deseja deletar esta mensagem?")) {
return;
}
try {
await client.mutation(api.chat.deletarMensagem, {
mensagemId,
});
} catch (error) {
console.error("Erro ao deletar mensagem:", error);
alert("Erro ao deletar mensagem");
}
}
// Função para responder mensagem (será passada via props ou event)
function responderMensagem(mensagem: Mensagem) {
// Disparar evento customizado para o componente pai
const event = new CustomEvent("responderMensagem", {
detail: { mensagemId: mensagem._id },
});
window.dispatchEvent(event);
}
</script>
<div
@@ -119,9 +226,9 @@
<!-- Mensagens do dia -->
{#each mensagensDia as mensagem (mensagem._id)}
{@const isMinha = mensagem.remetente?._id === mensagens.data[0]?.remetente?._id}
<div class={`flex mb-4 ${isMinha ? "justify-end" : "justify-start"}`}>
<div class={`max-w-[75%] ${isMinha ? "items-end" : "items-start"}`}>
{@const isMinha = mensagem.remetente?._id === usuarioAtualId}
<div class={`flex mb-4 w-full ${isMinha ? "justify-end" : "justify-start"}`}>
<div class={`flex flex-col max-w-[75%] ${isMinha ? "items-end" : "items-start"}`}>
<!-- Nome do remetente (apenas se não for minha) -->
{#if !isMinha}
<p class="text-xs text-base-content/60 mb-1 px-3">
@@ -137,10 +244,92 @@
: "bg-base-200 text-base-content rounded-bl-sm"
}`}
>
{#if mensagem.deletada}
{#if mensagem.mensagemOriginal}
<!-- Preview da mensagem respondida -->
<div class="mb-2 pl-3 border-l-2 border-base-content/20 opacity-70">
<p class="text-xs font-medium">
{mensagem.mensagemOriginal.remetente?.nome || "Usuário"}
</p>
<p class="text-xs truncate">
{mensagem.mensagemOriginal.deletada
? "Mensagem deletada"
: mensagem.mensagemOriginal.conteudo}
</p>
</div>
{/if}
{#if mensagemEditando?._id === mensagem._id}
<!-- Modo de edição -->
<div class="space-y-2">
<textarea
bind:value={novoConteudoEditado}
class="w-full p-2 rounded-lg bg-base-100 text-base-content text-sm resize-none"
rows="3"
onkeydown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
salvarEdicao();
} else if (e.key === "Escape") {
cancelarEdicao();
}
}}
></textarea>
<div class="flex gap-2 justify-end">
<button
class="btn btn-xs btn-ghost"
onclick={cancelarEdicao}
>
Cancelar
</button>
<button
class="btn btn-xs btn-primary"
onclick={salvarEdicao}
>
Salvar
</button>
</div>
</div>
{:else if mensagem.deletada}
<p class="text-sm italic opacity-70">Mensagem deletada</p>
{:else if mensagem.tipo === "texto"}
<p class="text-sm whitespace-pre-wrap break-words">{mensagem.conteudo}</p>
<div class="space-y-2">
<div class="flex items-start gap-2">
<p class="text-sm whitespace-pre-wrap break-words flex-1">{mensagem.conteudo}</p>
{#if mensagem.editadaEm}
<span class="text-xs opacity-50 italic" title="Editado">(editado)</span>
{/if}
</div>
<!-- Preview de link -->
{#if mensagem.linkPreview}
<a
href={mensagem.linkPreview.url}
target="_blank"
rel="noopener noreferrer"
class="block border border-base-300 rounded-lg overflow-hidden hover:border-primary transition-colors"
>
{#if mensagem.linkPreview.imagem}
<img
src={mensagem.linkPreview.imagem}
alt={mensagem.linkPreview.titulo || "Preview"}
class="w-full h-48 object-cover"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
{/if}
<div class="p-3 bg-base-200">
{#if mensagem.linkPreview.site}
<p class="text-xs text-base-content/50 mb-1">{mensagem.linkPreview.site}</p>
{/if}
{#if mensagem.linkPreview.titulo}
<p class="text-sm font-medium text-base-content mb-1">{mensagem.linkPreview.titulo}</p>
{/if}
{#if mensagem.linkPreview.descricao}
<p class="text-xs text-base-content/70 line-clamp-2">{mensagem.linkPreview.descricao}</p>
{/if}
</div>
</a>
{/if}
</div>
{:else if mensagem.tipo === "imagem"}
<div class="mb-2">
<img
@@ -198,14 +387,45 @@
{/each}
</div>
{/if}
<!-- Botão de responder -->
{#if !mensagem.deletada}
<button
class="text-xs text-base-content/50 hover:text-primary transition-colors mt-1"
onclick={() => responderMensagem(mensagem)}
title="Responder"
>
↪️ Responder
</button>
{/if}
</div>
<!-- Timestamp -->
<p
class={`text-xs text-base-content/50 mt-1 px-3 ${isMinha ? "text-right" : "text-left"}`}
>
<!-- Timestamp e ações -->
<div
class={`flex items-center gap-2 mt-1 px-3 ${isMinha ? "justify-end" : "justify-start"}`}
>
<p class="text-xs text-base-content/50">
{formatarDataMensagem(mensagem.enviadaEm)}
</p>
{#if isMinha && !mensagem.deletada && !mensagem.agendadaPara}
<div class="flex gap-1">
<button
class="text-xs text-base-content/50 hover:text-primary transition-colors"
onclick={() => editarMensagem(mensagem)}
title="Editar mensagem"
>
✏️
</button>
<button
class="text-xs text-base-content/50 hover:text-error transition-colors"
onclick={() => deletarMensagem(mensagem._id)}
title="Deletar mensagem"
>
🗑️
</button>
</div>
{/if}
</div>
</div>
</div>
{/each}
@@ -226,7 +446,7 @@
></div>
</div>
<p class="text-xs text-base-content/60">
{digitando.data.map((u: any) => u.nome).join(", ")} {digitando.data.length === 1
{digitando.data.map((u: { nome: string }) => u.nome).join(", ")} {digitando.data.length === 1
? "está digitando"
: "estão digitando"}...
</p>