532 lines
14 KiB
Svelte
532 lines
14 KiB
Svelte
<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 { Paperclip, Send, Smile } from 'lucide-svelte';
|
|
import { onMount } from 'svelte';
|
|
|
|
interface Props {
|
|
conversaId: Id<'conversas'>;
|
|
}
|
|
|
|
type ParticipanteInfo = {
|
|
_id: Id<'usuarios'>;
|
|
nome: string;
|
|
email?: string;
|
|
fotoPerfilUrl?: string;
|
|
avatar?: string;
|
|
};
|
|
|
|
type ConversaComParticipantes = {
|
|
_id: Id<'conversas'>;
|
|
tipo: 'individual' | 'grupo' | 'sala_reuniao';
|
|
participantesInfo?: ParticipanteInfo[];
|
|
};
|
|
|
|
const { conversaId }: Props = $props();
|
|
|
|
const client = useConvexClient();
|
|
const conversas = useQuery(api.chat.listarConversas, {});
|
|
|
|
let mensagem = $state('');
|
|
let textarea: HTMLTextAreaElement;
|
|
let enviando = $state(false);
|
|
let uploadingFile = $state(false);
|
|
let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let showEmojiPicker = $state(false);
|
|
let mensagemRespondendo: {
|
|
id: Id<'mensagens'>;
|
|
conteudo: string;
|
|
remetente: string;
|
|
} | null = $state(null);
|
|
let showMentionsDropdown = $state(false);
|
|
let mentionQuery = $state('');
|
|
let mentionStartPos = $state(0);
|
|
|
|
// Emojis mais usados
|
|
const emojis = [
|
|
'😀',
|
|
'😃',
|
|
'😄',
|
|
'😁',
|
|
'😅',
|
|
'😂',
|
|
'🤣',
|
|
'😊',
|
|
'😇',
|
|
'🙂',
|
|
'🙃',
|
|
'😉',
|
|
'😌',
|
|
'😍',
|
|
'🥰',
|
|
'😘',
|
|
'😗',
|
|
'😙',
|
|
'😚',
|
|
'😋',
|
|
'😛',
|
|
'😝',
|
|
'😜',
|
|
'🤪',
|
|
'🤨',
|
|
'🧐',
|
|
'🤓',
|
|
'😎',
|
|
'🥳',
|
|
'😏',
|
|
'👍',
|
|
'👎',
|
|
'👏',
|
|
'🙌',
|
|
'🤝',
|
|
'🙏',
|
|
'💪',
|
|
'✨',
|
|
'🎉',
|
|
'🎊',
|
|
'❤️',
|
|
'💙',
|
|
'💚',
|
|
'💛',
|
|
'🧡',
|
|
'💜',
|
|
'🖤',
|
|
'🤍',
|
|
'💯',
|
|
'🔥'
|
|
];
|
|
|
|
function adicionarEmoji(emoji: string) {
|
|
mensagem += emoji;
|
|
showEmojiPicker = false;
|
|
if (textarea) {
|
|
textarea.focus();
|
|
}
|
|
}
|
|
|
|
// Obter conversa atual
|
|
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)
|
|
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
|
|
const participantesFiltrados = $derived((): ParticipanteInfo[] => {
|
|
if (!mentionQuery.trim()) return participantesParaMencoes().slice(0, 5);
|
|
const query = mentionQuery.toLowerCase();
|
|
return participantesParaMencoes()
|
|
.filter(
|
|
(p) =>
|
|
p.nome?.toLowerCase().includes(query) ||
|
|
(p.email && p.email.toLowerCase().includes(query))
|
|
)
|
|
.slice(0, 5);
|
|
});
|
|
|
|
// Auto-resize do textarea e detectar menções
|
|
function handleInput(e: Event) {
|
|
const target = e.target as HTMLTextAreaElement;
|
|
if (textarea) {
|
|
textarea.style.height = 'auto';
|
|
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
|
|
}
|
|
|
|
// Detectar menções (@)
|
|
const cursorPos = target.selectionStart || 0;
|
|
const textBeforeCursor = mensagem.substring(0, cursorPos);
|
|
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
|
|
|
if (lastAtIndex !== -1) {
|
|
const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1);
|
|
// Se não há espaço após o @, mostrar dropdown
|
|
if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
|
|
mentionQuery = textAfterAt;
|
|
mentionStartPos = lastAtIndex;
|
|
showMentionsDropdown = true;
|
|
return;
|
|
}
|
|
}
|
|
|
|
showMentionsDropdown = false;
|
|
|
|
// Indicador de digitação (debounce de 1s)
|
|
if (digitacaoTimeout) {
|
|
clearTimeout(digitacaoTimeout);
|
|
}
|
|
digitacaoTimeout = setTimeout(() => {
|
|
if (mensagem.trim()) {
|
|
client.mutation(api.chat.indicarDigitacao, { conversaId });
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function inserirMencao(participante: ParticipanteInfo) {
|
|
const nome = participante.nome.split(' ')[0]; // Usar primeiro nome
|
|
const antes = mensagem.substring(0, mentionStartPos);
|
|
const depois = mensagem.substring(textarea.selectionStart || mensagem.length);
|
|
mensagem = antes + `@${nome} ` + depois;
|
|
showMentionsDropdown = false;
|
|
mentionQuery = '';
|
|
if (textarea) {
|
|
textarea.focus();
|
|
const newPos = antes.length + nome.length + 2;
|
|
setTimeout(() => {
|
|
textarea.setSelectionRange(newPos, newPos);
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
async function handleEnviar() {
|
|
const texto = mensagem.trim();
|
|
if (!texto || enviando) return;
|
|
|
|
// Extrair menções do texto (@nome)
|
|
const mencoesIds: Id<'usuarios'>[] = [];
|
|
const mentionRegex = /@(\w+)/g;
|
|
let match;
|
|
while ((match = mentionRegex.exec(texto)) !== null) {
|
|
const nomeMencionado = match[1];
|
|
const participante = participantesParaMencoes().find(
|
|
(p) => p.nome.split(' ')[0].toLowerCase() === nomeMencionado.toLowerCase()
|
|
);
|
|
if (participante) {
|
|
mencoesIds.push(participante._id);
|
|
}
|
|
}
|
|
|
|
console.log('📤 [MessageInput] Enviando mensagem:', {
|
|
conversaId,
|
|
conteudo: texto,
|
|
tipo: 'texto',
|
|
respostaPara: mensagemRespondendo?.id,
|
|
mencoes: mencoesIds
|
|
});
|
|
|
|
try {
|
|
enviando = true;
|
|
const result = await client.mutation(api.chat.enviarMensagem, {
|
|
conversaId,
|
|
conteudo: texto,
|
|
tipo: 'texto',
|
|
respostaPara: mensagemRespondendo?.id,
|
|
mencoes: mencoesIds.length > 0 ? mencoesIds : undefined
|
|
});
|
|
|
|
console.log('✅ [MessageInput] Mensagem enviada com sucesso! ID:', result);
|
|
|
|
mensagem = '';
|
|
mensagemRespondendo = null;
|
|
showMentionsDropdown = false;
|
|
mentionQuery = '';
|
|
if (textarea) {
|
|
textarea.style.height = 'auto';
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ [MessageInput] Erro ao enviar mensagem:', error);
|
|
alert('Erro ao enviar mensagem');
|
|
} finally {
|
|
enviando = false;
|
|
}
|
|
}
|
|
|
|
function cancelarResposta() {
|
|
mensagemRespondendo = null;
|
|
}
|
|
|
|
type MensagemComRemetente = {
|
|
_id: Id<'mensagens'>;
|
|
conteudo: string;
|
|
remetente?: { nome: string } | null;
|
|
};
|
|
|
|
// Escutar evento de resposta
|
|
onMount(() => {
|
|
const handler = (e: Event) => {
|
|
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(
|
|
(m) => m._id === customEvent.detail.mensagemId
|
|
);
|
|
if (msg) {
|
|
mensagemRespondendo = {
|
|
id: msg._id,
|
|
conteudo: msg.conteudo.substring(0, 100),
|
|
remetente: msg.remetente?.nome || 'Usuário'
|
|
};
|
|
textarea?.focus();
|
|
}
|
|
});
|
|
};
|
|
|
|
window.addEventListener('responderMensagem', handler);
|
|
return () => {
|
|
window.removeEventListener('responderMensagem', handler);
|
|
};
|
|
});
|
|
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
// Navegar dropdown de menções
|
|
if (showMentionsDropdown && participantesFiltrados().length > 0) {
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
|
|
e.preventDefault();
|
|
// Implementação simples: selecionar primeiro participante
|
|
if (e.key === 'Enter') {
|
|
inserirMencao(participantesFiltrados()[0]);
|
|
}
|
|
return;
|
|
}
|
|
if (e.key === 'Escape') {
|
|
showMentionsDropdown = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Enter sem Shift = enviar
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleEnviar();
|
|
}
|
|
}
|
|
|
|
async function handleFileUpload(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
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.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
uploadingFile = true;
|
|
|
|
// 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
|
|
});
|
|
|
|
if (!result.ok) {
|
|
throw new Error('Falha no upload');
|
|
}
|
|
|
|
const { storageId } = await result.json();
|
|
|
|
// 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,
|
|
tipo,
|
|
arquivoId: storageId,
|
|
arquivoNome: file.name,
|
|
arquivoTamanho: file.size,
|
|
arquivoTipo: file.type
|
|
});
|
|
|
|
// Limpar input
|
|
input.value = '';
|
|
} catch (error) {
|
|
console.error('Erro ao fazer upload:', error);
|
|
alert('Erro ao enviar arquivo');
|
|
} finally {
|
|
uploadingFile = false;
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
if (textarea) {
|
|
textarea.focus();
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div class="p-4">
|
|
<!-- Preview da mensagem respondendo -->
|
|
{#if mensagemRespondendo}
|
|
<div class="bg-base-200 mb-2 flex items-center justify-between rounded-lg p-2">
|
|
<div class="flex-1">
|
|
<p class="text-base-content/70 text-xs font-medium">
|
|
Respondendo a {mensagemRespondendo.remetente}
|
|
</p>
|
|
<p class="text-base-content/50 truncate text-xs">
|
|
{mensagemRespondendo.conteudo}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="btn btn-xs btn-ghost"
|
|
onclick={cancelarResposta}
|
|
title="Cancelar resposta"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<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}
|
|
/>
|
|
{/if}
|
|
</label>
|
|
|
|
<!-- Botão de EMOJI MODERNO -->
|
|
<div class="relative shrink-0">
|
|
<button
|
|
type="button"
|
|
class="group relative flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
|
|
style="background: rgba(255, 193, 7, 0.1); border: 1px solid rgba(255, 193, 7, 0.2);"
|
|
onclick={() => (showEmojiPicker = !showEmojiPicker)}
|
|
disabled={enviando || uploadingFile}
|
|
aria-label="Adicionar emoji"
|
|
title="Adicionar emoji"
|
|
>
|
|
<div
|
|
class="bg-warning/0 group-hover:bg-warning/10 absolute inset-0 transition-colors duration-300"
|
|
></div>
|
|
<Smile
|
|
class="text-warning relative z-10 h-5 w-5 transition-transform group-hover:scale-110"
|
|
strokeWidth={2}
|
|
/>
|
|
</button>
|
|
|
|
<!-- Picker de Emojis -->
|
|
{#if showEmojiPicker}
|
|
<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;"
|
|
>
|
|
<div class="grid grid-cols-10 gap-1">
|
|
{#each emojis as emoji}
|
|
<button
|
|
type="button"
|
|
class="hover:bg-base-200 cursor-pointer rounded p-1 text-2xl transition-transform hover:scale-125"
|
|
onclick={() => adicionarEmoji(emoji)}
|
|
>
|
|
{emoji}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Textarea -->
|
|
<div class="relative flex-1">
|
|
<textarea
|
|
bind:this={textarea}
|
|
bind:value={mensagem}
|
|
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"
|
|
rows="1"
|
|
disabled={enviando || uploadingFile}
|
|
></textarea>
|
|
|
|
<!-- 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"
|
|
>
|
|
{#each participantesFiltrados() as participante (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"
|
|
onclick={() => inserirMencao(participante)}
|
|
>
|
|
<div
|
|
class="bg-primary/20 flex h-8 w-8 items-center justify-center overflow-hidden rounded-full"
|
|
>
|
|
{#if participante.fotoPerfilUrl}
|
|
<img
|
|
src={participante.fotoPerfilUrl}
|
|
alt={participante.nome}
|
|
class="h-full w-full object-cover"
|
|
/>
|
|
{:else}
|
|
<span class="text-xs font-semibold"
|
|
>{participante.nome.charAt(0).toUpperCase()}</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate text-sm font-medium">{participante.nome}</p>
|
|
<p class="text-base-content/60 truncate text-xs">
|
|
@{participante.nome.split(' ')[0]}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</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"
|
|
>
|
|
<div
|
|
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/10"
|
|
></div>
|
|
{#if enviando}
|
|
<span class="loading loading-spinner loading-sm relative z-10 text-white"></span>
|
|
{:else}
|
|
<!-- Ícone de avião de papel moderno -->
|
|
<Send
|
|
class="relative z-10 h-5 w-5 text-white transition-all group-hover:translate-x-1 group-hover:scale-110"
|
|
/>
|
|
{/if}
|
|
</button>
|
|
</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>
|
|
</div>
|