Merge remote-tracking branch 'origin' into feat-pedidos

This commit is contained in:
2025-12-11 10:08:12 -03:00
194 changed files with 30374 additions and 10247 deletions

View File

@@ -1,11 +1,14 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { abrirConversa } from '$lib/stores/chatStore';
import NewConversationModal from './NewConversationModal.svelte';
import UserAvatar from './UserAvatar.svelte';
import { formatDistanceToNow } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import UserStatusBadge from './UserStatusBadge.svelte';
import UserAvatar from './UserAvatar.svelte';
import NewConversationModal from './NewConversationModal.svelte';
import { Search, Plus, MessageSquare, Users, UsersRound } from 'lucide-svelte';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
const client = useConvexClient();
@@ -86,16 +89,22 @@
});
});
function formatarTempo(timestamp: number | undefined): string {
if (!timestamp) return '';
try {
return formatDistanceToNow(new Date(timestamp), {
addSuffix: true,
locale: ptBR
});
} catch {
return '';
}
}
let processando = $state(false);
let showNewConversationModal = $state(false);
interface Usuario {
_id: Id<'usuarios'>;
nome: string;
[key: string]: unknown;
}
async function handleClickUsuario(usuario: Usuario) {
async function handleClickUsuario(usuario: any) {
if (processando) {
console.log('⏳ Já está processando uma ação, aguarde...');
return;
@@ -178,7 +187,6 @@
}
</script>
```
<div class="flex h-full flex-col">
<!-- Search bar -->
<div class="border-base-300 border-b p-4">
@@ -188,21 +196,16 @@
placeholder="Buscar usuários (nome, email, matrícula)..."
class="input input-bordered w-full pl-10"
bind:value={searchQuery}
aria-label="Buscar usuários ou conversas"
aria-describedby="search-help"
/>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="text-base-content/50 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
<span id="search-help" class="sr-only"
>Digite para buscar usuários por nome, email ou matrícula</span
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
<Search
class="text-base-content/50 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
strokeWidth={1.5}
/>
</div>
</div>
@@ -222,7 +225,7 @@
class={`tab flex-1 ${activeTab === 'conversas' ? 'tab-active' : ''}`}
onclick={() => (activeTab = 'conversas')}
>
💬 Conversas ({conversasFiltradas().length})
💬 Conversas ({conversasFiltradas.length})
</button>
</div>
@@ -235,16 +238,7 @@
title="Nova conversa (grupo ou sala de reunião)"
aria-label="Nova conversa"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="mr-1 h-4 w-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<Plus class="mr-1 h-4 w-4" strokeWidth={2} />
Nova Conversa
</button>
</div>
@@ -263,34 +257,24 @@
: 'cursor-pointer'}"
onclick={() => handleClickUsuario(usuario)}
disabled={processando}
aria-label="Abrir conversa com {usuario.nome}"
aria-describedby="usuario-status-{usuario._id}"
>
<!-- Ícone de mensagem -->
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-300 hover:scale-110"
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 1px solid rgba(102, 126, 234, 0.2);"
>
<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="text-primary h-5 w-5"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
<path d="M9 10h.01M15 10h.01" />
</svg>
<MessageSquare class="text-primary h-5 w-5" strokeWidth={2} />
</div>
<!-- Avatar -->
<div class="relative shrink-0">
<UserAvatar
avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfilUrl}
nome={usuario.nome}
size="md"
userId={usuario._id}
/>
<!-- Status badge -->
<div class="absolute right-0 bottom-0">
@@ -321,6 +305,9 @@
{usuario.statusMensagem || usuario.email}
</p>
</div>
<span id="usuario-status-{usuario._id}" class="sr-only">
Status: {getStatusLabel(usuario.statusPresenca)}
</span>
</div>
</button>
{/each}
@@ -332,27 +319,14 @@
{:else}
<!-- Nenhum usuário encontrado -->
<div class="flex h-full flex-col items-center justify-center px-4 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="text-base-content/30 mb-4 h-16 w-16"
>
<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>
<UsersRound class="text-base-content/30 mb-4 h-16 w-16" strokeWidth={1.5} />
<p class="text-base-content/70">Nenhum usuário encontrado</p>
</div>
{/if}
{:else}
<!-- Lista de conversas (grupos e salas) -->
{#if conversas?.data && conversasFiltradas().length > 0}
{#each conversasFiltradas() as conversa (conversa._id)}
{#if conversas?.data && conversasFiltradas.length > 0}
{#each conversasFiltradas as conversa (conversa._id)}
<button
type="button"
class="hover:bg-base-200 border-base-300 flex w-full items-center gap-3 border-b px-4 py-3 text-left transition-colors {processando
@@ -369,35 +343,9 @@
: 'from-primary/20 to-secondary/20 border-primary/30 border bg-linear-to-br'}"
>
{#if conversa.tipo === 'sala_reuniao'}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="h-5 w-5 text-blue-500"
>
<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>
<UsersRound class="h-5 w-5 text-blue-500" strokeWidth={2} />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="text-primary h-5 w-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"
/>
</svg>
<Users class="text-primary h-5 w-5" strokeWidth={2} />
{/if}
</div>
@@ -440,20 +388,7 @@
{:else}
<!-- Nenhuma conversa encontrada -->
<div class="flex h-full flex-col items-center justify-center px-4 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="text-base-content/30 mb-4 h-16 w-16"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
/>
</svg>
<MessageSquare class="text-base-content/30 mb-4 h-16 w-16" strokeWidth={1.5} />
<p class="text-base-content/70 mb-2 font-medium">Nenhuma conversa encontrada</p>
<p class="text-base-content/50 text-sm">Crie um grupo ou sala de reunião para começar</p>
</div>

View File

@@ -11,29 +11,46 @@
conversaAtiva,
fecharChat,
maximizarChat,
minimizarChat
minimizarChat,
notificacaoAtiva
} from '$lib/stores/chatStore';
import ChatList from './ChatList.svelte';
import ChatWindow from './ChatWindow.svelte';
import { MessageSquare, Minus, Maximize2, X, Bell } from 'lucide-svelte';
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
// Query para verificar o ID do usuário logado (usar como referência)
// Query otimizada: usar apenas uma query para obter usuário atual
// Priorizar obterPerfil pois retorna mais informações úteis
const meuPerfilQuery = useQuery(api.usuarios.obterPerfil, {});
// Usuário atual
const currentUser = useQuery(api.auth.getCurrentUser, {});
// Derivar ID do usuário de forma otimizada (usar perfil primeiro, fallback para currentUser)
const meuId = $derived(() => {
if (meuPerfilQuery?.data?._id) {
return String(meuPerfilQuery.data._id).trim();
}
if (currentUser?.data?._id) {
return String(currentUser.data._id).trim();
}
return null;
});
let isOpen = $derived(false);
let isMinimized = $derived(false);
let activeConversation = $state<string | null>(null);
// Função para obter a URL do avatar/foto do usuário logado
// Função para obter a URL do avatar/foto do usuário logado (otimizada)
let avatarUrlDoUsuario = $derived(() => {
const usuario = currentUser?.data;
if (!usuario) return null;
// Priorizar perfil (tem mais informações)
const perfil = meuPerfilQuery?.data;
if (perfil?.fotoPerfilUrl) {
return perfil.fotoPerfilUrl;
}
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
if (usuario.fotoPerfilUrl) {
// Fallback para currentUser
const usuario = currentUser?.data;
if (usuario?.fotoPerfilUrl) {
return usuario.fotoPerfilUrl;
}
@@ -50,6 +67,13 @@
let dragThreshold = $state(5); // Distância mínima em pixels para considerar arrastar
let hasMoved = $state(false); // Flag para verificar se houve movimento durante o arrastar
let shouldPreventClick = $state(false); // Flag para prevenir clique após arrastar
let isDoubleClicking = $state(false); // Flag para prevenir clique simples após duplo clique
// Suporte a gestos touch (swipe)
let touchStart = $state<{ x: number; y: number; time: number } | null>(null);
let touchCurrent = $state<{ x: number; y: number } | null>(null);
let isTouching = $state(false);
let swipeVelocity = $state(0); // Velocidade do swipe para animação
// Tamanho da janela (redimensionável)
const MIN_WIDTH = 300;
@@ -397,47 +421,29 @@
}
}
// Throttle para evitar execuções muito frequentes do effect
let ultimaExecucaoNotificacao = $state(0);
const THROTTLE_NOTIFICACAO_MS = 1000; // 1 segundo entre execuções
$effect(() => {
if (todasConversas?.data && currentUser?.data?._id) {
const agora = Date.now();
const tempoDesdeUltimaExecucao = agora - ultimaExecucaoNotificacao;
// Throttle: só executar se passou tempo suficiente
if (tempoDesdeUltimaExecucao < THROTTLE_NOTIFICACAO_MS && ultimaExecucaoNotificacao > 0) {
return;
}
if (todasConversas?.data && meuId()) {
ultimaExecucaoNotificacao = agora;
const conversas = todasConversas.data as ConversaComTimestamp[];
const meuIdAtual = meuId();
// Encontrar conversas com novas mensagens
// Obter ID do usuário logado de forma robusta
// Prioridade: usar query do Convex (mais confiável) > authStore
const usuarioLogado = currentUser?.data;
const perfilConvex = meuPerfilQuery?.data;
// Usar ID do Convex se disponível, caso contrário usar authStore
let meuId: string | null = null;
if (perfilConvex && perfilConvex._id) {
// Usar ID retornado pela query do Convex (mais confiável)
meuId = String(perfilConvex._id).trim();
} else if (usuarioLogado && usuarioLogado._id) {
// Fallback para authStore
meuId = String(usuarioLogado._id).trim();
}
if (!meuId) {
console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado:', {
currentUser: !!usuarioLogado,
currentUserId: usuarioLogado?._id,
convexPerfil: !!perfilConvex,
convexId: perfilConvex?._id
});
if (!meuIdAtual) {
console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado');
return;
}
// Log para debug (apenas em desenvolvimento)
if (import.meta.env.DEV) {
console.log('🔍 [ChatWidget] Usuário logado identificado:', {
id: meuId,
fonte: perfilConvex ? 'Convex Query' : 'CurrentUser',
nome: usuarioLogado?.nome || perfilConvex?.nome,
email: usuarioLogado?.email
});
}
conversas.forEach((conv) => {
if (!conv.ultimaMensagemTimestamp) return;
@@ -447,21 +453,8 @@
? String(conv.ultimaMensagemRemetenteId).trim()
: null;
// Log para debug da comparação (apenas em desenvolvimento)
if (import.meta.env.DEV && remetenteIdStr) {
const ehMinhaMensagem = remetenteIdStr === meuId;
if (ehMinhaMensagem) {
console.log('✅ [ChatWidget] Mensagem identificada como própria (ignorada):', {
conversaId: conv._id,
meuId,
remetenteId: remetenteIdStr,
mensagem: conv.ultimaMensagem?.substring(0, 50)
});
}
}
// Se a mensagem foi enviada pelo próprio usuário, ignorar completamente
if (remetenteIdStr && remetenteIdStr === meuId) {
if (remetenteIdStr && remetenteIdStr === meuIdAtual) {
// Mensagem enviada pelo próprio usuário - não tocar beep nem mostrar notificação
// Marcar como notificada para evitar processamento futuro
const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`;
@@ -482,14 +475,29 @@
const conversaIdStr = String(conv._id).trim();
const estaConversaEstaAberta = conversaAtivaId === conversaIdStr;
// Verificar se outra notificação já está ativa para esta mensagem
const notificacaoAtual = $notificacaoAtiva;
const jaTemNotificacaoAtiva =
notificacaoAtual &&
notificacaoAtual.conversaId === conversaIdStr &&
notificacaoAtual.mensagemId === mensagemId;
// Só mostrar notificação se:
// 1. O chat não está aberto OU
// 2. O chat está aberto mas não estamos vendo essa conversa específica
if (!isOpen || !estaConversaEstaAberta) {
// 3. E não há outra notificação ativa para esta mensagem
if ((!isOpen || !estaConversaEstaAberta) && !jaTemNotificacaoAtiva) {
// Marcar como notificada ANTES de mostrar notificação (evita duplicação)
mensagensNotificadasGlobal.add(mensagemId);
salvarMensagensNotificadasGlobal();
// Registrar notificação ativa no store global
notificacaoAtiva.set({
conversaId: conversaIdStr,
mensagemId,
componente: 'widget'
});
// Tocar som de notificação (apenas uma vez)
tocarSomNotificacaoGlobal();
@@ -501,13 +509,17 @@
};
showGlobalNotificationPopup = true;
// Ocultar popup após 5 segundos
// Ocultar popup após 5 segundos - garantir limpeza
if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout);
globalNotificationTimeout = null;
}
globalNotificationTimeout = setTimeout(() => {
showGlobalNotificationPopup = false;
globalNotificationMessage = null;
globalNotificationTimeout = null;
// Limpar notificação ativa do store
notificacaoAtiva.set(null);
}, 5000);
} else {
// Chat está aberto e estamos vendo essa conversa - marcar como visualizada
@@ -517,6 +529,14 @@
}
});
}
// Cleanup: limpar timeout quando o effect for desmontado
return () => {
if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout);
globalNotificationTimeout = null;
}
};
});
function handleToggle() {
@@ -575,6 +595,56 @@
maximizarChat();
}
// Handler para duplo clique no botão flutuante - abre e maximiza
function handleDoubleClick() {
// Marcar que estamos processando um duplo clique
isDoubleClicking = true;
// Se o chat estiver fechado ou minimizado, abrir e maximizar
if (!isOpen || isMinimized) {
abrirChat();
// Aguardar um pouco para garantir que o chat foi aberto antes de maximizar
setTimeout(() => {
if (position) {
// Salvar tamanho e posição atuais antes de maximizar
previousSize = { ...windowSize };
previousPosition = { ...position };
// Maximizar completamente
const winWidth =
windowDimensions.width ||
(typeof window !== 'undefined' ? window.innerWidth : DEFAULT_WIDTH);
const winHeight =
windowDimensions.height ||
(typeof window !== 'undefined' ? window.innerHeight : DEFAULT_HEIGHT);
windowSize = {
width: winWidth,
height: winHeight
};
position = {
x: 0,
y: 0
};
isMaximized = true;
saveSize();
ajustarPosicao();
maximizarChat();
}
// Resetar flag após processar
setTimeout(() => {
isDoubleClicking = false;
}, 300);
}, 50);
} else {
// Se já estiver aberto, apenas maximizar
handleMaximize();
setTimeout(() => {
isDoubleClicking = false;
}, 300);
}
}
// Funcionalidade de arrastar
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0 || !position) return; // Apenas botão esquerdo
@@ -611,6 +681,136 @@
// Não prevenir default para permitir clique funcionar se não houver movimento
}
// Handlers para gestos touch (swipe)
function handleTouchStart(e: TouchEvent) {
if (!position || e.touches.length !== 1) return;
const touch = e.touches[0];
touchStart = {
x: touch.clientX,
y: touch.clientY,
time: Date.now()
};
touchCurrent = { x: touch.clientX, y: touch.clientY };
isTouching = true;
isDragging = true;
dragStart = {
x: touch.clientX - position.x,
y: touch.clientY - position.y
};
hasMoved = false;
shouldPreventClick = false;
document.body.classList.add('dragging');
}
function handleTouchMove(e: TouchEvent) {
if (!isTouching || !touchStart || !position || e.touches.length !== 1) return;
const touch = e.touches[0];
touchCurrent = { x: touch.clientX, y: touch.clientY };
// Calcular velocidade do swipe
const deltaTime = Date.now() - touchStart.time;
const deltaX = touch.clientX - touchStart.x;
const deltaY = touch.clientY - touchStart.y;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (deltaTime > 0) {
swipeVelocity = distance / deltaTime; // pixels por ms
}
// Calcular nova posição
const newX = touch.clientX - dragStart.x;
const newY = touch.clientY - dragStart.y;
// Verificar se houve movimento significativo
const deltaXAbs = Math.abs(newX - position.x);
const deltaYAbs = Math.abs(newY - position.y);
if (deltaXAbs > dragThreshold || deltaYAbs > dragThreshold) {
hasMoved = true;
shouldPreventClick = true;
}
// Dimensões do widget
const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
const winWidth =
windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
const winHeight =
windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
const minX = -(widgetWidth - 100);
const maxX = Math.max(0, winWidth - 100);
const minY = -(widgetHeight - 100);
const maxY = Math.max(0, winHeight - 100);
position = {
x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(minY, Math.min(newY, maxY))
};
}
function handleTouchEnd(e: TouchEvent) {
if (!isTouching || !touchStart || !position) return;
const hadMoved = hasMoved;
// Aplicar momentum se houver velocidade suficiente
if (swipeVelocity > 0.5 && hadMoved) {
const deltaX = touchCurrent ? touchCurrent.x - touchStart.x : 0;
const deltaY = touchCurrent ? touchCurrent.y - touchStart.y : 0;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (distance > 10) {
// Aplicar momentum suave
const momentum = Math.min(swipeVelocity * 50, 200); // Limitar momentum
const angle = Math.atan2(deltaY, deltaX);
let momentumX = position.x + Math.cos(angle) * momentum;
let momentumY = position.y + Math.sin(angle) * momentum;
// Limitar dentro dos bounds
const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
const winWidth =
windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
const winHeight =
windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
const minX = -(widgetWidth - 100);
const maxX = Math.max(0, winWidth - 100);
const minY = -(widgetHeight - 100);
const maxY = Math.max(0, winHeight - 100);
momentumX = Math.max(minX, Math.min(momentumX, maxX));
momentumY = Math.max(minY, Math.min(momentumY, maxY));
position = { x: momentumX, y: momentumY };
isAnimating = true;
setTimeout(() => {
isAnimating = false;
ajustarPosicao();
}, 300);
}
} else {
ajustarPosicao();
}
isDragging = false;
isTouching = false;
touchStart = null;
touchCurrent = null;
swipeVelocity = 0;
document.body.classList.remove('dragging');
setTimeout(() => {
hasMoved = false;
shouldPreventClick = false;
}, 100);
savePosition();
}
function handleMouseMove(e: MouseEvent) {
if (isResizing) {
handleResizeMove(e);
@@ -745,10 +945,14 @@
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
window.addEventListener('touchmove', handleTouchMove, { passive: false });
window.addEventListener('touchend', handleTouchEnd);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
};
});
</script>
@@ -787,9 +991,16 @@
onmouseup={(e) => {
handleMouseUp(e);
}}
ontouchstart={handleTouchStart}
onclick={(e) => {
// Prevenir clique simples se estamos processando um duplo clique
if (isDoubleClicking) {
e.preventDefault();
e.stopPropagation();
return;
}
// Só executar toggle se não houve movimento durante o arrastar
if (!shouldPreventClick && !hasMoved) {
if (!shouldPreventClick && !hasMoved && !isTouching) {
handleToggle();
} else {
// Prevenir clique se houve movimento
@@ -798,30 +1009,54 @@
shouldPreventClick = false; // Resetar após prevenir
}
}}
aria-label="Abrir chat"
ondblclick={(e) => {
// Prevenir que o clique simples seja executado após o duplo clique
e.preventDefault();
e.stopPropagation();
// Executar maximização apenas se não houve movimento
if (!shouldPreventClick && !hasMoved && !isTouching) {
handleDoubleClick();
}
}}
aria-label="Abrir chat (duplo clique para maximizar)"
>
<!-- Anel de brilho rotativo -->
<!-- Anel de brilho rotativo melhorado com múltiplas camadas -->
<div
class="absolute inset-0 rounded-full opacity-0 transition-opacity duration-500 group-hover:opacity-100"
style="background: conic-gradient(from 0deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%); animation: rotate 3s linear infinite;"
style="background: conic-gradient(from 0deg, transparent 0%, rgba(255,255,255,0.4) 25%, rgba(255,255,255,0.6) 50%, rgba(255,255,255,0.4) 75%, transparent 100%); animation: rotate 3s linear infinite; transform-origin: center;"
></div>
<!-- Segunda camada para efeito de profundidade -->
<div
class="absolute inset-0 cursor-pointer rounded-lg opacity-0 transition-opacity duration-700 group-hover:opacity-60"
style="background: conic-gradient(from 180deg, transparent 0%, rgba(255,255,255,0.2) 30%, transparent 60%); animation: rotate 4s linear infinite reverse; transform-origin: center;"
onclick={(e) => {
// Propagar o clique para o elemento pai
e.stopPropagation();
if (!isDoubleClicking && !shouldPreventClick && !hasMoved && !isTouching) {
handleToggle();
}
}}
ondblclick={(e) => {
e.stopPropagation();
if (!shouldPreventClick && !hasMoved && !isTouching) {
handleDoubleClick();
}
}}
></div>
<!-- Efeito de brilho pulsante durante arrasto -->
{#if isDragging || isTouching}
<div
class="absolute inset-0 animate-pulse rounded-full opacity-30"
style="background: radial-gradient(circle at center, rgba(255,255,255,0.4) 0%, transparent 70%); animation: pulse-glow 1.5s ease-in-out infinite;"
></div>
{/if}
<!-- Ícone de chat moderno com efeito 3D -->
<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="relative z-10 h-7 w-7 text-white transition-all duration-500 group-hover:scale-110"
<MessageSquare
class="relative z-10 h-10 w-10 text-white transition-all duration-500 group-hover:scale-110"
style="filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));"
>
<path
d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"
/>
</svg>
strokeWidth={2}
/>
<!-- Badge ULTRA PREMIUM com gradiente e brilho -->
{#if count?.data && count.data > 0}
@@ -914,26 +1149,16 @@
{#if avatarUrlDoUsuario()}
<img
src={avatarUrlDoUsuario()}
alt={currentUser?.data?.nome || 'Usuário'}
alt={meuPerfilQuery?.data?.nome || currentUser?.data?.nome || 'Usuário'}
class="h-full w-full object-cover"
/>
{:else}
<!-- Fallback: ícone de chat genérico -->
<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"
<MessageSquare
class="h-5 w-5"
style="filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
<line x1="9" y1="10" x2="15" y2="10" />
<line x1="9" y1="14" x2="13" y2="14" />
</svg>
strokeWidth={2}
/>
{/if}
</div>
<span
@@ -955,19 +1180,11 @@
<div
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/20"
></div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
<Minus
class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110"
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
>
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
strokeWidth={2.5}
/>
</button>
<!-- Botão maximizar MODERNO -->
@@ -981,21 +1198,11 @@
<div
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/20"
></div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
<Maximize2
class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110"
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
>
<path
d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"
/>
</svg>
strokeWidth={2.5}
/>
</button>
<!-- Botão fechar MODERNO -->
@@ -1009,20 +1216,11 @@
<div
class="absolute inset-0 bg-red-500/0 transition-colors duration-300 group-hover:bg-red-500/30"
></div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
<X
class="relative z-10 h-5 w-5 transition-all duration-300 group-hover:scale-110 group-hover:rotate-90"
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
strokeWidth={2.5}
/>
</button>
</div>
</div>
@@ -1117,6 +1315,8 @@
</div>
{/if}
<!-- Indicador de Conexão -->
<!-- Popup Global de Notificação de Nova Mensagem (quando chat está fechado/minimizado) -->
{#if showGlobalNotificationPopup && globalNotificationMessage}
{@const notificationMsg = globalNotificationMessage}
@@ -1157,20 +1357,7 @@
>
<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">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="text-primary h-5 w-5"
>
<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>
<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">
@@ -1194,16 +1381,7 @@
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="h-4 w-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
<X class="h-4 w-4" strokeWidth={2} />
</button>
</div>
</div>
@@ -1249,7 +1427,7 @@
}
}
/* Rotação para anel de brilho */
/* Rotação para anel de brilho - suavizada */
@keyframes rotate {
from {
transform: rotate(0deg);
@@ -1259,6 +1437,19 @@
}
}
/* Efeito de pulso de brilho durante arrasto */
@keyframes pulse-glow {
0%,
100% {
opacity: 0.2;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(1.05);
}
}
/* Efeito shimmer para o header */
@keyframes shimmer {
0% {

View File

@@ -1,31 +1,35 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import MessageList from './MessageList.svelte';
import MessageInput from './MessageInput.svelte';
import UserStatusBadge from './UserStatusBadge.svelte';
import UserAvatar from './UserAvatar.svelte';
import ScheduleMessageModal from './ScheduleMessageModal.svelte';
import SalaReuniaoManager from './SalaReuniaoManager.svelte';
import CallWindow from '../call/CallWindow.svelte';
import ErrorModal from '../ErrorModal.svelte';
import E2EManagementModal from './E2EManagementModal.svelte';
//import { getAvatarUrl } from '$lib/utils/avatarGenerator';
import { browser } from '$app/environment';
import { traduzirErro } from '$lib/utils/erroHelpers';
import {
ArrowLeft,
Bell,
Clock,
LogOut,
MoreVertical,
Phone,
Users,
Phone,
Video,
X,
XCircle
Search,
Lock,
MoreVertical,
XCircle,
X
} from 'lucide-svelte';
//import { getAvatarUrl } from '$lib/utils/avatarGenerator';
import { browser } from '$app/environment';
import { voltarParaLista } from '$lib/stores/chatStore';
import { traduzirErro } from '$lib/utils/erroHelpers';
import CallWindow from '../call/CallWindow.svelte';
import ErrorModal from '../ErrorModal.svelte';
import MessageInput from './MessageInput.svelte';
import MessageList from './MessageList.svelte';
import SalaReuniaoManager from './SalaReuniaoManager.svelte';
import ScheduleMessageModal from './ScheduleMessageModal.svelte';
import UserAvatar from './UserAvatar.svelte';
import UserStatusBadge from './UserStatusBadge.svelte';
import { useConvexClient, useQuery } from 'convex-svelte';
//import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
@@ -43,16 +47,18 @@
let showSalaManager = $state(false);
let showAdminMenu = $state(false);
let showNotificacaoModal = $state(false);
let showE2EModal = $state(false);
let iniciandoChamada = $state(false);
let chamadaAtiva = $state<Id<'chamadas'> | null>(null);
// Estados para modal de erro
let showSearch = $state(false);
let searchQuery = $state('');
let searchResults = $state<Array<unknown | undefined>>([]);
let searching = $state(false);
let selectedSearchResult = $state<number>(-1);
let showErrorModal = $state(false);
let errorTitle = $state('Erro');
let errorMessage = $state('');
let errorInstructions = $state<string | undefined>(undefined);
let errorDetails = $state<string | undefined>(undefined);
const chamadaAtivaQuery = useQuery(api.chamadas.obterChamadaAtiva, {
conversaId: conversaId as Id<'conversas'>
});
@@ -63,7 +69,12 @@
conversaId: conversaId as Id<'conversas'>
});
let conversa = $derived(() => {
// Verificar se a conversa tem criptografia E2E habilitada
const temCriptografiaE2E = useQuery(api.chat.verificarCriptografiaE2E, {
conversaId: conversaId as Id<'conversas'>
});
const conversa = $derived(() => {
console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId);
console.log('📋 [ChatWindow] Conversas disponíveis:', conversas?.data);
@@ -184,9 +195,7 @@
}
// Buscar informações da chamada para obter roomName
const chamadaInfo = await client.query(api.chamadas.obterChamada, {
chamadaId
});
const chamadaInfo = await client.query(api.chamadas.obterChamada, { chamadaId });
if (!chamadaInfo) {
throw new Error('Chamada não encontrada');
@@ -277,6 +286,7 @@
fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl}
nome={conversa()?.outroUsuario?.nome || 'Usuário'}
size="md"
userId={conversa()?.outroUsuario?._id}
/>
{:else}
<div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-full text-xl">
@@ -291,9 +301,29 @@
</div>
<div class="min-w-0 flex-1">
<p class="text-base-content truncate font-semibold">
{getNomeConversa()}
</p>
<!-- Nome da conversa com indicador de criptografia E2E -->
<div class="flex items-center gap-2">
<p class="text-base-content truncate font-semibold">
{getNomeConversa()}
</p>
{#if temCriptografiaE2E?.data}
<button
type="button"
class="shrink-0"
onclick={(e) => {
e.stopPropagation();
showE2EModal = true;
}}
title="Gerenciar criptografia end-to-end (E2E)"
aria-label="Gerenciar criptografia E2E"
>
<Lock
class="text-success hover:text-success/80 h-4 w-4 transition-colors"
strokeWidth={2}
/>
</button>
{/if}
</div>
{#if getStatusMensagem()}
<p class="text-base-content/60 truncate text-xs">
{getStatusMensagem()}
@@ -316,7 +346,7 @@
{conversa()?.participantesInfo?.length || 0}
{conversa()?.participantesInfo?.length === 1 ? 'participante' : 'participantes'}
</p>
{#if conversa()?.participantesInfo && conversa()?.participantesInfo.length > 0}
{#if conversa()?.participantesInfo && conversa()?.participantesInfo?.length > 0}
<div class="flex items-center gap-2">
<div class="flex -space-x-2">
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
@@ -362,6 +392,32 @@
<!-- Botões de ação -->
<div class="flex items-center gap-1">
<!-- Botão de Busca -->
<button
type="button"
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
style="background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.2);"
onclick={(e) => {
e.stopPropagation();
showSearch = !showSearch;
if (!showSearch) {
searchQuery = '';
searchResults = [];
}
}}
aria-label="Buscar mensagens"
title="Buscar mensagens"
aria-expanded={showSearch}
>
<div
class="absolute inset-0 bg-green-500/0 transition-colors duration-300 group-hover:bg-green-500/10"
></div>
<Search
class="relative z-10 h-5 w-5 text-green-500 transition-transform group-hover:scale-110"
strokeWidth={2}
/>
</button>
<!-- Botões de Chamada -->
{#if !chamadaAtual && !chamadaAtiva}
<div class="dropdown dropdown-end">
@@ -376,7 +432,7 @@
aria-label="Ligação de áudio"
title="Iniciar ligação de áudio"
>
<Phone class="h-5 w-5" strokeWidth={2} />
<Phone class="h-5 w-5 text-white" strokeWidth={2} />
</button>
<ul
tabindex="0"
@@ -422,7 +478,7 @@
aria-label="Ligação de vídeo"
title="Iniciar ligação de vídeo"
>
<Video class="h-5 w-5" strokeWidth={2} />
<Video class="h-5 w-5 text-white" strokeWidth={2} />
</button>
<ul
tabindex="0"
@@ -577,6 +633,27 @@
</div>
{/if}
<!-- Botão Gerenciar E2E -->
<button
type="button"
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
style="background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.2);"
onclick={(e) => {
e.stopPropagation();
showE2EModal = true;
}}
aria-label="Gerenciar criptografia E2E"
title="Gerenciar criptografia end-to-end"
>
<div
class="absolute inset-0 bg-green-500/0 transition-colors duration-300 group-hover:bg-green-500/10"
></div>
<Lock
class="relative z-10 h-5 w-5 text-green-500 transition-transform group-hover:scale-110"
strokeWidth={2}
/>
</button>
<!-- Botão Agendar MODERNO -->
<button
type="button"
@@ -597,6 +674,113 @@
</div>
</div>
<!-- Barra de Busca (quando ativa) -->
{#if showSearch}
<div
class="border-base-300 bg-base-200 flex items-center gap-2 border-b px-4 py-2"
onclick={(e) => e.stopPropagation()}
>
<Search class="text-base-content/50 h-4 w-4" strokeWidth={2} />
<input
type="text"
placeholder="Buscar mensagens nesta conversa..."
class="input input-sm input-bordered flex-1"
bind:value={searchQuery}
onkeydown={handleSearchKeyDown}
aria-label="Buscar mensagens"
aria-describedby="search-results-info"
/>
<button
type="button"
class="btn btn-sm btn-ghost"
onclick={() => {
showSearch = false;
searchQuery = '';
searchResults = [];
}}
aria-label="Fechar busca"
>
<X class="h-4 w-4" />
</button>
</div>
<!-- Resultados da Busca -->
{#if searchQuery.trim().length >= 2}
<div
class="border-base-300 bg-base-200 max-h-64 overflow-y-auto border-b"
role="listbox"
aria-label="Resultados da busca"
id="search-results"
>
{#if searching}
<div class="flex items-center justify-center p-4">
<span class="loading loading-spinner loading-sm"></span>
<span class="text-base-content/50 ml-2 text-sm">Buscando...</span>
</div>
{:else if searchResults.length > 0}
<p id="search-results-info" class="sr-only">
{searchResults.length} resultado{searchResults.length !== 1 ? 's' : ''} encontrado{searchResults.length !==
1
? 's'
: ''}
</p>
{#each searchResults as resultado, index (resultado._id)}
<button
type="button"
class="hover:bg-base-300 flex w-full items-start gap-3 px-4 py-3 text-left transition-colors {index ===
selectedSearchResult
? 'bg-primary/10'
: ''}"
onclick={() => {
window.dispatchEvent(
new CustomEvent('scrollToMessage', {
detail: { mensagemId: resultado._id }
})
);
showSearch = false;
searchQuery = '';
}}
role="option"
aria-selected={index === selectedSearchResult}
aria-label="Mensagem de {resultado.remetente?.nome || 'Usuário'}"
>
<div
class="bg-primary/20 flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full"
>
{#if resultado.remetente?.fotoPerfilUrl}
<img
src={resultado.remetente.fotoPerfilUrl}
alt={resultado.remetente.nome}
class="h-full w-full object-cover"
/>
{:else}
<span class="text-xs font-semibold">
{resultado.remetente?.nome?.charAt(0).toUpperCase() || 'U'}
</span>
{/if}
</div>
<div class="min-w-0 flex-1">
<p class="text-base-content mb-1 text-xs font-semibold">
{resultado.remetente?.nome || 'Usuário'}
</p>
<p class="text-base-content/70 line-clamp-2 text-xs">
{resultado.conteudo}
</p>
<p class="text-base-content/50 mt-1 text-xs">
{new Date(resultado.enviadaEm).toLocaleString('pt-BR')}
</p>
</div>
</button>
{/each}
{:else if searchQuery.trim().length >= 2}
<div class="p-4 text-center">
<p class="text-base-content/50 text-sm">Nenhuma mensagem encontrada</p>
</div>
{/if}
</div>
{/if}
{/if}
<!-- Mensagens -->
<div class="min-h-0 flex-1 overflow-hidden">
<MessageList conversaId={conversaId as Id<'conversas'>} />
@@ -614,6 +798,14 @@
conversaId={conversaId as Id<'conversas'>}
onClose={() => (showScheduleModal = false)}
/>
<!-- Modal de Gerenciamento E2E -->
{#if showE2EModal}
<E2EManagementModal
conversaId={conversaId as Id<'conversas'>}
onClose={() => (showE2EModal = false)}
/>
{/if}
{/if}
<!-- Modal de Gerenciamento de Sala -->

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { onMount } from 'svelte';
import { Wifi, WifiOff, AlertCircle } from 'lucide-svelte';
const client = useConvexClient();
let isOnline = $state(true);
let convexConnected = $state(true);
let showIndicator = $state(false);
// Detectar status de conexão com internet
function updateOnlineStatus() {
isOnline = navigator.onLine;
showIndicator = !isOnline || !convexConnected;
}
// Detectar status de conexão com Convex
function updateConvexStatus() {
// Verificar se o client está conectado
// O Convex client expõe o status de conexão
const connectionState = (client as any).connectionState?.();
convexConnected = connectionState === 'Connected' || connectionState === 'Connecting';
showIndicator = !isOnline || !convexConnected;
}
onMount(() => {
// Verificar status inicial
updateOnlineStatus();
updateConvexStatus();
// Listeners para mudanças de conexão
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
// Verificar status do Convex periodicamente
const interval = setInterval(() => {
updateConvexStatus();
}, 5000);
return () => {
window.removeEventListener('online', updateOnlineStatus);
window.removeEventListener('offline', updateOnlineStatus);
clearInterval(interval);
};
});
// Observar mudanças no client do Convex
$effect(() => {
// Tentar acessar o estado de conexão do Convex
try {
const connectionState = (client as any).connectionState?.();
if (connectionState !== undefined) {
convexConnected = connectionState === 'Connected' || connectionState === 'Connecting';
showIndicator = !isOnline || !convexConnected;
}
} catch {
// Se não conseguir acessar, assumir conectado
convexConnected = true;
}
});
</script>
{#if showIndicator}
<div
class="fixed bottom-4 left-4 z-[99998] flex items-center gap-2 rounded-lg px-3 py-2 shadow-lg transition-all"
class:bg-error={!isOnline || !convexConnected}
class:bg-warning={isOnline && !convexConnected}
class:text-white={!isOnline || !convexConnected}
role="status"
aria-live="polite"
aria-label={!isOnline
? 'Sem conexão com a internet'
: !convexConnected
? 'Reconectando ao servidor'
: 'Conectado'}
>
{#if !isOnline}
<WifiOff class="h-4 w-4" />
<span class="text-sm font-medium">Sem conexão</span>
{:else if !convexConnected}
<AlertCircle class="h-4 w-4" />
<span class="text-sm font-medium">Reconectando...</span>
{:else}
<Wifi class="h-4 w-4" />
<span class="text-sm font-medium">Conectado</span>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,267 @@
<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 { Lock, X, RefreshCw, Shield, AlertTriangle, CheckCircle } from 'lucide-svelte';
import {
generateEncryptionKey,
exportKey,
storeEncryptionKey,
hasEncryptionKey,
removeStoredEncryptionKey
} from '$lib/utils/e2eEncryption';
import { armazenarChaveCriptografia, removerChaveCriptografia } from '$lib/stores/chatStore';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
interface Props {
conversaId: Id<'conversas'>;
onClose: () => void;
}
let { conversaId, onClose }: Props = $props();
const client = useConvexClient();
const temCriptografiaE2E = useQuery(api.chat.verificarCriptografiaE2E, { conversaId });
const chaveAtual = useQuery(api.chat.obterChaveCriptografia, { conversaId });
const conversa = useQuery(api.chat.listarConversas, {});
let ativando = $state(false);
let regenerando = $state(false);
let desativando = $state(false);
// Obter informações da conversa
const conversaInfo = $derived(() => {
if (!conversa?.data || !Array.isArray(conversa.data)) return null;
return conversa.data.find((c: { _id: string }) => c._id === conversaId) || null;
});
async function ativarE2E() {
if (!confirm('Deseja ativar criptografia end-to-end para esta conversa?\n\nTodas as mensagens futuras serão criptografadas.')) {
return;
}
try {
ativando = true;
// Gerar nova chave de criptografia
const encryptionKey = await generateEncryptionKey();
const keyData = await exportKey(encryptionKey.key);
// Armazenar localmente
storeEncryptionKey(conversaId, keyData, encryptionKey.keyId);
armazenarChaveCriptografia(conversaId, encryptionKey.key);
// Compartilhar chave com outros participantes
await client.mutation(api.chat.compartilharChaveCriptografia, {
conversaId,
chaveCompartilhada: keyData, // Em produção, isso deveria ser criptografado com chave pública de cada participante
keyId: encryptionKey.keyId
});
alert('Criptografia E2E ativada com sucesso!');
} catch (error) {
console.error('Erro ao ativar E2E:', error);
alert('Erro ao ativar criptografia E2E');
} finally {
ativando = false;
}
}
async function regenerarChave() {
if (!confirm('Deseja regenerar a chave de criptografia?\n\nAs mensagens antigas continuarão legíveis, mas novas mensagens usarão a nova chave.')) {
return;
}
try {
regenerando = true;
// Gerar nova chave
const encryptionKey = await generateEncryptionKey();
const keyData = await exportKey(encryptionKey.key);
// Atualizar chave localmente
storeEncryptionKey(conversaId, keyData, encryptionKey.keyId);
armazenarChaveCriptografia(conversaId, encryptionKey.key);
// Compartilhar nova chave (desativa chaves antigas automaticamente)
await client.mutation(api.chat.compartilharChaveCriptografia, {
conversaId,
chaveCompartilhada: keyData,
keyId: encryptionKey.keyId
});
alert('Chave regenerada com sucesso!');
} catch (error) {
console.error('Erro ao regenerar chave:', error);
alert('Erro ao regenerar chave');
} finally {
regenerando = false;
}
}
async function desativarE2E() {
if (!confirm('Deseja desativar criptografia end-to-end para esta conversa?\n\nAs mensagens antigas continuarão criptografadas, mas novas mensagens não serão mais criptografadas.')) {
return;
}
try {
desativando = true;
// Remover chave localmente
removeStoredEncryptionKey(conversaId);
removerChaveCriptografia(conversaId);
// Desativar chave no servidor (marcar como inativa)
// Nota: Não removemos a chave do servidor, apenas a marcamos como inativa
// Isso permite que mensagens antigas ainda possam ser descriptografadas
if (chaveAtual?.data) {
// A mutation compartilharChaveCriptografia já desativa chaves antigas
// Mas precisamos de uma mutation específica para desativar completamente
// Por enquanto, vamos apenas remover localmente
alert('Criptografia E2E desativada localmente. As mensagens antigas ainda podem ser descriptografadas se você tiver a chave.');
}
} catch (error) {
console.error('Erro ao desativar E2E:', error);
alert('Erro ao desativar criptografia E2E');
} finally {
desativando = false;
}
}
function formatarData(timestamp: number): string {
try {
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR });
} catch {
return 'Data inválida';
}
}
</script>
<div
class="modal modal-open"
role="dialog"
aria-labelledby="modal-title"
aria-modal="true"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
class="modal-box max-w-2xl"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
<h2 id="modal-title" class="flex items-center gap-2 text-xl font-bold">
<Shield class="text-primary h-5 w-5" />
Criptografia End-to-End (E2E)
</h2>
<button type="button" class="btn btn-sm btn-circle" onclick={onClose} aria-label="Fechar">
<X class="h-5 w-5" />
</button>
</div>
<!-- Content -->
<div class="flex-1 space-y-6 overflow-y-auto p-6">
<!-- Status da Criptografia -->
<div class="card bg-base-200">
<div class="card-body">
<div class="flex items-center gap-3">
{#if temCriptografiaE2E?.data}
<CheckCircle class="text-success h-6 w-6 shrink-0" />
<div class="flex-1">
<h3 class="card-title text-lg text-success">Criptografia E2E Ativa</h3>
<p class="text-sm text-base-content/70">
Suas mensagens estão protegidas com criptografia end-to-end
</p>
</div>
{:else}
<AlertTriangle class="text-warning h-6 w-6 shrink-0" />
<div class="flex-1">
<h3 class="card-title text-lg text-warning">Criptografia E2E Desativada</h3>
<p class="text-sm text-base-content/70">
Suas mensagens não estão criptografadas
</p>
</div>
{/if}
</div>
</div>
</div>
<!-- Informações da Chave -->
{#if temCriptografiaE2E?.data && chaveAtual?.data}
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Informações da Chave</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-base-content/70">ID da Chave:</span>
<span class="font-mono text-xs">{chaveAtual.data.keyId.substring(0, 16)}...</span>
</div>
<div class="flex justify-between">
<span class="text-base-content/70">Criada em:</span>
<span>{formatarData(chaveAtual.data.criadoEm)}</span>
</div>
<div class="flex justify-between">
<span class="text-base-content/70">Chave local:</span>
<span class="text-success">
{hasEncryptionKey(conversaId) ? '✓ Armazenada' : '✗ Não encontrada'}
</span>
</div>
</div>
</div>
</div>
{/if}
<!-- Informações sobre E2E -->
<div class="alert alert-info">
<Lock class="h-5 w-5" />
<div class="text-sm">
<p class="font-semibold">Como funciona a criptografia E2E?</p>
<ul class="mt-2 list-inside list-disc space-y-1 text-xs">
<li>Suas mensagens são criptografadas no seu dispositivo antes de serem enviadas</li>
<li>Apenas você e os participantes da conversa podem descriptografar as mensagens</li>
<li>O servidor não consegue ler o conteúdo das mensagens criptografadas</li>
<li>Mensagens antigas continuam legíveis mesmo após regenerar a chave</li>
</ul>
</div>
</div>
<!-- Ações -->
<div class="flex flex-col gap-3">
{#if temCriptografiaE2E?.data}
<button
type="button"
class="btn btn-warning"
onclick={regenerarChave}
disabled={regenerando || ativando || desativando}
>
<RefreshCw class="h-4 w-4 {regenerando ? 'animate-spin' : ''}" />
{regenerando ? 'Regenerando...' : 'Regenerar Chave'}
</button>
<button
type="button"
class="btn btn-error"
onclick={desativarE2E}
disabled={regenerando || ativando || desativando}
>
<X class="h-4 w-4" />
{desativando ? 'Desativando...' : 'Desativar E2E'}
</button>
{:else}
<button
type="button"
class="btn btn-primary"
onclick={ativarE2E}
disabled={regenerando || ativando || desativando}
>
<Lock class="h-4 w-4" />
{ativando ? 'Ativando...' : 'Ativar Criptografia E2E'}
</button>
{/if}
</div>
</div>
</div>
</div>

View File

@@ -1,9 +1,18 @@
<script lang="ts">
import { useConvexClient, useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { Paperclip, Send, Smile } from 'lucide-svelte';
import { onMount } from 'svelte';
import { Paperclip, Smile, Send } from 'lucide-svelte';
import {
encryptMessage,
encryptFile,
loadEncryptionKey,
storeEncryptionKey,
exportKey,
type EncryptedMessage
} from '$lib/utils/e2eEncryption';
import { obterChaveCriptografia, armazenarChaveCriptografia } from '$lib/stores/chatStore';
interface Props {
conversaId: Id<'conversas'>;
@@ -23,15 +32,50 @@
participantesInfo?: ParticipanteInfo[];
};
const { conversaId }: Props = $props();
let { conversaId }: Props = $props();
const client = useConvexClient();
const conversas = useQuery(api.chat.listarConversas, {});
// Verificar se a conversa tem criptografia E2E habilitada
const temCriptografiaE2E = useQuery(api.chat.verificarCriptografiaE2E, { conversaId });
// Constantes de validação
const MAX_MENSAGEM_LENGTH = 5000; // Limite de caracteres por mensagem
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
// Tipos de arquivo permitidos
const TIPOS_PERMITIDOS = {
imagens: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'],
documentos: [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain',
'text/csv'
],
arquivos: [
'application/zip',
'application/x-rar-compressed',
'application/x-7z-compressed',
'application/x-tar',
'application/gzip'
]
};
const TODOS_TIPOS_PERMITIDOS = [
...TIPOS_PERMITIDOS.imagens,
...TIPOS_PERMITIDOS.documentos,
...TIPOS_PERMITIDOS.arquivos
];
let mensagem = $state('');
let textarea: HTMLTextAreaElement;
let enviando = $state(false);
let uploadingFile = $state(false);
let uploadProgress = $state(0); // Progresso do upload (0-100)
let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null;
let showEmojiPicker = $state(false);
let mensagemRespondendo: {
@@ -42,6 +86,8 @@
let showMentionsDropdown = $state(false);
let mentionQuery = $state('');
let mentionStartPos = $state(0);
let selectedMentionIndex = $state(0); // Índice do participante selecionado no dropdown
let mensagemMuitoLonga = $state(false);
// Emojis mais usados
const emojis = [
@@ -106,20 +152,20 @@
}
// Obter conversa atual
let conversa = $derived((): ConversaComParticipantes | null => {
const conversa = $derived((): ConversaComParticipantes | null => {
if (!conversas?.data) return null;
return (conversas.data as ConversaComParticipantes[]).find((c) => c._id === conversaId) || null;
});
// Obter participantes para menções (apenas grupos e salas)
let participantesParaMencoes = $derived((): ParticipanteInfo[] => {
const participantesParaMencoes = $derived((): ParticipanteInfo[] => {
const c = conversa();
if (!c || (c.tipo !== 'grupo' && c.tipo !== 'sala_reuniao')) return [];
return c.participantesInfo || [];
});
// Filtrar participantes para dropdown de menções
let participantesFiltrados = $derived((): ParticipanteInfo[] => {
const participantesFiltrados = $derived((): ParticipanteInfo[] => {
if (!mentionQuery.trim()) return participantesParaMencoes().slice(0, 5);
const query = mentionQuery.toLowerCase();
return participantesParaMencoes()
@@ -134,6 +180,19 @@
// Auto-resize do textarea e detectar menções
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
// Validar tamanho da mensagem
if (mensagem.length > MAX_MENSAGEM_LENGTH) {
mensagemMuitoLonga = true;
// Limitar ao tamanho máximo
mensagem = mensagem.substring(0, MAX_MENSAGEM_LENGTH);
if (textarea) {
textarea.value = mensagem;
}
} else {
mensagemMuitoLonga = false;
}
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
@@ -160,11 +219,13 @@
// Indicador de digitação (debounce de 1s)
if (digitacaoTimeout) {
clearTimeout(digitacaoTimeout);
digitacaoTimeout = null;
}
digitacaoTimeout = setTimeout(() => {
if (mensagem.trim()) {
client.mutation(api.chat.indicarDigitacao, { conversaId });
}
digitacaoTimeout = null;
}, 1000);
}
@@ -175,6 +236,7 @@
mensagem = antes + `@${nome} ` + depois;
showMentionsDropdown = false;
mentionQuery = '';
selectedMentionIndex = 0; // Resetar índice selecionado
if (textarea) {
textarea.focus();
const newPos = antes.length + nome.length + 2;
@@ -188,6 +250,12 @@
const texto = mensagem.trim();
if (!texto || enviando) return;
// Validar tamanho antes de enviar
if (texto.length > MAX_MENSAGEM_LENGTH) {
alert(`Mensagem muito longa. O limite é de ${MAX_MENSAGEM_LENGTH} caracteres.`);
return;
}
// Extrair menções do texto (@nome)
const mencoesIds: Id<'usuarios'>[] = [];
const mentionRegex = /@(\w+)/g;
@@ -202,10 +270,70 @@
}
}
// Verificar se a conversa tem criptografia E2E e criptografar mensagem se necessário
const conversaTemE2E = temCriptografiaE2E?.data ?? false;
let conteudoParaEnviar = texto;
let criptografado = false;
let iv: string | undefined;
let keyId: string | undefined;
if (conversaTemE2E) {
try {
// Tentar obter chave do store primeiro
let encryptionKey = obterChaveCriptografia(conversaId);
// Se não estiver no store, tentar carregar do localStorage
if (!encryptionKey) {
encryptionKey = await loadEncryptionKey(conversaId);
if (encryptionKey) {
armazenarChaveCriptografia(conversaId, encryptionKey);
}
}
// Se ainda não tiver chave, tentar obter do servidor
if (!encryptionKey) {
const chaveDoServidor = await client.query(api.chat.obterChaveCriptografia, {
conversaId
});
if (chaveDoServidor?.chaveCompartilhada) {
// Importar chave do servidor (assumindo que está em formato exportado)
// Nota: Em produção, a chave do servidor deve ser criptografada com chave pública do usuário
// Por enquanto, vamos assumir que a chave já está descriptografada no cliente
const { importKey } = await import('$lib/utils/e2eEncryption');
encryptionKey = await importKey(chaveDoServidor.chaveCompartilhada);
// Armazenar chave localmente
const keyData = await exportKey(encryptionKey);
storeEncryptionKey(conversaId, keyData, chaveDoServidor.keyId);
armazenarChaveCriptografia(conversaId, encryptionKey);
}
}
if (encryptionKey) {
// Criptografar mensagem
const encrypted: EncryptedMessage = await encryptMessage(texto, encryptionKey);
conteudoParaEnviar = encrypted.encryptedContent;
iv = encrypted.iv;
keyId = encrypted.keyId;
criptografado = true;
} else {
console.warn(
'⚠️ [MessageInput] Criptografia E2E habilitada mas chave não encontrada. Enviando sem criptografia.'
);
}
} catch (error) {
console.error('❌ [MessageInput] Erro ao criptografar mensagem:', error);
alert('Erro ao criptografar mensagem. Tentando enviar sem criptografia...');
// Continuar sem criptografia em caso de erro
}
}
console.log('📤 [MessageInput] Enviando mensagem:', {
conversaId,
conteudo: texto,
conteudo: criptografado ? '[CRIPTOGRAFADO]' : texto,
tipo: 'texto',
criptografado,
respostaPara: mensagemRespondendo?.id,
mencoes: mencoesIds
});
@@ -214,10 +342,13 @@
enviando = true;
const result = await client.mutation(api.chat.enviarMensagem, {
conversaId,
conteudo: texto,
conteudo: conteudoParaEnviar,
tipo: 'texto',
respostaPara: mensagemRespondendo?.id,
mencoes: mencoesIds.length > 0 ? mencoesIds : undefined
mencoes: mencoesIds.length > 0 ? mencoesIds : undefined,
criptografado: criptografado ? true : undefined,
iv: iv,
keyId: keyId
});
console.log('✅ [MessageInput] Mensagem enviada com sucesso! ID:', result);
@@ -253,7 +384,7 @@
const customEvent = e as CustomEvent<{ mensagemId: Id<'mensagens'> }>;
// Buscar informações da mensagem para exibir preview
client.query(api.chat.obterMensagens, { conversaId, limit: 100 }).then((mensagens) => {
const msg = (mensagens as MensagemComRemetente[]).find(
const msg = (mensagens as unknown as { mensagens: MensagemComRemetente[] }).mensagens.find(
(m) => m._id === customEvent.detail.mensagemId
);
if (msg) {
@@ -270,22 +401,43 @@
window.addEventListener('responderMensagem', handler);
return () => {
window.removeEventListener('responderMensagem', handler);
// Limpar timeout de digitação ao desmontar
if (digitacaoTimeout) {
clearTimeout(digitacaoTimeout);
digitacaoTimeout = null;
}
};
});
function handleKeyDown(e: KeyboardEvent) {
// Navegar dropdown de menções
if (showMentionsDropdown && participantesFiltrados().length > 0) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
const participantes = participantesFiltrados();
if (e.key === 'ArrowDown') {
e.preventDefault();
// Implementação simples: selecionar primeiro participante
if (e.key === 'Enter') {
inserirMencao(participantesFiltrados()[0]);
selectedMentionIndex = Math.min(selectedMentionIndex + 1, participantes.length - 1);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
selectedMentionIndex = Math.max(selectedMentionIndex - 1, 0);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
if (participantes[selectedMentionIndex]) {
inserirMencao(participantes[selectedMentionIndex]);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
showMentionsDropdown = false;
selectedMentionIndex = 0;
return;
}
}
@@ -302,43 +454,175 @@
const file = input.files?.[0];
if (!file) return;
// Validar tamanho (max 10MB)
if (file.size > 10 * 1024 * 1024) {
alert('Arquivo muito grande. O tamanho máximo é 10MB.');
// Validar tamanho
if (file.size > MAX_FILE_SIZE) {
alert(
`Arquivo muito grande. O tamanho máximo é ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB.`
);
input.value = '';
return;
}
// Validar tipo de arquivo
if (!TODOS_TIPOS_PERMITIDOS.includes(file.type)) {
alert(
`Tipo de arquivo não permitido. Tipos aceitos:\n- Imagens: JPEG, PNG, GIF, WebP, SVG\n- Documentos: PDF, Word, Excel, PowerPoint, TXT, CSV\n- Arquivos: ZIP, RAR, 7Z, TAR, GZIP`
);
input.value = '';
return;
}
// Validar extensão do arquivo (segurança adicional)
const extensao = file.name.split('.').pop()?.toLowerCase();
const extensoesPermitidas = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'svg',
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'csv',
'zip',
'rar',
'7z',
'tar',
'gz'
];
if (extensao && !extensoesPermitidas.includes(extensao)) {
alert(`Extensão de arquivo não permitida: .${extensao}`);
input.value = '';
return;
}
// Sanitizar nome do arquivo (remover caracteres perigosos)
const nomeSanitizado = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
// Verificar se a conversa tem criptografia E2E e criptografar arquivo se necessário
const conversaTemE2E = temCriptografiaE2E?.data ?? false;
let arquivoParaUpload: Blob = file;
let arquivoCriptografado = false;
let arquivoIv: string | undefined;
let arquivoKeyId: string | undefined;
if (conversaTemE2E) {
try {
// Tentar obter chave de criptografia
let encryptionKey = obterChaveCriptografia(conversaId);
if (!encryptionKey) {
encryptionKey = await loadEncryptionKey(conversaId);
if (encryptionKey) {
armazenarChaveCriptografia(conversaId, encryptionKey);
}
}
if (!encryptionKey) {
const chaveDoServidor = await client.query(api.chat.obterChaveCriptografia, {
conversaId
});
if (chaveDoServidor?.chaveCompartilhada) {
const { importKey } = await import('$lib/utils/e2eEncryption');
encryptionKey = await importKey(chaveDoServidor.chaveCompartilhada);
const keyData = await exportKey(encryptionKey);
storeEncryptionKey(conversaId, keyData, chaveDoServidor.keyId);
armazenarChaveCriptografia(conversaId, encryptionKey);
}
}
if (encryptionKey) {
// Criptografar arquivo
const encrypted = await encryptFile(file, encryptionKey);
arquivoParaUpload = encrypted.encryptedBlob;
arquivoIv = encrypted.iv;
arquivoKeyId = encrypted.keyId;
arquivoCriptografado = true;
} else {
console.warn(
'⚠️ [MessageInput] Criptografia E2E habilitada mas chave não encontrada. Enviando arquivo sem criptografia.'
);
}
} catch (error) {
console.error('❌ [MessageInput] Erro ao criptografar arquivo:', error);
alert('Erro ao criptografar arquivo. Tentando enviar sem criptografia...');
}
}
try {
uploadingFile = true;
uploadProgress = 0;
// 1. Obter upload URL
const uploadUrl = await client.mutation(api.chat.uploadArquivoChat, {
conversaId
});
// 2. Upload do arquivo
const result = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file
// 2. Upload do arquivo com progresso
const xhr = new XMLHttpRequest();
// Promise para aguardar upload completo
const uploadPromise = new Promise<string>((resolve, reject) => {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
uploadProgress = Math.round((e.loaded / e.total) * 100);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
resolve(response.storageId);
} catch {
reject(new Error('Resposta inválida do servidor'));
}
} else {
reject(new Error(`Falha no upload: ${xhr.statusText}`));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Erro de rede durante upload'));
});
xhr.addEventListener('abort', () => {
reject(new Error('Upload cancelado'));
});
xhr.open('POST', uploadUrl);
// Se arquivo foi criptografado, usar tipo genérico
xhr.setRequestHeader(
'Content-Type',
arquivoCriptografado ? 'application/octet-stream' : file.type
);
xhr.send(arquivoParaUpload);
});
if (!result.ok) {
throw new Error('Falha no upload');
}
const { storageId } = await result.json();
const storageId = await uploadPromise;
// 3. Enviar mensagem com o arquivo
const tipo: 'imagem' | 'arquivo' = file.type.startsWith('image/') ? 'imagem' : 'arquivo';
await client.mutation(api.chat.enviarMensagem, {
conversaId,
conteudo: tipo === 'imagem' ? '' : file.name,
conteudo: tipo === 'imagem' ? '' : nomeSanitizado,
tipo,
arquivoId: storageId,
arquivoNome: file.name,
arquivoId: storageId as Id<'_storage'>,
arquivoNome: nomeSanitizado,
arquivoTamanho: file.size,
arquivoTipo: file.type
arquivoTipo: file.type,
// Campos de criptografia E2E para arquivos
criptografado: arquivoCriptografado ? true : undefined,
iv: arquivoIv,
keyId: arquivoKeyId
});
// Limpar input
@@ -383,31 +667,48 @@
<div class="flex items-end gap-2">
<!-- Botão de anexar arquivo MODERNO -->
<label
class="group relative flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
style="background: rgba(102, 126, 234, 0.1); border: 1px solid rgba(102, 126, 234, 0.2);"
title="Anexar arquivo"
>
<input
type="file"
class="hidden"
onchange={handleFileUpload}
disabled={uploadingFile || enviando}
accept="*/*"
/>
<div
class="bg-primary/0 group-hover:bg-primary/10 absolute inset-0 transition-colors duration-300"
></div>
{#if uploadingFile}
<span class="loading loading-spinner loading-sm relative z-10"></span>
{:else}
<!-- Ícone de clipe moderno -->
<Paperclip
class="text-primary relative z-10 h-5 w-5 transition-transform group-hover:scale-110"
strokeWidth={2}
<div class="relative shrink-0">
<label
class="group relative flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
style="background: rgba(102, 126, 234, 0.1); border: 1px solid rgba(102, 126, 234, 0.2);"
title="Anexar arquivo"
aria-label="Anexar arquivo"
>
<input
type="file"
class="hidden"
onchange={handleFileUpload}
disabled={uploadingFile || enviando}
accept="*/*"
aria-label="Selecionar arquivo para anexar"
/>
<div
class="bg-primary/0 group-hover:bg-primary/10 absolute inset-0 transition-colors duration-300"
></div>
{#if uploadingFile}
<span class="loading loading-spinner loading-sm relative z-10"></span>
{:else}
<!-- Ícone de clipe moderno -->
<Paperclip
class="text-primary relative z-10 h-5 w-5 transition-transform group-hover:scale-110"
strokeWidth={2}
/>
{/if}
</label>
<!-- Barra de progresso do upload -->
{#if uploadingFile && uploadProgress > 0}
<div
class="bg-base-200 absolute right-0 -bottom-1 left-0 h-1 rounded-full"
style="z-index: 20;"
>
<div
class="bg-primary h-full rounded-full transition-all duration-300"
style="width: {uploadProgress}%;"
></div>
</div>
{/if}
</label>
</div>
<!-- Botão de EMOJI MODERNO -->
<div class="relative shrink-0">
@@ -418,6 +719,8 @@
onclick={() => (showEmojiPicker = !showEmojiPicker)}
disabled={enviando || uploadingFile}
aria-label="Adicionar emoji"
aria-expanded={showEmojiPicker}
aria-haspopup="true"
title="Adicionar emoji"
>
<div
@@ -434,13 +737,18 @@
<div
class="bg-base-100 border-base-300 absolute bottom-full left-0 z-50 mb-2 rounded-xl border p-3 shadow-2xl"
style="width: 280px; max-height: 200px; overflow-y-auto;"
role="dialog"
aria-label="Selecionar emoji"
id="emoji-picker"
>
<div class="grid grid-cols-10 gap-1">
{#each emojis as emoji}
<div class="grid grid-cols-10 gap-1" role="grid">
{#each emojis as emoji, index (emoji)}
<button
type="button"
class="hover:bg-base-200 cursor-pointer rounded p-1 text-2xl transition-transform hover:scale-125"
onclick={() => adicionarEmoji(emoji)}
aria-label="Adicionar emoji {emoji}"
role="gridcell"
>
{emoji}
</button>
@@ -458,21 +766,46 @@
oninput={handleInput}
onkeydown={handleKeyDown}
placeholder="Digite uma mensagem... (use @ para mencionar)"
class="textarea textarea-bordered max-h-[120px] min-h-[44px] w-full resize-none pr-10"
class="textarea textarea-bordered max-h-[120px] min-h-[44px] w-full resize-none pr-10 {mensagemMuitoLonga
? 'textarea-error'
: ''}"
rows="1"
disabled={enviando || uploadingFile}
maxlength={MAX_MENSAGEM_LENGTH}
aria-label="Campo de mensagem"
aria-describedby="mensagem-help"
aria-invalid={mensagemMuitoLonga}
></textarea>
{#if mensagemMuitoLonga || mensagem.length > MAX_MENSAGEM_LENGTH * 0.9}
<div
class="absolute right-2 bottom-1 text-xs {mensagem.length > MAX_MENSAGEM_LENGTH
? 'text-error'
: 'text-base-content/50'}"
>
{mensagem.length}/{MAX_MENSAGEM_LENGTH}
</div>
{/if}
<!-- Dropdown de Menções -->
{#if showMentionsDropdown && participantesFiltrados().length > 0 && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
<div
class="bg-base-100 border-base-300 absolute bottom-full left-0 z-50 mb-2 max-h-48 w-64 overflow-y-auto rounded-lg border shadow-xl"
role="listbox"
aria-label="Lista de participantes para mencionar"
id="mentions-dropdown"
>
{#each participantesFiltrados() as participante (participante._id)}
{#each participantesFiltrados() as participante, index (participante._id)}
<button
type="button"
class="hover:bg-base-200 flex w-full items-center gap-2 px-4 py-2 text-left transition-colors"
class="hover:bg-base-200 flex w-full items-center gap-2 px-4 py-2 text-left transition-colors {index ===
selectedMentionIndex
? 'bg-primary/20'
: ''}"
onclick={() => inserirMencao(participante)}
role="option"
aria-selected={index === selectedMentionIndex}
aria-label="Mencionar {participante.nome}"
id="mention-option-{index}"
>
<div
class="bg-primary/20 flex h-8 w-8 items-center justify-center overflow-hidden rounded-full"
@@ -508,7 +841,8 @@
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={handleEnviar}
disabled={!mensagem.trim() || enviando || uploadingFile}
aria-label="Enviar"
aria-label="Enviar mensagem"
aria-describedby="mensagem-help"
>
<div
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/10"
@@ -525,7 +859,8 @@
</div>
<!-- Informação sobre atalhos -->
<p class="text-base-content/50 mt-2 text-center text-xs">
💡 Enter para enviar • Shift+Enter para quebrar linha • 😊 Clique no emoji
<p id="mensagem-help" class="text-base-content/50 mt-2 text-center text-xs" role="note">
💡 Enter para enviar • Shift+Enter para quebrar linha • 😊 Clique no emoji • Use @ para
mencionar
</p>
</div>

View File

@@ -1,22 +1,120 @@
<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 { useConvexClient, useQuery } from 'convex-svelte';
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';
import {
notificacaoAtiva,
obterChaveCriptografia,
armazenarChaveCriptografia
} from '$lib/stores/chatStore';
import {
decryptMessage,
decryptFile,
loadEncryptionKey,
exportKey,
storeEncryptionKey,
type EncryptedMessage
} from '$lib/utils/e2eEncryption';
interface Props {
conversaId: Id<'conversas'>;
}
const { conversaId }: Props = $props();
let { conversaId }: Props = $props();
const client = useConvexClient();
const mensagens = useQuery(api.chat.obterMensagens, {
// Estados para paginação
let cursor = $state<Id<'mensagens'> | null>(null);
let todasMensagens = $state<Array<any>>([]);
let carregandoMais = $state(false);
let hasMore = $state(true);
// Query para obter mensagens com paginação
const mensagensQuery = useQuery(api.chat.obterMensagens, {
conversaId,
limit: 50
limit: 50,
cursor: cursor || undefined
});
// Atualizar lista de mensagens quando a query mudar
// Usar untrack para evitar loops infinitos
let processandoMensagens = false;
$effect(() => {
if (processandoMensagens) return;
if (!mensagensQuery?.data) return;
const resultado = mensagensQuery.data as {
mensagens: any[];
hasMore: boolean;
nextCursor: Id<'mensagens'> | null;
};
const novasMensagens = resultado.mensagens || [];
// Comparação simples usando JSON para evitar loops
const idsAtuais = todasMensagens
.map((m) => String(m?._id))
.sort()
.join(',');
const idsNovos = novasMensagens
.map((m) => String(m?._id))
.sort()
.join(',');
// Só atualizar se realmente mudou
if (idsAtuais !== idsNovos) {
processandoMensagens = true;
try {
if (cursor === null) {
todasMensagens = novasMensagens;
} else {
// Carregamento adicional: adicionar no início
const idsExistentes = new Set(todasMensagens.map((m) => String(m?._id)));
const novasParaAdicionar = novasMensagens.filter(
(m) => m?._id && !idsExistentes.has(String(m._id))
);
if (novasParaAdicionar.length > 0) {
todasMensagens = [...novasParaAdicionar, ...todasMensagens];
}
}
hasMore = resultado.hasMore || false;
carregandoMais = false;
} finally {
processandoMensagens = false;
}
} else {
hasMore = resultado.hasMore || false;
carregandoMais = false;
}
});
// Resetar quando mudar de conversa
let conversaIdAnterior = $state<string | null>(null);
let resetandoConversa = false;
$effect(() => {
if (resetandoConversa) return;
const conversaIdAtual = String(conversaId);
if (conversaIdAnterior !== null && conversaIdAnterior !== conversaIdAtual) {
resetandoConversa = true;
try {
cursor = null;
todasMensagens = [];
hasMore = true;
carregandoMais = false;
mensagensComConteudo = [];
ultimoProcessamento = '';
} finally {
resetandoConversa = false;
}
}
conversaIdAnterior = conversaIdAtual;
});
const digitando = useQuery(api.chat.obterDigitando, { conversaId });
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId });
const conversas = useQuery(api.chat.listarConversas, {});
@@ -53,8 +151,8 @@
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) => {
if (todasMensagens.length > 0) {
todasMensagens.forEach((msg) => {
mensagensNotificadas.add(String(msg._id));
});
salvarMensagensNotificadas();
@@ -146,16 +244,48 @@
}
}
// Função para carregar mais mensagens (scroll infinito)
async function carregarMaisMensagens() {
if (carregandoMais || !hasMore || !mensagensQuery?.data) return;
const resultado = mensagensQuery.data as {
mensagens: any[];
hasMore: boolean;
nextCursor: Id<'mensagens'> | null;
};
if (!resultado.nextCursor) return;
carregandoMais = true;
cursor = resultado.nextCursor;
// Aguardar um pouco para a query atualizar
await new Promise((resolve) => setTimeout(resolve, 100));
}
// Detectar quando usuário rola para o topo para carregar mais mensagens
function handleScroll(e: Event) {
const target = e.target as HTMLDivElement;
// Considerar "no final" se estiver a menos de 150px do final
const distanciaDoFinal = target.scrollHeight - target.scrollTop - target.clientHeight;
const isAtBottom = distanciaDoFinal < 150;
shouldScrollToBottom = isAtBottom;
// Se está próximo do topo (menos de 200px), carregar mais mensagens
if (target.scrollTop < 200 && hasMore && !carregandoMais) {
carregarMaisMensagens();
}
}
// 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;
if (todasMensagens.length > 0 && messagesContainer) {
const currentCount = todasMensagens.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];
if (isNewMessage && todasMensagens.length > 0 && usuarioAtualId) {
const ultimaMensagem = todasMensagens[todasMensagens.length - 1];
const mensagemId = String(ultimaMensagem._id);
const remetenteIdStr = ultimaMensagem.remetenteId
? String(ultimaMensagem.remetenteId).trim()
@@ -163,16 +293,33 @@
? String(ultimaMensagem.remetente._id).trim()
: null;
// Verificar se outra notificação já está ativa para esta mensagem
const notificacaoAtual = $notificacaoAtiva;
const conversaIdStr = String(conversaId).trim();
const jaTemNotificacaoAtiva =
notificacaoAtual &&
notificacaoAtual.conversaId === conversaIdStr &&
notificacaoAtual.mensagemId === mensagemId;
// Se é uma nova mensagem de outro usuário (não minha) E ainda não foi notificada
// E não há outra notificação ativa para esta mensagem
if (
remetenteIdStr &&
remetenteIdStr !== usuarioAtualId &&
!mensagensNotificadas.has(mensagemId)
!mensagensNotificadas.has(mensagemId) &&
!jaTemNotificacaoAtiva
) {
// Marcar como notificada antes de tocar som (evita duplicação)
mensagensNotificadas.add(mensagemId);
salvarMensagensNotificadas();
// Registrar notificação ativa no store global
notificacaoAtiva.set({
conversaId: conversaIdStr,
mensagemId,
componente: 'messageList'
});
// Tocar som de notificação (apenas uma vez)
tocarSomNotificacao();
@@ -185,19 +332,55 @@
};
showNotificationPopup = true;
// Ocultar popup após 5 segundos
// Ocultar popup após 5 segundos - garantir limpeza
if (notificationTimeout) {
clearTimeout(notificationTimeout);
notificationTimeout = null;
}
notificationTimeout = setTimeout(() => {
showNotificationPopup = false;
notificationMessage = null;
notificationTimeout = null;
// Limpar notificação ativa do store
notificacaoAtiva.set(null);
}, 5000);
}
}
if (isNewMessage || shouldScrollToBottom) {
// Usar requestAnimationFrame para garantir que o DOM foi atualizado
// Scroll automático inteligente: só rolar se:
// 1. É uma nova mensagem E o usuário está no final (ou perto)
// 2. OU o usuário já estava no final antes
if (isNewMessage) {
// Verificar se está no final antes de fazer scroll
if (messagesContainer) {
const distanciaDoFinal =
messagesContainer.scrollHeight -
messagesContainer.scrollTop -
messagesContainer.clientHeight;
const estaNoFinal = distanciaDoFinal < 150;
// Só fazer scroll se estiver no final ou se for minha própria mensagem
const ultimaMensagem = todasMensagens[todasMensagens.length - 1];
const remetenteIdStr = ultimaMensagem.remetenteId
? String(ultimaMensagem.remetenteId).trim()
: ultimaMensagem.remetente?._id
? String(ultimaMensagem.remetente._id).trim()
: null;
const ehMinhaMensagem = remetenteIdStr && remetenteIdStr === usuarioAtualId;
if (estaNoFinal || ehMinhaMensagem || shouldScrollToBottom) {
// Usar requestAnimationFrame para garantir que o DOM foi atualizado
requestAnimationFrame(() => {
tick().then(() => {
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
});
});
}
}
} else if (shouldScrollToBottom) {
// Se não é nova mensagem mas o usuário estava no final, manter no final
requestAnimationFrame(() => {
tick().then(() => {
if (messagesContainer) {
@@ -209,12 +392,20 @@
lastMessageCount = currentCount;
}
// Cleanup: limpar timeout quando o effect for desmontado
return () => {
if (notificationTimeout) {
clearTimeout(notificationTimeout);
notificationTimeout = null;
}
};
});
// Marcar como lida quando mensagens carregam
$effect(() => {
if (mensagens?.data && mensagens.data.length > 0 && usuarioAtualId) {
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
if (todasMensagens.length > 0 && usuarioAtualId) {
const ultimaMensagem = todasMensagens[todasMensagens.length - 1];
const remetenteIdStr = ultimaMensagem.remetenteId
? String(ultimaMensagem.remetenteId).trim()
: ultimaMensagem.remetente?._id
@@ -285,8 +476,232 @@
site?: string;
} | null;
lidaPor?: Id<'usuarios'>[]; // IDs dos usuários que leram a mensagem
// Campos para criptografia E2E
criptografado?: boolean;
iv?: string;
keyId?: string;
// Campo derivado para conteúdo descriptografado (cache)
conteudoDescriptografado?: string | null;
// Campo derivado para URL de arquivo descriptografado (cache)
arquivoUrlDescriptografado?: string | null;
}
// Função para descriptografar um arquivo
async function descriptografarArquivo(
mensagem: Mensagem,
encryptionKey: CryptoKey
): Promise<string | null> {
if (!mensagem.criptografado || !mensagem.iv || !mensagem.arquivoUrl) {
return null; // Arquivo não está criptografado ou não tem URL
}
try {
// Buscar arquivo do storage
const response = await fetch(mensagem.arquivoUrl);
if (!response.ok) {
console.warn('⚠️ [MessageList] Erro ao buscar arquivo criptografado:', response.statusText);
return null;
}
const encryptedBlob = await response.blob();
// Descriptografar arquivo
const decryptedBlob = await decryptFile(encryptedBlob, mensagem.iv, encryptionKey);
// Criar URL temporária para o arquivo descriptografado
const url = URL.createObjectURL(decryptedBlob);
return url;
} catch (error) {
console.error('❌ [MessageList] Erro ao descriptografar arquivo:', error);
return null;
}
}
// Função para descriptografar uma mensagem
async function descriptografarMensagem(mensagem: Mensagem): Promise<string | null> {
if (!mensagem.criptografado || !mensagem.iv || !mensagem.keyId) {
return null; // Mensagem não está criptografada
}
try {
// Tentar obter chave do store primeiro
let encryptionKey = obterChaveCriptografia(conversaId);
// Se não estiver no store, tentar carregar do localStorage
if (!encryptionKey) {
encryptionKey = await loadEncryptionKey(conversaId);
if (encryptionKey) {
armazenarChaveCriptografia(conversaId, encryptionKey);
}
}
// Se ainda não tiver chave, tentar obter do servidor
if (!encryptionKey) {
const chaveDoServidor = await client.query(api.chat.obterChaveCriptografia, {
conversaId
});
if (chaveDoServidor?.chaveCompartilhada) {
const { importKey } = await import('$lib/utils/e2eEncryption');
encryptionKey = await importKey(chaveDoServidor.chaveCompartilhada);
// Armazenar chave localmente
const keyData = await exportKey(encryptionKey);
storeEncryptionKey(conversaId, keyData, chaveDoServidor.keyId);
armazenarChaveCriptografia(conversaId, encryptionKey);
}
}
if (!encryptionKey) {
console.warn(
'⚠️ [MessageList] Chave de criptografia não encontrada para descriptografar mensagem'
);
return null;
}
// Descriptografar mensagem
const encrypted: EncryptedMessage = {
encryptedContent: mensagem.conteudo,
iv: mensagem.iv,
keyId: mensagem.keyId
};
const decrypted = await decryptMessage(encrypted, encryptionKey);
return decrypted;
} catch (error) {
console.error('❌ [MessageList] Erro ao descriptografar mensagem:', error);
return null;
}
}
// Usar mensagens processadas (com cache de descriptografia)
let mensagensComConteudo = $state<Mensagem[]>([]);
// Processar mensagens para descriptografar as criptografadas
let ultimoProcessamento = $state<string>('');
let processandoDescriptografia = false;
$effect(() => {
if (processandoDescriptografia) return;
const mensagens = todasMensagens;
// Se não há mensagens, limpar e retornar
if (!mensagens || mensagens.length === 0) {
if (mensagensComConteudo.length > 0) {
mensagensComConteudo = [];
}
ultimoProcessamento = '';
return;
}
// Criar hash simples das mensagens para evitar reprocessamento
const hashMensagens = mensagens
.map((m) => `${String(m._id)}-${m.criptografado ? '1' : '0'}`)
.join('|');
if (hashMensagens === ultimoProcessamento && mensagensComConteudo.length === mensagens.length) {
return; // Já foi processado
}
ultimoProcessamento = hashMensagens;
processandoDescriptografia = true;
const processarMensagens = async () => {
const processadas: Mensagem[] = [];
// Obter chave de criptografia uma vez para todas as mensagens
let encryptionKey: CryptoKey | null = null;
if (mensagens.some((m) => m?.criptografado)) {
encryptionKey = obterChaveCriptografia(conversaId);
if (!encryptionKey) {
encryptionKey = await loadEncryptionKey(conversaId);
if (encryptionKey) {
armazenarChaveCriptografia(conversaId, encryptionKey);
}
}
if (!encryptionKey) {
const chaveDoServidor = await client.query(api.chat.obterChaveCriptografia, {
conversaId
});
if (chaveDoServidor?.chaveCompartilhada) {
const { importKey } = await import('$lib/utils/e2eEncryption');
encryptionKey = await importKey(chaveDoServidor.chaveCompartilhada);
const keyData = await exportKey(encryptionKey);
storeEncryptionKey(conversaId, keyData, chaveDoServidor.keyId);
armazenarChaveCriptografia(conversaId, encryptionKey);
}
}
}
for (const msg of mensagens) {
if (!msg) continue; // Pular mensagens nulas
const mensagemProcessada = { ...msg };
// Se a mensagem está criptografada e ainda não foi descriptografada
if (mensagemProcessada.criptografado && encryptionKey) {
// Descriptografar conteúdo de texto
if (!mensagemProcessada.conteudoDescriptografado && mensagemProcessada.tipo === 'texto') {
const decrypted = await descriptografarMensagem(mensagemProcessada);
if (decrypted !== null) {
mensagemProcessada.conteudoDescriptografado = decrypted;
} else {
mensagemProcessada.conteudoDescriptografado =
'🔒 Não foi possível descriptografar esta mensagem';
}
}
// Descriptografar arquivo/imagem
if (
!mensagemProcessada.arquivoUrlDescriptografado &&
(mensagemProcessada.tipo === 'arquivo' || mensagemProcessada.tipo === 'imagem')
) {
const arquivoDescriptografado = await descriptografarArquivo(
mensagemProcessada,
encryptionKey
);
if (arquivoDescriptografado) {
mensagemProcessada.arquivoUrlDescriptografado = arquivoDescriptografado;
}
}
} else if (!mensagemProcessada.criptografado) {
// Mensagem não criptografada: usar conteúdo original
mensagemProcessada.conteudoDescriptografado = mensagemProcessada.conteudo || '';
mensagemProcessada.arquivoUrlDescriptografado = mensagemProcessada.arquivoUrl || null;
} else {
// Já foi processada, manter
if (!mensagemProcessada.conteudoDescriptografado) {
mensagemProcessada.conteudoDescriptografado = mensagemProcessada.conteudo || '';
}
if (!mensagemProcessada.arquivoUrlDescriptografado) {
mensagemProcessada.arquivoUrlDescriptografado = mensagemProcessada.arquivoUrl || null;
}
}
processadas.push(mensagemProcessada);
}
mensagensComConteudo = processadas;
processandoDescriptografia = false;
};
processarMensagens().catch((error) => {
console.error('❌ [MessageList] Erro ao processar mensagens:', error);
// Em caso de erro, pelo menos mostrar as mensagens sem descriptografia
mensagensComConteudo = todasMensagens.map((msg) => ({
...msg,
conteudoDescriptografado: msg.criptografado
? '🔒 Erro ao descriptografar'
: msg.conteudo || '',
arquivoUrlDescriptografado: msg.criptografado ? null : msg.arquivoUrl || null
}));
processandoDescriptografia = false;
});
});
function agruparMensagensPorDia(msgs: Mensagem[]): Record<string, Mensagem[]> {
const grupos: Record<string, Mensagem[]> = {};
for (const msg of msgs) {
@@ -299,12 +714,6 @@
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,
@@ -328,7 +737,8 @@
async function editarMensagem(mensagem: Mensagem) {
mensagemEditando = mensagem;
novoConteudoEditado = mensagem.conteudo;
// Usar conteúdo descriptografado se disponível, senão usar original
novoConteudoEditado = mensagem.conteudoDescriptografado ?? mensagem.conteudo;
}
async function salvarEdicao() {
@@ -395,7 +805,7 @@
}
// Obter informações da conversa atual
let conversaAtual = $derived(() => {
const conversaAtual = $derived(() => {
if (!conversas?.data) return null;
return (conversas.data as any[]).find((c) => c._id === conversaId) || null;
});
@@ -434,6 +844,34 @@
return false;
}
// Escutar evento de scroll para mensagem específica (da busca)
onMount(() => {
const handler = (e: Event) => {
const customEvent = e as CustomEvent<{ mensagemId: Id<'mensagens'> }>;
const mensagemId = customEvent.detail.mensagemId;
// Encontrar elemento da mensagem e fazer scroll
if (messagesContainer) {
const mensagemElement = messagesContainer.querySelector(
`[data-mensagem-id="${mensagemId}"]`
);
if (mensagemElement) {
mensagemElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Destacar mensagem temporariamente
mensagemElement.classList.add('bg-yellow-200', 'dark:bg-yellow-900');
setTimeout(() => {
mensagemElement.classList.remove('bg-yellow-200', 'dark:bg-yellow-900');
}, 2000);
}
}
};
window.addEventListener('scrollToMessage', handler);
return () => {
window.removeEventListener('scrollToMessage', handler);
};
});
</script>
<div
@@ -441,8 +879,10 @@
bind:this={messagesContainer}
onscroll={handleScroll}
>
{#if mensagens?.data && mensagens.data.length > 0}
{@const gruposPorDia = agruparMensagensPorDia(mensagens.data)}
{#if todasMensagens.length > 0}
{@const mensagensParaExibir =
mensagensComConteudo.length > 0 ? mensagensComConteudo : todasMensagens}
{@const gruposPorDia = agruparMensagensPorDia(mensagensParaExibir)}
{#each Object.entries(gruposPorDia) as [dia, mensagensDia]}
<!-- Separador de dia -->
<div class="my-4 flex items-center justify-center">
@@ -511,12 +951,27 @@
cancelarEdicao();
}
}}
aria-label="Editar mensagem"
aria-describedby="edicao-help"
></textarea>
<p id="edicao-help" class="sr-only">
Pressione Ctrl+Enter para salvar ou Escape para cancelar
</p>
<div class="flex justify-end gap-2">
<button class="btn btn-xs btn-ghost" onclick={cancelarEdicao}>
<button
class="btn btn-xs btn-ghost"
onclick={cancelarEdicao}
aria-label="Cancelar edição"
>
Cancelar
</button>
<button class="btn btn-xs btn-primary" onclick={salvarEdicao}> Salvar </button>
<button
class="btn btn-xs btn-primary"
onclick={salvarEdicao}
aria-label="Salvar edição"
>
Salvar
</button>
</div>
</div>
{:else if mensagem.deletada}
@@ -525,7 +980,7 @@
<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}
{mensagem.conteudoDescriptografado ?? mensagem.conteudo}
</p>
{#if mensagem.editadaEm}
<span class="text-xs italic opacity-50" title="Editado">(editado)</span>
@@ -573,37 +1028,30 @@
{:else if mensagem.tipo === 'imagem'}
<div class="mb-2">
<img
src={mensagem.arquivoUrl}
src={mensagem.arquivoUrlDescriptografado ?? mensagem.arquivoUrl}
alt={mensagem.arquivoNome}
class="max-w-full rounded-lg"
onerror={(e) => {
if (mensagem.criptografado) {
(e.target as HTMLImageElement).alt = '🔒 Erro ao descriptografar imagem';
}
}}
/>
</div>
{#if mensagem.conteudo}
<p class="text-sm break-words whitespace-pre-wrap">
{mensagem.conteudo}
{mensagem.conteudoDescriptografado ?? mensagem.conteudo}
</p>
{/if}
{:else if mensagem.tipo === 'arquivo'}
<a
href={mensagem.arquivoUrl}
href={mensagem.arquivoUrlDescriptografado ?? mensagem.arquivoUrl}
target="_blank"
rel="noopener noreferrer"
download={mensagem.arquivoNome}
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="h-5 w-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>
<File class="h-5 w-5" strokeWidth={1.5} />
<div class="text-sm">
<p class="font-medium">{mensagem.arquivoNome}</p>
{#if mensagem.arquivoTamanho}
@@ -637,6 +1085,7 @@
class="text-base-content/50 hover:text-primary mt-1 text-xs transition-colors"
onclick={() => responderMensagem(mensagem)}
title="Responder"
aria-label="Responder à mensagem de {mensagem.remetente?.nome || 'usuário'}"
>
↪️ Responder
</button>
@@ -655,45 +1104,15 @@
<div class="ml-1 flex items-center gap-0.5">
{#if mensagemFoiLida(mensagem)}
<!-- Dois checks azuis para mensagem lida -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
<CheckCircle2
class="h-3.5 w-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="h-3.5 w-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>
/>
<CheckCircle2 class="h-3.5 w-3.5 text-blue-500" fill="currentColor" />
{:else}
<!-- Um check verde para mensagem enviada -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="h-3.5 w-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>
<CheckCircle class="h-3.5 w-3.5 text-green-500" fill="currentColor" />
{/if}
</div>
{/if}
@@ -705,6 +1124,7 @@
class="text-base-content/50 hover:text-primary text-xs transition-colors"
onclick={() => editarMensagem(mensagem)}
title="Editar mensagem"
aria-label="Editar esta mensagem"
>
✏️
</button>
@@ -712,6 +1132,7 @@
class="text-base-content/50 hover:text-error text-xs transition-colors"
onclick={() => deletarMensagem(mensagem._id, false)}
title="Deletar mensagem"
aria-label="Deletar esta mensagem"
>
🗑️
</button>
@@ -721,6 +1142,7 @@
class="text-base-content/50 hover:text-error text-xs transition-colors"
onclick={() => deletarMensagem(mensagem._id, true)}
title="Deletar mensagem (como administrador)"
aria-label="Deletar esta mensagem como administrador"
>
🗑️ Admin
</button>
@@ -753,7 +1175,22 @@
</p>
</div>
{/if}
{:else if !mensagens?.data}
<!-- Indicador de carregamento de mais mensagens -->
{#if carregandoMais}
<div class="my-4 flex items-center justify-center">
<span class="loading loading-spinner loading-sm"></span>
<span class="text-base-content/50 ml-2 text-xs">Carregando mensagens anteriores...</span>
</div>
{/if}
<!-- Indicador de fim das mensagens -->
{#if !hasMore && todasMensagens.length > 0}
<div class="my-4 flex items-center justify-center">
<span class="text-base-content/50 text-xs">Não há mais mensagens</span>
</div>
{/if}
{:else if !mensagensQuery?.data && todasMensagens.length === 0}
<!-- Loading -->
<div class="flex h-full items-center justify-center">
<span class="loading loading-spinner loading-lg"></span>
@@ -761,20 +1198,7 @@
{:else}
<!-- Vazio -->
<div class="flex h-full flex-col items-center justify-center text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="text-base-content/30 mb-4 h-16 w-16"
>
<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>
<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>
@@ -796,20 +1220,7 @@
>
<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">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="text-primary h-5 w-5"
>
<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>
<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">
@@ -831,16 +1242,7 @@
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="h-4 w-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
<X class="h-4 w-4" strokeWidth={2} />
</button>
</div>
</div>

View File

@@ -1,11 +1,10 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useConvexClient, useQuery } from 'convex-svelte';
import { notificacoesCount } from '$lib/stores/chatStore';
import { formatDistanceToNow } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { AtSign, Bell, BellOff, Calendar, Clock, Mail, Trash2, Users, X } from 'lucide-svelte';
import { onMount } from 'svelte';
import { notificacoesCount } from '$lib/stores/chatStore';
import { Bell, Mail, AtSign, Users, Calendar, Clock, BellOff, Trash2, X } from 'lucide-svelte';
// Queries e Client
const client = useConvexClient();
@@ -82,19 +81,25 @@
});
notificacoesAusencias = notifsAusencias || [];
} catch (queryError: unknown) {
// Silenciar erro se a função não estiver disponível ainda (Convex não sincronizado)
// Silenciar erros de timeout e função não encontrada
const errorMessage =
queryError instanceof Error ? queryError.message : String(queryError);
if (!errorMessage.includes('Could not find public function')) {
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
const isFunctionNotFound = errorMessage.includes('Could not find public function');
if (!isTimeout && !isFunctionNotFound) {
console.error('Erro ao buscar notificações de ausências:', queryError);
}
notificacoesAusencias = [];
}
}
} catch (e) {
// Erro geral - silenciar se for sobre função não encontrada
// Erro geral - silenciar se for sobre função não encontrada ou timeout
const errorMessage = e instanceof Error ? e.message : String(e);
if (!errorMessage.includes('Could not find public function')) {
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
const isFunctionNotFound = errorMessage.includes('Could not find public function');
if (!isTimeout && !isFunctionNotFound) {
console.error('Erro ao buscar notificações de ausências:', e);
}
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useConvexClient, useQuery } from 'convex-svelte';
import { onMount } from 'svelte';
const client = useConvexClient();
@@ -14,6 +15,52 @@
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
let lastActivity = Date.now();
let lastStatusUpdate = 0;
let pendingStatusUpdate: ReturnType<typeof setTimeout> | null = null;
const STATUS_UPDATE_THROTTLE = 5000; // 5 segundos entre atualizações
// Função auxiliar para atualizar status com throttle e tratamento de erro
async function atualizarStatusPresencaSeguro(
status: 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao'
) {
if (!usuarioAutenticado) return;
const now = Date.now();
// Throttle: só atualizar se passou tempo suficiente desde a última atualização
if (now - lastStatusUpdate < STATUS_UPDATE_THROTTLE) {
// Cancelar atualização pendente se houver
if (pendingStatusUpdate) {
clearTimeout(pendingStatusUpdate);
}
// Agendar atualização para depois do throttle
pendingStatusUpdate = setTimeout(
() => {
atualizarStatusPresencaSeguro(status);
},
STATUS_UPDATE_THROTTLE - (now - lastStatusUpdate)
);
return;
}
// Limpar atualização pendente se houver
if (pendingStatusUpdate) {
clearTimeout(pendingStatusUpdate);
pendingStatusUpdate = null;
}
lastStatusUpdate = now;
try {
await client.mutation(api.chat.atualizarStatusPresenca, { status });
} catch (error) {
// Silenciar erros de timeout - não são críticos para a funcionalidade
const errorMessage = error instanceof Error ? error.message : String(error);
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
if (!isTimeout) {
console.error('Erro ao atualizar status de presença:', error);
}
}
}
// Detectar atividade do usuário
function handleActivity() {
@@ -30,9 +77,7 @@
inactivityTimeout = setTimeout(
() => {
if (usuarioAutenticado) {
client.mutation(api.chat.atualizarStatusPresenca, {
status: 'ausente'
});
atualizarStatusPresencaSeguro('ausente');
}
},
5 * 60 * 1000
@@ -44,7 +89,7 @@
if (!usuarioAutenticado) return;
// Configurar como online ao montar (apenas se autenticado)
client.mutation(api.chat.atualizarStatusPresenca, { status: 'online' });
atualizarStatusPresencaSeguro('online');
// Heartbeat a cada 30 segundos (apenas se autenticado)
heartbeatInterval = setInterval(() => {
@@ -60,7 +105,7 @@
// Se houve atividade nos últimos 5 minutos, manter online
if (timeSinceLastActivity < 5 * 60 * 1000) {
client.mutation(api.chat.atualizarStatusPresenca, { status: 'online' });
atualizarStatusPresencaSeguro('online');
}
}, 30 * 1000);
@@ -81,10 +126,10 @@
if (document.hidden) {
// Aba ficou inativa
client.mutation(api.chat.atualizarStatusPresenca, { status: 'ausente' });
atualizarStatusPresencaSeguro('ausente');
} else {
// Aba ficou ativa
client.mutation(api.chat.atualizarStatusPresenca, { status: 'online' });
atualizarStatusPresencaSeguro('online');
handleActivity();
}
}
@@ -93,9 +138,15 @@
// Cleanup
return () => {
// Limpar atualização pendente
if (pendingStatusUpdate) {
clearTimeout(pendingStatusUpdate);
pendingStatusUpdate = null;
}
// Marcar como offline ao desmontar (apenas se autenticado)
if (usuarioAutenticado) {
client.mutation(api.chat.atualizarStatusPresenca, { status: 'offline' });
atualizarStatusPresencaSeguro('offline');
}
if (heartbeatInterval) {

View File

@@ -1,13 +1,55 @@
<script lang="ts">
import { User } from 'lucide-svelte';
import { getCachedAvatar } from '$lib/utils/avatarCache';
import { onMount } from 'svelte';
interface Props {
fotoPerfilUrl?: string | null;
nome: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
userId?: string; // ID do usuário para cache
}
const { fotoPerfilUrl, nome, size = 'md' }: Props = $props();
let { fotoPerfilUrl, nome, size = 'md', userId }: Props = $props();
let cachedAvatarUrl = $state<string | null>(null);
let loading = $state(true);
onMount(async () => {
if (fotoPerfilUrl) {
loading = true;
try {
cachedAvatarUrl = await getCachedAvatar(fotoPerfilUrl, userId);
} catch (error) {
console.warn('Erro ao carregar avatar:', error);
cachedAvatarUrl = null;
} finally {
loading = false;
}
} else {
loading = false;
}
});
// Atualizar quando fotoPerfilUrl mudar
$effect(() => {
if (fotoPerfilUrl) {
loading = true;
getCachedAvatar(fotoPerfilUrl, userId)
.then((url) => {
cachedAvatarUrl = url;
loading = false;
})
.catch((error) => {
console.warn('Erro ao carregar avatar:', error);
cachedAvatarUrl = null;
loading = false;
});
} else {
cachedAvatarUrl = null;
loading = false;
}
});
const sizeClasses = {
xs: 'w-8 h-8',
@@ -30,11 +72,25 @@
<div
class={`${sizeClasses[size]} bg-base-200 text-base-content/50 flex items-center justify-center overflow-hidden rounded-full`}
>
{#if fotoPerfilUrl}
{#if loading}
<span class="loading loading-spinner loading-xs"></span>
{:else if cachedAvatarUrl}
<img
src={cachedAvatarUrl}
alt={`Foto de perfil de ${nome}`}
class="h-full w-full object-cover"
loading="lazy"
onerror={() => {
cachedAvatarUrl = null;
}}
/>
{:else if fotoPerfilUrl}
<!-- Fallback: usar URL original se cache falhar -->
<img
src={fotoPerfilUrl}
alt={`Foto de perfil de ${nome}`}
class="h-full w-full object-cover"
loading="lazy"
/>
{:else}
<User size={iconSizes[size]} />

View File

@@ -1,10 +1,12 @@
<script lang="ts">
import { CheckCircle2, XCircle, AlertCircle, Plus, Video } from 'lucide-svelte';
interface Props {
status?: 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao';
size?: 'sm' | 'md' | 'lg';
}
const { status = 'offline', size = 'md' }: Props = $props();
let { status = 'offline', size = 'md' }: Props = $props();
const sizeClasses = {
sm: 'w-3 h-3',
@@ -12,63 +14,55 @@
lg: 'w-5 h-5'
};
const iconSizes = {
sm: 8,
md: 12,
lg: 16
};
const statusConfig = {
online: {
color: 'bg-success',
borderColor: 'border-success',
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#10b981"/>
<path d="M9 12l2 2 4-4" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`,
icon: CheckCircle2,
label: '🟢 Online'
},
offline: {
color: 'bg-base-300',
borderColor: 'border-base-300',
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#9ca3af"/>
<path d="M8 8l8 8M16 8l-8 8" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>`,
icon: XCircle,
label: '⚫ Offline'
},
ausente: {
color: 'bg-warning',
borderColor: 'border-warning',
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#f59e0b"/>
<circle cx="12" cy="6" r="1.5" fill="white"/>
<path d="M12 10v4" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>`,
icon: AlertCircle,
label: '🟡 Ausente'
},
externo: {
color: 'bg-info',
borderColor: 'border-info',
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#3b82f6"/>
<path d="M8 12h8M12 8v8" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>`,
icon: Plus,
label: '🔵 Externo'
},
em_reuniao: {
color: 'bg-error',
borderColor: 'border-error',
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#ef4444"/>
<rect x="8" y="8" width="8" height="8" fill="white" rx="1"/>
</svg>`,
icon: Video,
label: '🔴 Em Reunião'
}
};
let config = $derived(statusConfig[status]);
const config = $derived(statusConfig[status]);
const IconComponent = $derived(config.icon);
const iconSize = $derived(iconSizes[size]);
</script>
<div
class={`${sizeClasses[size]} relative flex items-center justify-center rounded-full`}
style="box-shadow: 0 2px 8px rgba(0,0,0,0.15); border: 2px solid white;"
class={`${sizeClasses[size]} ${config.color} ${config.borderColor} relative flex items-center justify-center rounded-full border-2`}
style="box-shadow: 0 2px 8px rgba(0,0,0,0.15);"
title={config.label}
aria-label={config.label}
>
{@html config.icon}
<IconComponent class="text-white" size={iconSize} strokeWidth={2.5} fill="currentColor" />
</div>