feat: enhance chat functionality with global notifications and user management

- Implemented global notifications for new messages, allowing users to receive alerts even when the chat is minimized or closed.
- Added functionality for users to leave group conversations and meeting rooms, with appropriate notifications sent to remaining participants.
- Introduced a modal for sending notifications within meeting rooms, enabling admins to communicate important messages to all participants.
- Enhanced the chat components to support mention functionality, allowing users to tag participants in messages for better engagement.
- Updated backend mutations to handle user exit from conversations and sending notifications, ensuring robust data handling and user experience.
This commit is contained in:
2025-11-05 10:40:30 -03:00
parent 8ca737c62f
commit 1774b135b3
6 changed files with 853 additions and 18 deletions

View File

@@ -7,6 +7,7 @@
minimizarChat,
maximizarChat,
abrirChat,
abrirConversa,
} from "$lib/stores/chatStore";
import { useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
@@ -70,6 +71,9 @@
}
let windowSize = $state(getSavedSize());
let isMaximized = $state(false);
let previousSize = $state({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT });
let previousPosition = $state({ x: 0, y: 0 });
// Salvar tamanho no localStorage
function saveSize() {
@@ -152,6 +156,94 @@
activeConversation = $conversaAtiva;
});
// Detectar novas mensagens globalmente (mesmo quando chat está fechado/minimizado)
const todasConversas = useQuery(api.chat.listarConversas, {});
let ultimoTimestampGlobal = $state<number>(0);
let showGlobalNotificationPopup = $state(false);
let globalNotificationMessage = $state<{ remetente: string; conteudo: string; conversaId: string } | null>(null);
let globalNotificationTimeout: ReturnType<typeof setTimeout> | null = null;
// Função para tocar som de notificação
function tocarSomNotificacaoGlobal() {
try {
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
const audioContext = new AudioContext();
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(() => {});
} 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 (e) {
// Ignorar erro de áudio
}
}
$effect(() => {
if (todasConversas?.data && authStore.usuario?._id) {
// Encontrar o maior timestamp de todas as conversas
let maiorTimestamp = 0;
let conversaComNovaMensagem: any = null;
todasConversas.data.forEach((conv: any) => {
if (conv.ultimaMensagemTimestamp && conv.ultimaMensagemTimestamp > maiorTimestamp) {
maiorTimestamp = conv.ultimaMensagemTimestamp;
conversaComNovaMensagem = conv;
}
});
// Se há nova mensagem (timestamp maior) e não estamos vendo essa conversa, tocar som e mostrar popup
if (maiorTimestamp > ultimoTimestampGlobal && conversaComNovaMensagem) {
const conversaAtivaId = activeConversation ? String(activeConversation) : null;
const conversaIdStr = String(conversaComNovaMensagem._id);
// Só mostrar notificação se não estamos vendo essa conversa
if (!isOpen || conversaAtivaId !== conversaIdStr) {
// Tocar som de notificação
tocarSomNotificacaoGlobal();
// Mostrar popup de notificação
globalNotificationMessage = {
remetente: conversaComNovaMensagem.outroUsuario?.nome || conversaComNovaMensagem.nome || "Usuário",
conteudo: conversaComNovaMensagem.ultimaMensagem || "",
conversaId: conversaComNovaMensagem._id
};
showGlobalNotificationPopup = true;
// Ocultar popup após 5 segundos
if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout);
}
globalNotificationTimeout = setTimeout(() => {
showGlobalNotificationPopup = false;
globalNotificationMessage = null;
}, 5000);
}
ultimoTimestampGlobal = maiorTimestamp;
}
}
});
function handleToggle() {
if (isOpen && !isMinimized) {
minimizarChat();
@@ -169,6 +261,31 @@
}
function handleMaximize() {
if (isMaximized) {
// Restaurar tamanho anterior
windowSize = previousSize;
position = previousPosition;
isMaximized = false;
saveSize();
ajustarPosicao();
} else {
// Salvar tamanho e posição atuais
previousSize = { ...windowSize };
previousPosition = { ...position };
// Maximizar completamente: usar toda a largura e altura da tela
windowSize = {
width: window.innerWidth,
height: window.innerHeight
};
position = {
x: 0,
y: 0
};
isMaximized = true;
saveSize();
ajustarPosicao();
}
maximizarChat();
}
@@ -546,6 +663,60 @@
</div>
{/if}
<!-- Popup Global de Notificação de Nova Mensagem (quando chat está fechado/minimizado) -->
{#if showGlobalNotificationPopup && globalNotificationMessage}
<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 cursor-pointer"
style="box-shadow: 0 10px 40px -10px rgba(0,0,0,0.3); animation: slideInRight 0.3s ease-out;"
onclick={() => {
showGlobalNotificationPopup = false;
globalNotificationMessage = null;
if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout);
}
// Abrir chat e conversa ao clicar
abrirChat();
abrirConversa(globalNotificationMessage.conversaId as any);
}}
>
<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 {globalNotificationMessage.remetente}</p>
<p class="text-xs text-base-content/70 line-clamp-2">{globalNotificationMessage.conteudo}</p>
<p class="text-xs text-primary mt-1">Clique para abrir</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();
showGlobalNotificationPopup = false;
globalNotificationMessage = null;
if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout);
}
}}
>
<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}
<style>
/* Animação do badge com bounce suave */
@keyframes badge-bounce {