- Implemented error handling for unhandled promise rejections related to message channels, improving stability during push notification operations. - Updated the PushNotificationManager component to manage push subscription registration with timeouts, preventing application hangs. - Enhanced the sidebar and chat components to display user avatars, improving user experience and visual consistency. - Refactored email processing logic to support scheduled email sending, integrating new backend functionalities for better email management. - Improved overall error handling and logging across components to reduce console spam and enhance debugging capabilities.
513 lines
18 KiB
Svelte
513 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { useQuery, useConvexClient } from "convex-svelte";
|
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
|
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
|
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">;
|
|
}
|
|
|
|
let { conversaId }: Props = $props();
|
|
|
|
const client = useConvexClient();
|
|
const mensagens = useQuery(api.chat.obterMensagens, { conversaId, limit: 50 });
|
|
const digitando = useQuery(api.chat.obterDigitando, { conversaId });
|
|
|
|
let messagesContainer: HTMLDivElement;
|
|
let shouldScrollToBottom = true;
|
|
let lastMessageCount = 0;
|
|
|
|
// Obter ID do usuário atual - usar $state para garantir reatividade
|
|
let usuarioAtualId = $state<string | null>(null);
|
|
|
|
// Atualizar usuarioAtualId sempre que authStore.usuario mudar
|
|
$effect(() => {
|
|
const usuario = authStore.usuario;
|
|
if (usuario?._id) {
|
|
const idStr = String(usuario._id).trim();
|
|
usuarioAtualId = idStr || null;
|
|
} else {
|
|
usuarioAtualId = null;
|
|
}
|
|
});
|
|
|
|
// Auto-scroll para a última mensagem quando novas mensagens chegam
|
|
$effect(() => {
|
|
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 && usuarioAtualId) {
|
|
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
|
|
const remetenteIdStr = ultimaMensagem.remetenteId
|
|
? String(ultimaMensagem.remetenteId).trim()
|
|
: (ultimaMensagem.remetente?._id ? String(ultimaMensagem.remetente._id).trim() : null);
|
|
// Só marcar como lida se não for minha mensagem
|
|
if (remetenteIdStr && remetenteIdStr !== usuarioAtualId) {
|
|
client.mutation(api.chat.marcarComoLida, {
|
|
conversaId,
|
|
mensagemId: ultimaMensagem._id,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
function formatarDataMensagem(timestamp: number): string {
|
|
try {
|
|
return format(new Date(timestamp), "HH:mm", { locale: ptBR });
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function formatarDiaMensagem(timestamp: number): string {
|
|
try {
|
|
return format(new Date(timestamp), "dd/MM/yyyy", { locale: ptBR });
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
interface Mensagem {
|
|
_id: Id<"mensagens">;
|
|
remetenteId: Id<"usuarios">;
|
|
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]) {
|
|
grupos[dia] = [];
|
|
}
|
|
grupos[dia].push(msg);
|
|
}
|
|
return grupos;
|
|
}
|
|
|
|
function handleScroll(e: Event) {
|
|
const target = e.target as HTMLDivElement;
|
|
const isAtBottom =
|
|
target.scrollHeight - target.scrollTop - target.clientHeight < 100;
|
|
shouldScrollToBottom = isAtBottom;
|
|
}
|
|
|
|
async function handleReagir(mensagemId: Id<"mensagens">, emoji: string) {
|
|
await client.mutation(api.chat.reagirMensagem, {
|
|
mensagemId,
|
|
emoji,
|
|
});
|
|
}
|
|
|
|
function getEmojisReacao(mensagem: Mensagem): Array<{ emoji: string; count: number }> {
|
|
if (!mensagem.reagiuPor || mensagem.reagiuPor.length === 0) return [];
|
|
|
|
const emojiMap: Record<string, number> = {};
|
|
for (const reacao of mensagem.reagiuPor) {
|
|
emojiMap[reacao.emoji] = (emojiMap[reacao.emoji] || 0) + 1;
|
|
}
|
|
|
|
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
|
|
class="h-full overflow-y-auto px-4 py-4 bg-base-100"
|
|
bind:this={messagesContainer}
|
|
onscroll={handleScroll}
|
|
>
|
|
{#if mensagens?.data && mensagens.data.length > 0}
|
|
{@const gruposPorDia = agruparMensagensPorDia(mensagens.data)}
|
|
{#each Object.entries(gruposPorDia) as [dia, mensagensDia]}
|
|
<!-- Separador de dia -->
|
|
<div class="flex items-center justify-center my-4">
|
|
<div class="px-3 py-1 rounded-full bg-base-300 text-base-content/70 text-xs">
|
|
{dia}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mensagens do dia -->
|
|
{#each mensagensDia as mensagem (mensagem._id)}
|
|
{@const remetenteIdStr = (() => {
|
|
// Priorizar remetenteId direto da mensagem
|
|
if (mensagem.remetenteId) {
|
|
return String(mensagem.remetenteId).trim();
|
|
}
|
|
// Fallback para remetente._id
|
|
if (mensagem.remetente?._id) {
|
|
return String(mensagem.remetente._id).trim();
|
|
}
|
|
return null;
|
|
})()}
|
|
{@const isMinha = usuarioAtualId && remetenteIdStr && remetenteIdStr === 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 (sempre exibido, mas discreto para mensagens próprias) -->
|
|
{#if isMinha}
|
|
<p class="text-xs text-base-content/40 mb-1 px-3">
|
|
Você
|
|
</p>
|
|
{:else}
|
|
<p class="text-xs text-base-content/60 mb-1 px-3">
|
|
{mensagem.remetente?.nome || "Usuário"}
|
|
</p>
|
|
{/if}
|
|
|
|
<!-- Balão da mensagem -->
|
|
<div
|
|
class={`rounded-2xl px-4 py-2 ${
|
|
isMinha
|
|
? "bg-blue-200 text-gray-900 rounded-br-sm"
|
|
: "bg-base-200 text-base-content rounded-bl-sm"
|
|
}`}
|
|
>
|
|
{#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"}
|
|
<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
|
|
src={mensagem.arquivoUrl}
|
|
alt={mensagem.arquivoNome}
|
|
class="max-w-full rounded-lg"
|
|
/>
|
|
</div>
|
|
{#if mensagem.conteudo}
|
|
<p class="text-sm whitespace-pre-wrap break-words">{mensagem.conteudo}</p>
|
|
{/if}
|
|
{:else if mensagem.tipo === "arquivo"}
|
|
<a
|
|
href={mensagem.arquivoUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="flex items-center gap-2 hover:opacity-80"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="1.5"
|
|
stroke="currentColor"
|
|
class="w-5 h-5"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
|
/>
|
|
</svg>
|
|
<div class="text-sm">
|
|
<p class="font-medium">{mensagem.arquivoNome}</p>
|
|
{#if mensagem.arquivoTamanho}
|
|
<p class="text-xs opacity-70">
|
|
{(mensagem.arquivoTamanho / 1024 / 1024).toFixed(2)} MB
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</a>
|
|
{/if}
|
|
|
|
<!-- Reações -->
|
|
{#if !mensagem.deletada && getEmojisReacao(mensagem).length > 0}
|
|
<div class="flex items-center gap-1 mt-2">
|
|
{#each getEmojisReacao(mensagem) as reacao}
|
|
<button
|
|
type="button"
|
|
class="text-xs px-2 py-0.5 rounded-full bg-base-300/50 hover:bg-base-300"
|
|
onclick={() => handleReagir(mensagem._id, reacao.emoji)}
|
|
>
|
|
{reacao.emoji} {reacao.count}
|
|
</button>
|
|
{/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 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}
|
|
{/each}
|
|
|
|
<!-- Indicador de digitação -->
|
|
{#if digitando?.data && digitando.data.length > 0}
|
|
<div class="flex items-center gap-2 mb-4">
|
|
<div class="flex items-center gap-1">
|
|
<div class="w-2 h-2 rounded-full bg-base-content/50 animate-bounce"></div>
|
|
<div
|
|
class="w-2 h-2 rounded-full bg-base-content/50 animate-bounce"
|
|
style="animation-delay: 0.1s;"
|
|
></div>
|
|
<div
|
|
class="w-2 h-2 rounded-full bg-base-content/50 animate-bounce"
|
|
style="animation-delay: 0.2s;"
|
|
></div>
|
|
</div>
|
|
<p class="text-xs text-base-content/60">
|
|
{digitando.data.map((u: { nome: string }) => u.nome).join(", ")} {digitando.data.length === 1
|
|
? "está digitando"
|
|
: "estão digitando"}...
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
{:else if !mensagens?.data}
|
|
<!-- Loading -->
|
|
<div class="flex items-center justify-center h-full">
|
|
<span class="loading loading-spinner loading-lg"></span>
|
|
</div>
|
|
{:else}
|
|
<!-- Vazio -->
|
|
<div class="flex flex-col items-center justify-center h-full text-center">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="1.5"
|
|
stroke="currentColor"
|
|
class="w-16 h-16 text-base-content/30 mb-4"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
|
|
/>
|
|
</svg>
|
|
<p class="text-base-content/70">Nenhuma mensagem ainda</p>
|
|
<p class="text-sm text-base-content/50 mt-1">Envie a primeira mensagem!</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|