Files
sgse-app/apps/web/src/lib/components/chat/MessageList.svelte
deyvisonwanderley 6166043735 feat: enhance ErrorModal and chat components with new features and improvements
- Refactored ErrorModal to utilize a dialog element for better accessibility and user experience, including a close button with an icon.
- Updated chat components to improve participant display and message read status, enhancing user engagement and clarity.
- Introduced loading indicators for user and conversation data in SalaReuniaoManager to improve responsiveness during data fetching.
- Enhanced message handling in MessageList to indicate whether messages have been read, providing users with better feedback on message status.
- Improved overall structure and styling across various components for consistency and maintainability.
2025-11-05 14:05:52 -03:00

814 lines
30 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 });
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId });
const conversas = useQuery(api.chat.listarConversas, {});
let messagesContainer: HTMLDivElement;
let shouldScrollToBottom = true;
let lastMessageCount = 0;
let mensagensNotificadas = $state<Set<string>>(new Set());
let showNotificationPopup = $state(false);
let notificationMessage = $state<{ remetente: string; conteudo: string } | null>(null);
let notificationTimeout: ReturnType<typeof setTimeout> | null = null;
let mensagensCarregadas = $state(false);
// Obter ID do usuário atual - usar $state para garantir reatividade
let usuarioAtualId = $state<string | null>(null);
// Carregar mensagens já notificadas do localStorage ao montar
$effect(() => {
if (typeof window !== 'undefined' && !mensagensCarregadas) {
const saved = localStorage.getItem('chat-mensagens-notificadas');
if (saved) {
try {
const ids = JSON.parse(saved) as string[];
mensagensNotificadas = new Set(ids);
} catch {
mensagensNotificadas = new Set();
}
}
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) => {
mensagensNotificadas.add(String(msg._id));
});
salvarMensagensNotificadas();
}
}
});
// Salvar mensagens notificadas no localStorage
function salvarMensagensNotificadas() {
if (typeof window !== 'undefined') {
const ids = Array.from(mensagensNotificadas);
// Limitar a 1000 IDs para não encher o localStorage
const idsLimitados = ids.slice(-1000);
localStorage.setItem('chat-mensagens-notificadas', JSON.stringify(idsLimitados));
}
}
// 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;
}
});
// Função para tocar som de notificação
function tocarSomNotificacao() {
try {
// Usar AudioContext (requer interação do usuário para iniciar)
const AudioContextClass = window.AudioContext || (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextClass) return;
let audioContext: AudioContext | null = null;
try {
audioContext = new AudioContext();
} catch (e) {
// Se falhar, tentar resumir contexto existente
console.warn("Não foi possível criar AudioContext:", e);
return;
}
// Resumir contexto se estiver suspenso (necessário após interação do usuário)
if (audioContext.state === 'suspended') {
audioContext.resume().then(() => {
const oscillator = audioContext!.createOscillator();
const gainNode = audioContext!.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext!.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.2, audioContext!.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext!.currentTime + 0.3);
oscillator.start(audioContext!.currentTime);
oscillator.stop(audioContext!.currentTime + 0.3);
}).catch(() => {
// Ignorar erro se não conseguir resumir
});
} else {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.2, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.3);
}
} catch (error) {
console.error("Erro ao tocar som de notificação:", error);
}
}
// 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;
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];
const mensagemId = String(ultimaMensagem._id);
const remetenteIdStr = ultimaMensagem.remetenteId
? String(ultimaMensagem.remetenteId).trim()
: (ultimaMensagem.remetente?._id ? String(ultimaMensagem.remetente._id).trim() : null);
// Se é uma nova mensagem de outro usuário (não minha) E ainda não foi notificada
if (remetenteIdStr && remetenteIdStr !== usuarioAtualId && !mensagensNotificadas.has(mensagemId)) {
// Marcar como notificada antes de tocar som (evita duplicação)
mensagensNotificadas.add(mensagemId);
salvarMensagensNotificadas();
// Tocar som de notificação (apenas uma vez)
tocarSomNotificacao();
// Mostrar popup de notificação
notificationMessage = {
remetente: ultimaMensagem.remetente?.nome || "Usuário",
conteudo: ultimaMensagem.conteudo.substring(0, 100) + (ultimaMensagem.conteudo.length > 100 ? "..." : "")
};
showNotificationPopup = true;
// Ocultar popup após 5 segundos
if (notificationTimeout) {
clearTimeout(notificationTimeout);
}
notificationTimeout = setTimeout(() => {
showNotificationPopup = false;
notificationMessage = null;
}, 5000);
}
}
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;
minutosPara?: 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;
lidaPor?: Id<"usuarios">[]; // IDs dos usuários que leram a mensagem
}
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">, isAdminDeleting: boolean = false) {
const mensagemTexto = isAdminDeleting
? "Tem certeza que deseja deletar esta mensagem como administrador? O remetente será notificado."
: "Tem certeza que deseja deletar esta mensagem?";
if (!confirm(mensagemTexto)) {
return;
}
try {
if (isAdminDeleting) {
const resultado = await client.mutation(api.chat.deletarMensagemComoAdmin, {
mensagemId,
});
if (!resultado.sucesso) {
alert(resultado.erro || "Erro ao deletar mensagem");
}
} else {
await client.mutation(api.chat.deletarMensagem, {
mensagemId,
});
}
} catch (error) {
console.error("Erro ao deletar mensagem:", error);
alert(error.message || "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);
}
// Obter informações da conversa atual
const conversaAtual = $derived(() => {
if (!conversas?.data) return null;
return (conversas.data as any[]).find((c) => c._id === conversaId) || null;
});
// Função para determinar se uma mensagem foi lida
function mensagemFoiLida(mensagem: Mensagem): boolean {
if (!mensagem.lidaPor || mensagem.lidaPor.length === 0) return false;
if (!conversaAtual() || !usuarioAtualId) return false;
const conversa = conversaAtual();
if (!conversa) return false;
// Converter lidaPor para strings para comparação
const lidaPorStr = mensagem.lidaPor.map((id) => String(id));
// Para conversas individuais: verificar se o outro participante leu
if (conversa.tipo === "individual") {
const outroParticipante = conversa.participantes?.find(
(p: any) => String(p) !== usuarioAtualId
);
if (outroParticipante) {
return lidaPorStr.includes(String(outroParticipante));
}
}
// Para grupos/salas: verificar se pelo menos um outro participante leu
if (conversa.tipo === "grupo" || conversa.tipo === "sala_reuniao") {
const outrosParticipantes = conversa.participantes?.filter(
(p: any) => String(p) !== usuarioAtualId && String(p) !== String(mensagem.remetenteId)
) || [];
if (outrosParticipantes.length === 0) return false;
// Verificar se pelo menos um outro participante leu
return outrosParticipantes.some((p: any) =>
lidaPorStr.includes(String(p))
);
}
return false;
}
</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}
<!-- Indicadores de status de envio e leitura -->
<div class="flex items-center gap-0.5 ml-1">
{#if mensagemFoiLida(mensagem)}
<!-- Dois checks azuis para mensagem lida -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-3.5 h-3.5 text-blue-500"
style="margin-left: -2px;"
>
<path
fill-rule="evenodd"
d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z"
clip-rule="evenodd"
/>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-3.5 h-3.5 text-blue-500"
>
<path
fill-rule="evenodd"
d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z"
clip-rule="evenodd"
/>
</svg>
{:else}
<!-- Um check verde para mensagem enviada -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-3.5 h-3.5 text-green-500"
>
<path
fill-rule="evenodd"
d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</div>
{/if}
{#if !mensagem.deletada && !mensagem.agendadaPara}
<div class="flex gap-1">
{#if isMinha}
<!-- Ações para minhas próprias mensagens -->
<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, false)}
title="Deletar mensagem"
>
🗑️
</button>
{:else if isAdmin?.data}
<!-- Ações para admin deletar mensagens de outros -->
<button
class="text-xs text-base-content/50 hover:text-error transition-colors"
onclick={() => deletarMensagem(mensagem._id, true)}
title="Deletar mensagem (como administrador)"
>
🗑️ Admin
</button>
{/if}
</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>
<!-- Popup de Notificação de Nova Mensagem -->
{#if showNotificationPopup && notificationMessage}
<div
class="fixed top-4 right-4 z-[1000] bg-base-100 rounded-lg shadow-2xl border border-primary/20 p-4 max-w-sm animate-in slide-in-from-top-5 fade-in duration-300"
style="box-shadow: 0 10px 40px -10px rgba(0,0,0,0.3);"
onclick={() => {
showNotificationPopup = false;
notificationMessage = null;
if (notificationTimeout) {
clearTimeout(notificationTimeout);
}
}}
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5 text-primary"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-base-content text-sm mb-1">Nova mensagem de {notificationMessage.remetente}</p>
<p class="text-xs text-base-content/70 line-clamp-2">{notificationMessage.conteudo}</p>
</div>
<button
type="button"
class="flex-shrink-0 w-6 h-6 rounded-full hover:bg-base-200 flex items-center justify-center transition-colors"
onclick={(e) => {
e.stopPropagation();
showNotificationPopup = false;
notificationMessage = null;
if (notificationTimeout) {
clearTimeout(notificationTimeout);
}
}}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/if}