774 lines
24 KiB
Svelte
774 lines
24 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 { File, CheckCircle2, CheckCircle, MessageSquare, Bell, X } from 'lucide-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, {});
|
|
// Usuário atual
|
|
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
|
|
|
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 currentUser mudar
|
|
$effect(() => {
|
|
const usuario = currentUser?.data;
|
|
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="bg-base-100 h-full overflow-y-auto px-4 py-4"
|
|
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="my-4 flex items-center justify-center">
|
|
<div class="bg-base-300 text-base-content/70 rounded-full px-3 py-1 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={`mb-4 flex w-full ${isMinha ? 'justify-end' : 'justify-start'}`}>
|
|
<div class={`flex max-w-[75%] flex-col ${isMinha ? 'items-end' : 'items-start'}`}>
|
|
<!-- Nome do remetente (sempre exibido, mas discreto para mensagens próprias) -->
|
|
{#if isMinha}
|
|
<p class="text-base-content/40 mb-1 px-3 text-xs">Você</p>
|
|
{:else}
|
|
<p class="text-base-content/60 mb-1 px-3 text-xs">
|
|
{mensagem.remetente?.nome || 'Usuário'}
|
|
</p>
|
|
{/if}
|
|
|
|
<!-- Balão da mensagem -->
|
|
<div
|
|
class={`rounded-2xl px-4 py-2 ${
|
|
isMinha
|
|
? 'rounded-br-sm bg-blue-200 text-gray-900'
|
|
: 'bg-base-200 text-base-content rounded-bl-sm'
|
|
}`}
|
|
>
|
|
{#if mensagem.mensagemOriginal}
|
|
<!-- Preview da mensagem respondida -->
|
|
<div class="border-base-content/20 mb-2 border-l-2 pl-3 opacity-70">
|
|
<p class="text-xs font-medium">
|
|
{mensagem.mensagemOriginal.remetente?.nome || 'Usuário'}
|
|
</p>
|
|
<p class="truncate text-xs">
|
|
{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="bg-base-100 text-base-content w-full resize-none rounded-lg p-2 text-sm"
|
|
rows="3"
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
salvarEdicao();
|
|
} else if (e.key === 'Escape') {
|
|
cancelarEdicao();
|
|
}
|
|
}}
|
|
></textarea>
|
|
<div class="flex justify-end gap-2">
|
|
<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="flex-1 text-sm break-words whitespace-pre-wrap">
|
|
{mensagem.conteudo}
|
|
</p>
|
|
{#if mensagem.editadaEm}
|
|
<span class="text-xs italic opacity-50" title="Editado">(editado)</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Preview de link -->
|
|
{#if mensagem.linkPreview}
|
|
<a
|
|
href={mensagem.linkPreview.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="border-base-300 hover:border-primary block overflow-hidden rounded-lg border transition-colors"
|
|
>
|
|
{#if mensagem.linkPreview.imagem}
|
|
<img
|
|
src={mensagem.linkPreview.imagem}
|
|
alt={mensagem.linkPreview.titulo || 'Preview'}
|
|
class="h-48 w-full object-cover"
|
|
onerror={(e) => {
|
|
(e.target as HTMLImageElement).style.display = 'none';
|
|
}}
|
|
/>
|
|
{/if}
|
|
<div class="bg-base-200 p-3">
|
|
{#if mensagem.linkPreview.site}
|
|
<p class="text-base-content/50 mb-1 text-xs">
|
|
{mensagem.linkPreview.site}
|
|
</p>
|
|
{/if}
|
|
{#if mensagem.linkPreview.titulo}
|
|
<p class="text-base-content mb-1 text-sm font-medium">
|
|
{mensagem.linkPreview.titulo}
|
|
</p>
|
|
{/if}
|
|
{#if mensagem.linkPreview.descricao}
|
|
<p class="text-base-content/70 line-clamp-2 text-xs">
|
|
{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 break-words whitespace-pre-wrap">
|
|
{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"
|
|
>
|
|
<File class="h-5 w-5" strokeWidth={1.5} />
|
|
<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="mt-2 flex items-center gap-1">
|
|
{#each getEmojisReacao(mensagem) as reacao}
|
|
<button
|
|
type="button"
|
|
class="bg-base-300/50 hover:bg-base-300 rounded-full px-2 py-0.5 text-xs"
|
|
onclick={() => handleReagir(mensagem._id, reacao.emoji)}
|
|
>
|
|
{reacao.emoji}
|
|
{reacao.count}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Botão de responder -->
|
|
{#if !mensagem.deletada}
|
|
<button
|
|
class="text-base-content/50 hover:text-primary mt-1 text-xs transition-colors"
|
|
onclick={() => responderMensagem(mensagem)}
|
|
title="Responder"
|
|
>
|
|
↪️ Responder
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Timestamp e ações -->
|
|
<div
|
|
class={`mt-1 flex items-center gap-2 px-3 ${isMinha ? 'justify-end' : 'justify-start'}`}
|
|
>
|
|
<p class="text-base-content/50 text-xs">
|
|
{formatarDataMensagem(mensagem.enviadaEm)}
|
|
</p>
|
|
{#if isMinha && !mensagem.deletada && !mensagem.agendadaPara}
|
|
<!-- Indicadores de status de envio e leitura -->
|
|
<div class="ml-1 flex items-center gap-0.5">
|
|
{#if mensagemFoiLida(mensagem)}
|
|
<!-- Dois checks azuis para mensagem lida -->
|
|
<CheckCircle2
|
|
class="h-3.5 w-3.5 text-blue-500"
|
|
style="margin-left: -2px;"
|
|
fill="currentColor"
|
|
/>
|
|
<CheckCircle2 class="h-3.5 w-3.5 text-blue-500" fill="currentColor" />
|
|
{:else}
|
|
<!-- Um check verde para mensagem enviada -->
|
|
<CheckCircle class="h-3.5 w-3.5 text-green-500" fill="currentColor" />
|
|
{/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-base-content/50 hover:text-primary text-xs transition-colors"
|
|
onclick={() => editarMensagem(mensagem)}
|
|
title="Editar mensagem"
|
|
>
|
|
✏️
|
|
</button>
|
|
<button
|
|
class="text-base-content/50 hover:text-error text-xs 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-base-content/50 hover:text-error text-xs 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="mb-4 flex items-center gap-2">
|
|
<div class="flex items-center gap-1">
|
|
<div class="bg-base-content/50 h-2 w-2 animate-bounce rounded-full"></div>
|
|
<div
|
|
class="bg-base-content/50 h-2 w-2 animate-bounce rounded-full"
|
|
style="animation-delay: 0.1s;"
|
|
></div>
|
|
<div
|
|
class="bg-base-content/50 h-2 w-2 animate-bounce rounded-full"
|
|
style="animation-delay: 0.2s;"
|
|
></div>
|
|
</div>
|
|
<p class="text-base-content/60 text-xs">
|
|
{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 h-full items-center justify-center">
|
|
<span class="loading loading-spinner loading-lg"></span>
|
|
</div>
|
|
{:else}
|
|
<!-- Vazio -->
|
|
<div class="flex h-full flex-col items-center justify-center text-center">
|
|
<MessageSquare
|
|
class="text-base-content/30 mb-4 h-16 w-16"
|
|
strokeWidth={1.5}
|
|
/>
|
|
<p class="text-base-content/70">Nenhuma mensagem ainda</p>
|
|
<p class="text-base-content/50 mt-1 text-sm">Envie a primeira mensagem!</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Popup de Notificação de Nova Mensagem -->
|
|
{#if showNotificationPopup && notificationMessage}
|
|
<div
|
|
class="bg-base-100 border-primary/20 animate-in slide-in-from-top-5 fade-in fixed top-4 right-4 z-[1000] max-w-sm rounded-lg border p-4 shadow-2xl 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="bg-primary/20 flex h-10 w-10 shrink-0 items-center justify-center rounded-full">
|
|
<Bell class="text-primary h-5 w-5" strokeWidth={2} />
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-base-content mb-1 text-sm font-semibold">
|
|
Nova mensagem de {notificationMessage.remetente}
|
|
</p>
|
|
<p class="text-base-content/70 line-clamp-2 text-xs">
|
|
{notificationMessage.conteudo}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="hover:bg-base-200 flex h-6 w-6 shrink-0 items-center justify-center rounded-full transition-colors"
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
showNotificationPopup = false;
|
|
notificationMessage = null;
|
|
if (notificationTimeout) {
|
|
clearTimeout(notificationTimeout);
|
|
}
|
|
}}
|
|
>
|
|
<X class="h-4 w-4" strokeWidth={2} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|