Files
sgse-app/apps/web/src/lib/components/chat/MessageInput.svelte
killer-cf 9a5f2b294d refactor: integrate current user data across components
- Replaced instances of `authStore` with `currentUser` to streamline user authentication handling.
- Updated permission checks and user-related data retrieval to utilize the new `useQuery` for better performance and clarity.
- Cleaned up component structures and improved formatting for consistency and readability.
- Enhanced error handling and user feedback mechanisms in various components to improve user experience.
2025-11-08 10:52:33 -03:00

550 lines
16 KiB
Svelte

<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 { onMount } from "svelte";
import { Paperclip, Smile, Send } from "lucide-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[];
};
let { 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="mb-2 p-2 bg-base-200 rounded-lg flex items-center justify-between"
>
<div class="flex-1">
<p class="text-xs font-medium text-base-content/70">
Respondendo a {mensagemRespondendo.remetente}
</p>
<p class="text-xs text-base-content/50 truncate">
{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="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden cursor-pointer shrink-0"
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="absolute inset-0 bg-primary/0 group-hover:bg-primary/10 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="w-5 h-5 text-primary relative z-10 group-hover:scale-110 transition-transform"
strokeWidth={2}
/>
{/if}
</label>
<!-- Botão de EMOJI MODERNO -->
<div class="relative shrink-0">
<button
type="button"
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden"
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="absolute inset-0 bg-warning/0 group-hover:bg-warning/10 transition-colors duration-300"
></div>
<Smile
class="w-5 h-5 text-warning relative z-10 group-hover:scale-110 transition-transform"
strokeWidth={2}
/>
</button>
<!-- Picker de Emojis -->
{#if showEmojiPicker}
<div
class="absolute bottom-full left-0 mb-2 p-3 bg-base-100 rounded-xl shadow-2xl border border-base-300 z-50"
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="text-2xl hover:scale-125 transition-transform cursor-pointer p-1 hover:bg-base-200 rounded"
onclick={() => adicionarEmoji(emoji)}
>
{emoji}
</button>
{/each}
</div>
</div>
{/if}
</div>
<!-- Textarea -->
<div class="flex-1 relative">
<textarea
bind:this={textarea}
bind:value={mensagem}
oninput={handleInput}
onkeydown={handleKeyDown}
placeholder="Digite uma mensagem... (use @ para mencionar)"
class="textarea textarea-bordered w-full resize-none min-h-[44px] max-h-[120px] 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="absolute bottom-full left-0 mb-2 bg-base-100 rounded-lg shadow-xl border border-base-300 z-50 w-64 max-h-48 overflow-y-auto"
>
{#each participantesFiltrados() as participante (participante._id)}
<button
type="button"
class="w-full text-left px-4 py-2 hover:bg-base-200 transition-colors flex items-center gap-2"
onclick={() => inserirMencao(participante)}
>
<div
class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center overflow-hidden"
>
{#if participante.fotoPerfilUrl}
<img
src={participante.fotoPerfilUrl}
alt={participante.nome}
class="w-full h-full object-cover"
/>
{:else}
<span class="text-xs font-semibold"
>{participante.nome.charAt(0).toUpperCase()}</span
>
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{participante.nome}</p>
<p class="text-xs text-base-content/60 truncate">
@{participante.nome.split(" ")[0]}
</p>
</div>
</button>
{/each}
</div>
{/if}
</div>
<!-- Botão de enviar MODERNO -->
<button
type="button"
class="flex items-center justify-center w-12 h-12 rounded-xl transition-all duration-300 group relative overflow-hidden shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
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 group-hover:bg-white/10 transition-colors duration-300"
></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="w-5 h-5 text-white relative z-10 group-hover:scale-110 group-hover:translate-x-1 transition-all"
/>
{/if}
</button>
</div>
<!-- Informação sobre atalhos -->
<p class="text-xs text-base-content/50 mt-2 text-center">
💡 Enter para enviar • Shift+Enter para quebrar linha • 😊 Clique no emoji
</p>
</div>