- 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.
550 lines
16 KiB
Svelte
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>
|