Ajuste chat #9
@@ -7,6 +7,7 @@
|
|||||||
minimizarChat,
|
minimizarChat,
|
||||||
maximizarChat,
|
maximizarChat,
|
||||||
abrirChat,
|
abrirChat,
|
||||||
|
abrirConversa,
|
||||||
} from "$lib/stores/chatStore";
|
} from "$lib/stores/chatStore";
|
||||||
import { useQuery } from "convex-svelte";
|
import { useQuery } from "convex-svelte";
|
||||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
@@ -70,6 +71,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let windowSize = $state(getSavedSize());
|
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
|
// Salvar tamanho no localStorage
|
||||||
function saveSize() {
|
function saveSize() {
|
||||||
@@ -152,6 +156,94 @@
|
|||||||
activeConversation = $conversaAtiva;
|
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() {
|
function handleToggle() {
|
||||||
if (isOpen && !isMinimized) {
|
if (isOpen && !isMinimized) {
|
||||||
minimizarChat();
|
minimizarChat();
|
||||||
@@ -169,6 +261,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleMaximize() {
|
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();
|
maximizarChat();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,6 +663,60 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
<style>
|
||||||
/* Animação do badge com bounce suave */
|
/* Animação do badge com bounce suave */
|
||||||
@keyframes badge-bounce {
|
@keyframes badge-bounce {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQuery } from "convex-svelte";
|
import { useQuery, useConvexClient } from "convex-svelte";
|
||||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
import { voltarParaLista } from "$lib/stores/chatStore";
|
import { voltarParaLista } from "$lib/stores/chatStore";
|
||||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
import ScheduleMessageModal from "./ScheduleMessageModal.svelte";
|
import ScheduleMessageModal from "./ScheduleMessageModal.svelte";
|
||||||
import SalaReuniaoManager from "./SalaReuniaoManager.svelte";
|
import SalaReuniaoManager from "./SalaReuniaoManager.svelte";
|
||||||
import { getAvatarUrl } from "$lib/utils/avatarGenerator";
|
import { getAvatarUrl } from "$lib/utils/avatarGenerator";
|
||||||
|
import { authStore } from "$lib/stores/auth.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversaId: string;
|
conversaId: string;
|
||||||
@@ -17,8 +18,12 @@
|
|||||||
|
|
||||||
let { conversaId }: Props = $props();
|
let { conversaId }: Props = $props();
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
let showScheduleModal = $state(false);
|
let showScheduleModal = $state(false);
|
||||||
let showSalaManager = $state(false);
|
let showSalaManager = $state(false);
|
||||||
|
let showAdminMenu = $state(false);
|
||||||
|
let showNotificacaoModal = $state(false);
|
||||||
|
|
||||||
const conversas = useQuery(api.chat.listarConversas, {});
|
const conversas = useQuery(api.chat.listarConversas, {});
|
||||||
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId: conversaId as any });
|
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId: conversaId as any });
|
||||||
@@ -73,11 +78,36 @@
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSairGrupoOuSala() {
|
||||||
|
const c = conversa();
|
||||||
|
if (!c || (c.tipo !== "grupo" && c.tipo !== "sala_reuniao")) return;
|
||||||
|
|
||||||
|
const tipoTexto = c.tipo === "sala_reuniao" ? "sala de reunião" : "grupo";
|
||||||
|
if (!confirm(`Tem certeza que deseja sair da ${tipoTexto} "${c.nome || "Sem nome"}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resultado = await client.mutation(api.chat.sairGrupoOuSala, {
|
||||||
|
conversaId: conversaId as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resultado.sucesso) {
|
||||||
|
voltarParaLista();
|
||||||
|
} else {
|
||||||
|
alert(resultado.erro || "Erro ao sair da conversa");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Erro ao sair da conversa:", error);
|
||||||
|
alert(error.message || "Erro ao sair da conversa");
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full" onclick={() => (showAdminMenu = false)}>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-base-300 bg-base-200">
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-base-300 bg-base-200" onclick={(e) => e.stopPropagation()}>
|
||||||
<!-- Botão Voltar -->
|
<!-- Botão Voltar -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -170,15 +200,50 @@
|
|||||||
|
|
||||||
<!-- Botões de ação -->
|
<!-- Botões de ação -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<!-- Botão Gerenciar Sala (apenas para salas de reunião) -->
|
<!-- Botão Sair (apenas para grupos e salas de reunião) -->
|
||||||
{#if conversa()?.tipo === "sala_reuniao"}
|
{#if conversa() && (conversa()?.tipo === "grupo" || conversa()?.tipo === "sala_reuniao")}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
|
||||||
|
style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2);"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSairGrupoOuSala();
|
||||||
|
}}
|
||||||
|
aria-label="Sair"
|
||||||
|
title="Sair da conversa"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-red-500/0 group-hover:bg-red-500/10 transition-colors duration-300"></div>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="w-5 h-5 text-red-500 relative z-10 group-hover:scale-110 transition-transform"
|
||||||
|
>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
|
<polyline points="16 17 21 12 16 7"/>
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Botão Menu Administrativo (apenas para salas de reunião e apenas para admins) -->
|
||||||
|
{#if conversa()?.tipo === "sala_reuniao" && isAdmin?.data}
|
||||||
|
<div class="relative admin-menu-container">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
|
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
|
||||||
style="background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2);"
|
style="background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2);"
|
||||||
onclick={() => (showSalaManager = true)}
|
onclick={(e) => {
|
||||||
aria-label="Gerenciar sala"
|
e.stopPropagation();
|
||||||
title="Gerenciar sala de reunião"
|
showAdminMenu = !showAdminMenu;
|
||||||
|
}}
|
||||||
|
aria-label="Menu administrativo"
|
||||||
|
title="Recursos administrativos"
|
||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-blue-500/0 group-hover:bg-blue-500/10 transition-colors duration-300"></div>
|
<div class="absolute inset-0 bg-blue-500/0 group-hover:bg-blue-500/10 transition-colors duration-300"></div>
|
||||||
<svg
|
<svg
|
||||||
@@ -191,10 +256,82 @@
|
|||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
class="w-5 h-5 text-blue-500 relative z-10 group-hover:scale-110 transition-transform"
|
class="w-5 h-5 text-blue-500 relative z-10 group-hover:scale-110 transition-transform"
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="1"/>
|
||||||
<path d="M12 1v6m0 6v6M5.64 5.64l4.24 4.24m4.24 4.24l4.24 4.24M1 12h6m6 0h6M5.64 18.36l4.24-4.24m4.24-4.24l4.24-4.24"/>
|
<circle cx="12" cy="5" r="1"/>
|
||||||
|
<circle cx="12" cy="19" r="1"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
{#if showAdminMenu}
|
||||||
|
<ul
|
||||||
|
class="absolute right-0 top-full mt-2 bg-base-100 rounded-lg shadow-xl border border-base-300 w-56 z-[100] overflow-hidden"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left px-4 py-3 hover:bg-base-200 transition-colors flex items-center gap-2"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showSalaManager = true;
|
||||||
|
showAdminMenu = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||||
|
</svg>
|
||||||
|
Gerenciar Participantes
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left px-4 py-3 hover:bg-base-200 transition-colors flex items-center gap-2"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showNotificacaoModal = true;
|
||||||
|
showAdminMenu = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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="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>
|
||||||
|
Enviar Notificação
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left px-4 py-3 hover:bg-error/10 transition-colors flex items-center gap-2 text-error"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
(async () => {
|
||||||
|
if (!confirm("Tem certeza que deseja encerrar esta reunião? Todos os participantes serão removidos.")) return;
|
||||||
|
try {
|
||||||
|
const resultado = await client.mutation(api.chat.encerrarReuniao, {
|
||||||
|
conversaId: conversaId as any,
|
||||||
|
});
|
||||||
|
if (resultado.sucesso) {
|
||||||
|
alert("Reunião encerrada com sucesso!");
|
||||||
|
voltarParaLista();
|
||||||
|
} else {
|
||||||
|
alert(resultado.erro || "Erro ao encerrar reunião");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.message || "Erro ao encerrar reunião");
|
||||||
|
}
|
||||||
|
showAdminMenu = false;
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
Encerrar Reunião
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Botão Agendar MODERNO -->
|
<!-- Botão Agendar MODERNO -->
|
||||||
@@ -252,3 +389,91 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Modal de Enviar Notificação -->
|
||||||
|
{#if showNotificacaoModal && conversa()?.tipo === "sala_reuniao" && isAdmin?.data}
|
||||||
|
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" onclick={() => (showNotificacaoModal = false)}>
|
||||||
|
<div
|
||||||
|
class="bg-base-100 rounded-xl shadow-2xl w-full max-w-md m-4"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
|
||||||
|
<h2 class="text-xl font-semibold">Enviar Notificação</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm btn-circle"
|
||||||
|
onclick={() => (showNotificacaoModal = false)}
|
||||||
|
>
|
||||||
|
<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="M6 18 18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<form
|
||||||
|
onsubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const titulo = formData.get("titulo") as string;
|
||||||
|
const mensagem = formData.get("mensagem") as string;
|
||||||
|
|
||||||
|
if (!titulo.trim() || !mensagem.trim()) {
|
||||||
|
alert("Preencha todos os campos");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resultado = await client.mutation(api.chat.enviarNotificacaoReuniao, {
|
||||||
|
conversaId: conversaId as any,
|
||||||
|
titulo: titulo.trim(),
|
||||||
|
mensagem: mensagem.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resultado.sucesso) {
|
||||||
|
alert("Notificação enviada com sucesso!");
|
||||||
|
showNotificacaoModal = false;
|
||||||
|
} else {
|
||||||
|
alert(resultado.erro || "Erro ao enviar notificação");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.message || "Erro ao enviar notificação");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Título</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="titulo"
|
||||||
|
placeholder="Título da notificação"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Mensagem</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
name="mensagem"
|
||||||
|
placeholder="Mensagem da notificação"
|
||||||
|
class="textarea textarea-bordered w-full"
|
||||||
|
rows="4"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="button" class="btn btn-ghost flex-1" onclick={() => (showNotificacaoModal = false)}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary flex-1">
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useConvexClient } from "convex-svelte";
|
import { useConvexClient, useQuery } from "convex-svelte";
|
||||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { authStore } from "$lib/stores/auth.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversaId: Id<"conversas">;
|
conversaId: Id<"conversas">;
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
let { conversaId }: Props = $props();
|
let { conversaId }: Props = $props();
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
const conversas = useQuery(api.chat.listarConversas, {});
|
||||||
|
|
||||||
let mensagem = $state("");
|
let mensagem = $state("");
|
||||||
let textarea: HTMLTextAreaElement;
|
let textarea: HTMLTextAreaElement;
|
||||||
@@ -19,6 +21,9 @@
|
|||||||
let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null;
|
let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let showEmojiPicker = $state(false);
|
let showEmojiPicker = $state(false);
|
||||||
let mensagemRespondendo: { id: Id<"mensagens">; conteudo: string; remetente: string } | null = $state(null);
|
let mensagemRespondendo: { id: Id<"mensagens">; conteudo: string; remetente: string } | null = $state(null);
|
||||||
|
let showMentionsDropdown = $state(false);
|
||||||
|
let mentionQuery = $state("");
|
||||||
|
let mentionStartPos = $state(0);
|
||||||
|
|
||||||
// Emojis mais usados
|
// Emojis mais usados
|
||||||
const emojis = [
|
const emojis = [
|
||||||
@@ -37,13 +42,55 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-resize do textarea
|
// Obter conversa atual
|
||||||
function handleInput() {
|
const conversa = $derived(() => {
|
||||||
|
if (!conversas?.data) return null;
|
||||||
|
return conversas.data.find((c: any) => c._id === conversaId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obter participantes para menções (apenas grupos e salas)
|
||||||
|
const participantesParaMencoes = $derived(() => {
|
||||||
|
const c = conversa();
|
||||||
|
if (!c || (c.tipo !== "grupo" && c.tipo !== "sala_reuniao")) return [];
|
||||||
|
return c.participantesInfo || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtrar participantes para dropdown de menções
|
||||||
|
const participantesFiltrados = $derived(() => {
|
||||||
|
if (!mentionQuery.trim()) return participantesParaMencoes().slice(0, 5);
|
||||||
|
const query = mentionQuery.toLowerCase();
|
||||||
|
return participantesParaMencoes().filter((p: any) =>
|
||||||
|
p.nome?.toLowerCase().includes(query) ||
|
||||||
|
p.email?.toLowerCase().includes(query)
|
||||||
|
).slice(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-resize do textarea e detectar menções
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const target = e.target as HTMLTextAreaElement;
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
textarea.style.height = "auto";
|
textarea.style.height = "auto";
|
||||||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + "px";
|
textarea.style.height = Math.min(textarea.scrollHeight, 120) + "px";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detectar menções (@)
|
||||||
|
const cursorPos = target.selectionStart || 0;
|
||||||
|
const textBeforeCursor = mensagem.substring(0, cursorPos);
|
||||||
|
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||||
|
|
||||||
|
if (lastAtIndex !== -1) {
|
||||||
|
const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1);
|
||||||
|
// Se não há espaço após o @, mostrar dropdown
|
||||||
|
if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
|
||||||
|
mentionQuery = textAfterAt;
|
||||||
|
mentionStartPos = lastAtIndex;
|
||||||
|
showMentionsDropdown = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showMentionsDropdown = false;
|
||||||
|
|
||||||
// Indicador de digitação (debounce de 1s)
|
// Indicador de digitação (debounce de 1s)
|
||||||
if (digitacaoTimeout) {
|
if (digitacaoTimeout) {
|
||||||
clearTimeout(digitacaoTimeout);
|
clearTimeout(digitacaoTimeout);
|
||||||
@@ -55,15 +102,46 @@
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inserirMencao(participante: any) {
|
||||||
|
const nome = participante.nome.split(' ')[0]; // Usar primeiro nome
|
||||||
|
const antes = mensagem.substring(0, mentionStartPos);
|
||||||
|
const depois = mensagem.substring(textarea.selectionStart || mensagem.length);
|
||||||
|
mensagem = antes + `@${nome} ` + depois;
|
||||||
|
showMentionsDropdown = false;
|
||||||
|
mentionQuery = "";
|
||||||
|
if (textarea) {
|
||||||
|
textarea.focus();
|
||||||
|
const newPos = antes.length + nome.length + 2;
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.setSelectionRange(newPos, newPos);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleEnviar() {
|
async function handleEnviar() {
|
||||||
const texto = mensagem.trim();
|
const texto = mensagem.trim();
|
||||||
if (!texto || enviando) return;
|
if (!texto || enviando) return;
|
||||||
|
|
||||||
|
// Extrair menções do texto (@nome)
|
||||||
|
const mencoesIds: Id<"usuarios">[] = [];
|
||||||
|
const mentionRegex = /@(\w+)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = mentionRegex.exec(texto)) !== null) {
|
||||||
|
const nomeMencionado = match[1];
|
||||||
|
const participante = participantesParaMencoes().find((p: any) =>
|
||||||
|
p.nome.split(' ')[0].toLowerCase() === nomeMencionado.toLowerCase()
|
||||||
|
);
|
||||||
|
if (participante) {
|
||||||
|
mencoesIds.push(participante._id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log("📤 [MessageInput] Enviando mensagem:", {
|
console.log("📤 [MessageInput] Enviando mensagem:", {
|
||||||
conversaId,
|
conversaId,
|
||||||
conteudo: texto,
|
conteudo: texto,
|
||||||
tipo: "texto",
|
tipo: "texto",
|
||||||
respostaPara: mensagemRespondendo?.id,
|
respostaPara: mensagemRespondendo?.id,
|
||||||
|
mencoes: mencoesIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -73,12 +151,15 @@
|
|||||||
conteudo: texto,
|
conteudo: texto,
|
||||||
tipo: "texto",
|
tipo: "texto",
|
||||||
respostaPara: mensagemRespondendo?.id,
|
respostaPara: mensagemRespondendo?.id,
|
||||||
|
mencoes: mencoesIds.length > 0 ? mencoesIds : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ [MessageInput] Mensagem enviada com sucesso! ID:", result);
|
console.log("✅ [MessageInput] Mensagem enviada com sucesso! ID:", result);
|
||||||
|
|
||||||
mensagem = "";
|
mensagem = "";
|
||||||
mensagemRespondendo = null;
|
mensagemRespondendo = null;
|
||||||
|
showMentionsDropdown = false;
|
||||||
|
mentionQuery = "";
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
textarea.style.height = "auto";
|
textarea.style.height = "auto";
|
||||||
}
|
}
|
||||||
@@ -119,6 +200,22 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
// Navegar dropdown de menções
|
||||||
|
if (showMentionsDropdown && participantesFiltrados().length > 0) {
|
||||||
|
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
// Implementação simples: selecionar primeiro participante
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
inserirMencao(participantesFiltrados()[0]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
showMentionsDropdown = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Enter sem Shift = enviar
|
// Enter sem Shift = enviar
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -295,11 +392,36 @@
|
|||||||
bind:value={mensagem}
|
bind:value={mensagem}
|
||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onkeydown={handleKeyDown}
|
onkeydown={handleKeyDown}
|
||||||
placeholder="Digite uma mensagem..."
|
placeholder="Digite uma mensagem... (use @ para mencionar)"
|
||||||
class="textarea textarea-bordered w-full resize-none min-h-[44px] max-h-[120px] pr-10"
|
class="textarea textarea-bordered w-full resize-none min-h-[44px] max-h-[120px] pr-10"
|
||||||
rows="1"
|
rows="1"
|
||||||
disabled={enviando || uploadingFile}
|
disabled={enviando || uploadingFile}
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
|
<!-- Dropdown de Menções -->
|
||||||
|
{#if showMentionsDropdown && participantesFiltrados().length > 0 && (conversa()?.tipo === "grupo" || conversa()?.tipo === "sala_reuniao")}
|
||||||
|
<div class="absolute bottom-full left-0 mb-2 bg-base-100 rounded-lg shadow-xl border border-base-300 z-50 w-64 max-h-48 overflow-y-auto">
|
||||||
|
{#each participantesFiltrados() as participante (participante._id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left px-4 py-2 hover:bg-base-200 transition-colors flex items-center gap-2"
|
||||||
|
onclick={() => inserirMencao(participante)}
|
||||||
|
>
|
||||||
|
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center overflow-hidden">
|
||||||
|
{#if participante.fotoPerfilUrl}
|
||||||
|
<img src={participante.fotoPerfilUrl} alt={participante.nome} class="w-full h-full object-cover" />
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs font-semibold">{participante.nome.charAt(0).toUpperCase()}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium truncate">{participante.nome}</p>
|
||||||
|
<p class="text-xs text-base-content/60 truncate">@{participante.nome.split(' ')[0]}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Botão de enviar MODERNO -->
|
<!-- Botão de enviar MODERNO -->
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
let messagesContainer: HTMLDivElement;
|
let messagesContainer: HTMLDivElement;
|
||||||
let shouldScrollToBottom = true;
|
let shouldScrollToBottom = true;
|
||||||
let lastMessageCount = 0;
|
let lastMessageCount = 0;
|
||||||
|
let lastMessageId = $state<string | null>(null);
|
||||||
|
let showNotificationPopup = $state(false);
|
||||||
|
let notificationMessage = $state<{ remetente: string; conteudo: string } | null>(null);
|
||||||
|
let notificationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
// Obter ID do usuário atual - usar $state para garantir reatividade
|
// Obter ID do usuário atual - usar $state para garantir reatividade
|
||||||
let usuarioAtualId = $state<string | null>(null);
|
let usuarioAtualId = $state<string | null>(null);
|
||||||
@@ -36,12 +40,101 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Função para tocar som de notificação
|
||||||
|
function tocarSomNotificacao() {
|
||||||
|
try {
|
||||||
|
// Usar AudioContext (requer interação do usuário para iniciar)
|
||||||
|
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||||
|
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
|
// Auto-scroll para a última mensagem quando novas mensagens chegam
|
||||||
|
// E detectar novas mensagens para tocar som e mostrar popup
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (mensagens?.data && messagesContainer) {
|
if (mensagens?.data && messagesContainer) {
|
||||||
const currentCount = mensagens.data.length;
|
const currentCount = mensagens.data.length;
|
||||||
const isNewMessage = currentCount > lastMessageCount;
|
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 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)
|
||||||
|
if (remetenteIdStr && remetenteIdStr !== usuarioAtualId && ultimaMensagem._id !== lastMessageId) {
|
||||||
|
// Tocar som de notificação
|
||||||
|
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);
|
||||||
|
|
||||||
|
lastMessageId = ultimaMensagem._id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isNewMessage || shouldScrollToBottom) {
|
if (isNewMessage || shouldScrollToBottom) {
|
||||||
// Usar requestAnimationFrame para garantir que o DOM foi atualizado
|
// Usar requestAnimationFrame para garantir que o DOM foi atualizado
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -536,3 +629,53 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,11 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" onclick={onClose}>
|
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<div
|
<div
|
||||||
class="bg-base-100 rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col m-4"
|
class="bg-base-100 rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col m-4"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@@ -1300,6 +1300,176 @@ export const rebaixarAdministrador = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permite que um usuário saia de um grupo ou sala de reunião
|
||||||
|
*/
|
||||||
|
export const sairGrupoOuSala = mutation({
|
||||||
|
args: {
|
||||||
|
conversaId: v.id("conversas"),
|
||||||
|
},
|
||||||
|
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||||
|
if (!usuarioAtual) {
|
||||||
|
return { sucesso: false, erro: "Não autenticado" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversa = await ctx.db.get(args.conversaId);
|
||||||
|
if (!conversa) {
|
||||||
|
return { sucesso: false, erro: "Conversa não encontrada" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é grupo ou sala de reunião
|
||||||
|
if (conversa.tipo !== "grupo" && conversa.tipo !== "sala_reuniao") {
|
||||||
|
return { sucesso: false, erro: "Esta funcionalidade é apenas para grupos e salas de reunião" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se usuário é participante
|
||||||
|
if (!conversa.participantes.includes(usuarioAtual._id)) {
|
||||||
|
return { sucesso: false, erro: "Você não é participante desta conversa" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remover usuário dos participantes
|
||||||
|
const novosParticipantes = conversa.participantes.filter((p) => p !== usuarioAtual._id);
|
||||||
|
|
||||||
|
// Se for sala de reunião e o usuário for administrador, removê-lo também dos administradores
|
||||||
|
let novosAdministradores = conversa.administradores;
|
||||||
|
if (conversa.tipo === "sala_reuniao" && conversa.administradores) {
|
||||||
|
novosAdministradores = conversa.administradores.filter((adminId) => adminId !== usuarioAtual._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.conversaId, {
|
||||||
|
participantes: novosParticipantes,
|
||||||
|
administradores: novosAdministradores && novosAdministradores.length > 0 ? novosAdministradores : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar notificação para outros participantes informando que o usuário saiu
|
||||||
|
const tipoTexto = conversa.tipo === "sala_reuniao" ? "sala de reunião" : "grupo";
|
||||||
|
for (const participanteId of novosParticipantes) {
|
||||||
|
await ctx.db.insert("notificacoes", {
|
||||||
|
usuarioId: participanteId,
|
||||||
|
tipo: "nova_mensagem",
|
||||||
|
conversaId: args.conversaId,
|
||||||
|
remetenteId: usuarioAtual._id,
|
||||||
|
titulo: "Participante saiu",
|
||||||
|
descricao: `${usuarioAtual.nome} saiu da ${tipoTexto} "${conversa.nome || "Sem nome"}"`,
|
||||||
|
lida: false,
|
||||||
|
criadaEm: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sucesso: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encerra uma sala de reunião (apenas administradores)
|
||||||
|
* Remove todos os participantes e marca a sala como encerrada
|
||||||
|
*/
|
||||||
|
export const encerrarReuniao = mutation({
|
||||||
|
args: {
|
||||||
|
conversaId: v.id("conversas"),
|
||||||
|
},
|
||||||
|
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||||
|
if (!usuarioAtual) {
|
||||||
|
return { sucesso: false, erro: "Não autenticado" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversa = await ctx.db.get(args.conversaId);
|
||||||
|
if (!conversa) {
|
||||||
|
return { sucesso: false, erro: "Sala de reunião não encontrada" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é sala de reunião
|
||||||
|
if (conversa.tipo !== "sala_reuniao") {
|
||||||
|
return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se usuário é administrador
|
||||||
|
const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id);
|
||||||
|
if (!isAdmin) {
|
||||||
|
return { sucesso: false, erro: "Apenas administradores podem encerrar a reunião" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar notificação para todos os participantes informando que a reunião foi encerrada
|
||||||
|
for (const participanteId of conversa.participantes) {
|
||||||
|
if (participanteId !== usuarioAtual._id) {
|
||||||
|
await ctx.db.insert("notificacoes", {
|
||||||
|
usuarioId: participanteId,
|
||||||
|
tipo: "nova_mensagem",
|
||||||
|
conversaId: args.conversaId,
|
||||||
|
remetenteId: usuarioAtual._id,
|
||||||
|
titulo: "Reunião encerrada",
|
||||||
|
descricao: `A sala de reunião "${conversa.nome || "Sem nome"}" foi encerrada por ${usuarioAtual.nome}`,
|
||||||
|
lida: false,
|
||||||
|
criadaEm: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remover todos os participantes (exceto o criador, se necessário manter histórico)
|
||||||
|
// Por enquanto, vamos apenas limpar a lista de participantes
|
||||||
|
await ctx.db.patch(args.conversaId, {
|
||||||
|
participantes: [],
|
||||||
|
administradores: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sucesso: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envia uma notificação para todos os participantes de uma sala de reunião (apenas administradores)
|
||||||
|
*/
|
||||||
|
export const enviarNotificacaoReuniao = mutation({
|
||||||
|
args: {
|
||||||
|
conversaId: v.id("conversas"),
|
||||||
|
titulo: v.string(),
|
||||||
|
mensagem: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||||
|
if (!usuarioAtual) {
|
||||||
|
return { sucesso: false, erro: "Não autenticado" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversa = await ctx.db.get(args.conversaId);
|
||||||
|
if (!conversa) {
|
||||||
|
return { sucesso: false, erro: "Sala de reunião não encontrada" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é sala de reunião
|
||||||
|
if (conversa.tipo !== "sala_reuniao") {
|
||||||
|
return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se usuário é administrador
|
||||||
|
const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id);
|
||||||
|
if (!isAdmin) {
|
||||||
|
return { sucesso: false, erro: "Apenas administradores podem enviar notificações" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar notificação para todos os participantes
|
||||||
|
for (const participanteId of conversa.participantes) {
|
||||||
|
await ctx.db.insert("notificacoes", {
|
||||||
|
usuarioId: participanteId,
|
||||||
|
tipo: "nova_mensagem",
|
||||||
|
conversaId: args.conversaId,
|
||||||
|
remetenteId: usuarioAtual._id,
|
||||||
|
titulo: args.titulo || "Notificação da sala de reunião",
|
||||||
|
descricao: args.mensagem,
|
||||||
|
lida: false,
|
||||||
|
criadaEm: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sucesso: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ========== QUERIES ==========
|
// ========== QUERIES ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user