Files
sgse-app/apps/web/src/lib/components/chat/MessageInput.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>