feat: implement end-to-end encryption for chat messages and files, including key management and decryption functionality; enhance chat components to support encrypted content display
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
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';
|
||||
@@ -19,14 +20,14 @@
|
||||
X,
|
||||
ArrowLeft,
|
||||
LogOut,
|
||||
MoreVertical,
|
||||
Users,
|
||||
Clock,
|
||||
XCircle,
|
||||
Phone,
|
||||
Video,
|
||||
ChevronDown,
|
||||
Search
|
||||
Search,
|
||||
Lock,
|
||||
MoreVertical,
|
||||
XCircle
|
||||
} from 'lucide-svelte';
|
||||
|
||||
//import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
|
||||
@@ -45,21 +46,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);
|
||||
let showSearch = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<Array<any>>([]);
|
||||
let searchResults = $state<Array<unknown | undefined>>([]);
|
||||
let searching = $state(false);
|
||||
let selectedSearchResult = $state<number>(-1);
|
||||
|
||||
// Estados para modal de erro
|
||||
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'>
|
||||
});
|
||||
@@ -70,6 +68,11 @@
|
||||
conversaId: conversaId as Id<'conversas'>
|
||||
});
|
||||
|
||||
// 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);
|
||||
@@ -297,9 +300,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()}
|
||||
@@ -322,7 +345,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)}
|
||||
@@ -609,6 +632,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"
|
||||
@@ -674,7 +718,8 @@
|
||||
</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
|
||||
{searchResults.length} resultado{searchResults.length !== 1 ? 's' : ''} encontrado{searchResults.length !==
|
||||
1
|
||||
? 's'
|
||||
: ''}
|
||||
</p>
|
||||
@@ -698,7 +743,9 @@
|
||||
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">
|
||||
<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}
|
||||
@@ -750,6 +797,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 -->
|
||||
|
||||
267
apps/web/src/lib/components/chat/E2EManagementModal.svelte
Normal file
267
apps/web/src/lib/components/chat/E2EManagementModal.svelte
Normal 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>
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
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'>;
|
||||
@@ -28,6 +37,9 @@
|
||||
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
|
||||
@@ -168,7 +180,7 @@
|
||||
// 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;
|
||||
@@ -180,7 +192,7 @@
|
||||
} else {
|
||||
mensagemMuitoLonga = false;
|
||||
}
|
||||
|
||||
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
|
||||
@@ -237,7 +249,7 @@
|
||||
async function handleEnviar() {
|
||||
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.`);
|
||||
@@ -258,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
|
||||
});
|
||||
@@ -270,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);
|
||||
@@ -309,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) {
|
||||
@@ -338,19 +413,19 @@
|
||||
// Navegar dropdown de menções
|
||||
if (showMentionsDropdown && participantesFiltrados().length > 0) {
|
||||
const participantes = participantesFiltrados();
|
||||
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
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]) {
|
||||
@@ -358,7 +433,7 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
showMentionsDropdown = false;
|
||||
@@ -381,7 +456,9 @@
|
||||
|
||||
// Validar tamanho
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert(`Arquivo muito grande. O tamanho máximo é ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB.`);
|
||||
alert(
|
||||
`Arquivo muito grande. O tamanho máximo é ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB.`
|
||||
);
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
@@ -428,6 +505,58 @@
|
||||
// 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;
|
||||
@@ -470,8 +599,12 @@
|
||||
});
|
||||
|
||||
xhr.open('POST', uploadUrl);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
xhr.send(file);
|
||||
// Se arquivo foi criptografado, usar tipo genérico
|
||||
xhr.setRequestHeader(
|
||||
'Content-Type',
|
||||
arquivoCriptografado ? 'application/octet-stream' : file.type
|
||||
);
|
||||
xhr.send(arquivoParaUpload);
|
||||
});
|
||||
|
||||
const storageId = await uploadPromise;
|
||||
@@ -482,10 +615,14 @@
|
||||
conversaId,
|
||||
conteudo: tipo === 'imagem' ? '' : nomeSanitizado,
|
||||
tipo,
|
||||
arquivoId: storageId,
|
||||
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
|
||||
@@ -558,15 +695,15 @@
|
||||
/>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
|
||||
<!-- Barra de progresso do upload -->
|
||||
{#if uploadingFile && uploadProgress > 0}
|
||||
<div
|
||||
class="absolute -bottom-1 left-0 right-0 h-1 rounded-full bg-base-200"
|
||||
class="bg-base-200 absolute right-0 -bottom-1 left-0 h-1 rounded-full"
|
||||
style="z-index: 20;"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all duration-300"
|
||||
class="bg-primary h-full rounded-full transition-all duration-300"
|
||||
style="width: {uploadProgress}%;"
|
||||
></div>
|
||||
</div>
|
||||
@@ -605,7 +742,7 @@
|
||||
id="emoji-picker"
|
||||
>
|
||||
<div class="grid grid-cols-10 gap-1" role="grid">
|
||||
{#each emojis as emoji}
|
||||
{#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"
|
||||
@@ -640,9 +777,11 @@
|
||||
aria-invalid={mensagemMuitoLonga}
|
||||
></textarea>
|
||||
{#if mensagemMuitoLonga || mensagem.length > MAX_MENSAGEM_LENGTH * 0.9}
|
||||
<div class="absolute bottom-1 right-2 text-xs {mensagem.length > MAX_MENSAGEM_LENGTH
|
||||
? 'text-error'
|
||||
: 'text-base-content/50'}">
|
||||
<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}
|
||||
@@ -658,7 +797,8 @@
|
||||
{#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 {index === selectedMentionIndex
|
||||
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)}
|
||||
@@ -695,15 +835,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Botão de enviar MODERNO -->
|
||||
<button
|
||||
type="button"
|
||||
class="group relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
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 mensagem"
|
||||
aria-describedby="mensagem-help"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
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 mensagem"
|
||||
aria-describedby="mensagem-help"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/10"
|
||||
></div>
|
||||
@@ -720,6 +860,7 @@
|
||||
|
||||
<!-- Informação sobre atalhos -->
|
||||
<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
|
||||
💡 Enter para enviar • Shift+Enter para quebrar linha • 😊 Clique no emoji • Use @ para
|
||||
mencionar
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,15 @@
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { File, CheckCircle2, CheckCircle, MessageSquare, Bell, X } from 'lucide-svelte';
|
||||
import { notificacaoAtiva } from '$lib/stores/chatStore';
|
||||
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'>;
|
||||
@@ -31,27 +39,49 @@
|
||||
|
||||
// Atualizar lista de mensagens quando a query mudar
|
||||
$effect(() => {
|
||||
if (mensagensQuery?.data) {
|
||||
const resultado = mensagensQuery.data as { mensagens: any[]; hasMore: boolean; nextCursor: Id<'mensagens'> | null };
|
||||
if (!mensagensQuery?.data) return;
|
||||
|
||||
const resultado = mensagensQuery.data as { mensagens: any[]; hasMore: boolean; nextCursor: Id<'mensagens'> | null };
|
||||
const novasMensagens = resultado.mensagens || [];
|
||||
|
||||
// Evitar atualizações desnecessárias comparando IDs das mensagens
|
||||
if (cursor === null) {
|
||||
// Primeira carga: verificar se realmente mudou
|
||||
const idsAtuais = todasMensagens.map(m => m?._id).filter(Boolean);
|
||||
const idsNovos = novasMensagens.map(m => m?._id).filter(Boolean);
|
||||
const idsIguais = idsAtuais.length === idsNovos.length &&
|
||||
idsAtuais.every((id, i) => id === idsNovos[i]);
|
||||
|
||||
if (cursor === null) {
|
||||
// Primeira carga: substituir todas as mensagens
|
||||
todasMensagens = resultado.mensagens || [];
|
||||
} else {
|
||||
// Carregamento adicional: adicionar no início (mensagens mais antigas)
|
||||
todasMensagens = [...(resultado.mensagens || []), ...todasMensagens];
|
||||
if (!idsIguais) {
|
||||
todasMensagens = novasMensagens;
|
||||
}
|
||||
} else {
|
||||
// Carregamento adicional: adicionar no início (mensagens mais antigas)
|
||||
// Verificar se já não foram adicionadas
|
||||
const idsExistentes = new Set(todasMensagens.map(m => m?._id).filter(Boolean));
|
||||
const novasParaAdicionar = novasMensagens.filter(m => m?._id && !idsExistentes.has(m._id));
|
||||
|
||||
hasMore = resultado.hasMore || false;
|
||||
carregandoMais = false;
|
||||
if (novasParaAdicionar.length > 0) {
|
||||
todasMensagens = [...novasParaAdicionar, ...todasMensagens];
|
||||
}
|
||||
}
|
||||
|
||||
hasMore = resultado.hasMore || false;
|
||||
carregandoMais = false;
|
||||
});
|
||||
|
||||
// Resetar quando mudar de conversa
|
||||
let conversaIdAnterior = $state<string | null>(null);
|
||||
$effect(() => {
|
||||
cursor = null;
|
||||
todasMensagens = [];
|
||||
hasMore = true;
|
||||
const conversaIdAtual = String(conversaId);
|
||||
if (conversaIdAnterior !== null && conversaIdAnterior !== conversaIdAtual) {
|
||||
cursor = null;
|
||||
todasMensagens = [];
|
||||
hasMore = true;
|
||||
carregandoMais = false;
|
||||
mensagensComConteudo = [];
|
||||
}
|
||||
conversaIdAnterior = conversaIdAtual;
|
||||
});
|
||||
|
||||
const digitando = useQuery(api.chat.obterDigitando, { conversaId });
|
||||
@@ -411,8 +441,216 @@
|
||||
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>('');
|
||||
$effect(() => {
|
||||
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 das mensagens para evitar reprocessamento desnecessário
|
||||
const hashMensagens = mensagens.map(m => `${m._id}-${m.criptografado ? '1' : '0'}`).join('|');
|
||||
if (hashMensagens === ultimoProcessamento) {
|
||||
return; // Já foi processado
|
||||
}
|
||||
|
||||
ultimoProcessamento = hashMensagens;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
function agruparMensagensPorDia(msgs: Mensagem[]): Record<string, Mensagem[]> {
|
||||
const grupos: Record<string, Mensagem[]> = {};
|
||||
for (const msg of msgs) {
|
||||
@@ -448,7 +686,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() {
|
||||
@@ -587,8 +826,9 @@
|
||||
bind:this={messagesContainer}
|
||||
onscroll={handleScroll}
|
||||
>
|
||||
{#if todasMensagens.length > 0}
|
||||
{@const gruposPorDia = agruparMensagensPorDia(todasMensagens)}
|
||||
{#if todasMensagens.length > 0 || mensagensComConteudo.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">
|
||||
@@ -684,7 +924,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>
|
||||
@@ -732,21 +972,27 @@
|
||||
{: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"
|
||||
>
|
||||
<File class="h-5 w-5" strokeWidth={1.5} />
|
||||
|
||||
Reference in New Issue
Block a user