feat: implement comprehensive chat system with user presence management, notification handling, and avatar integration; enhance UI components for improved user experience
This commit is contained in:
253
apps/web/src/lib/components/chat/MessageList.svelte
Normal file
253
apps/web/src/lib/components/chat/MessageList.svelte
Normal file
@@ -0,0 +1,253 @@
|
||||
<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 { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { onMount, tick } from "svelte";
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<"conversas">;
|
||||
}
|
||||
|
||||
let { conversaId }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const mensagens = useQuery(api.chat.obterMensagens, { conversaId, limit: 50 });
|
||||
const digitando = useQuery(api.chat.obterDigitando, { conversaId });
|
||||
|
||||
let messagesContainer: HTMLDivElement;
|
||||
let shouldScrollToBottom = true;
|
||||
|
||||
// Auto-scroll para a última mensagem
|
||||
$effect(() => {
|
||||
if (mensagens && shouldScrollToBottom && messagesContainer) {
|
||||
tick().then(() => {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Marcar como lida quando mensagens carregam
|
||||
$effect(() => {
|
||||
if (mensagens && mensagens.length > 0) {
|
||||
const ultimaMensagem = mensagens[mensagens.length - 1];
|
||||
client.mutation(api.chat.marcarComoLida, {
|
||||
conversaId,
|
||||
mensagemId: ultimaMensagem._id as any,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function formatarDataMensagem(timestamp: number): string {
|
||||
try {
|
||||
return format(new Date(timestamp), "HH:mm", { locale: ptBR });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatarDiaMensagem(timestamp: number): string {
|
||||
try {
|
||||
return format(new Date(timestamp), "dd/MM/yyyy", { locale: ptBR });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function agruparMensagensPorDia(msgs: any[]): Record<string, any[]> {
|
||||
const grupos: Record<string, any[]> = {};
|
||||
for (const msg of msgs) {
|
||||
const dia = formatarDiaMensagem(msg.enviadaEm);
|
||||
if (!grupos[dia]) {
|
||||
grupos[dia] = [];
|
||||
}
|
||||
grupos[dia].push(msg);
|
||||
}
|
||||
return grupos;
|
||||
}
|
||||
|
||||
function handleScroll(e: Event) {
|
||||
const target = e.target as HTMLDivElement;
|
||||
const isAtBottom =
|
||||
target.scrollHeight - target.scrollTop - target.clientHeight < 100;
|
||||
shouldScrollToBottom = isAtBottom;
|
||||
}
|
||||
|
||||
async function handleReagir(mensagemId: string, emoji: string) {
|
||||
await client.mutation(api.chat.reagirMensagem, {
|
||||
mensagemId: mensagemId as any,
|
||||
emoji,
|
||||
});
|
||||
}
|
||||
|
||||
function getEmojisReacao(mensagem: any): Array<{ emoji: string; count: number }> {
|
||||
if (!mensagem.reagiuPor || mensagem.reagiuPor.length === 0) return [];
|
||||
|
||||
const emojiMap: Record<string, number> = {};
|
||||
for (const reacao of mensagem.reagiuPor) {
|
||||
emojiMap[reacao.emoji] = (emojiMap[reacao.emoji] || 0) + 1;
|
||||
}
|
||||
|
||||
return Object.entries(emojiMap).map(([emoji, count]) => ({ emoji, count }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="h-full overflow-y-auto px-4 py-4 bg-base-100"
|
||||
bind:this={messagesContainer}
|
||||
onscroll={handleScroll}
|
||||
>
|
||||
{#if mensagens && mensagens.length > 0}
|
||||
{@const gruposPorDia = agruparMensagensPorDia(mensagens)}
|
||||
{#each Object.entries(gruposPorDia) as [dia, mensagensDia]}
|
||||
<!-- Separador de dia -->
|
||||
<div class="flex items-center justify-center my-4">
|
||||
<div class="px-3 py-1 rounded-full bg-base-300 text-base-content/70 text-xs">
|
||||
{dia}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensagens do dia -->
|
||||
{#each mensagensDia as mensagem (mensagem._id)}
|
||||
{@const isMinha = mensagem.remetente?._id === mensagens[0]?.remetente?._id}
|
||||
<div class={`flex mb-4 ${isMinha ? "justify-end" : "justify-start"}`}>
|
||||
<div class={`max-w-[75%] ${isMinha ? "items-end" : "items-start"}`}>
|
||||
<!-- Nome do remetente (apenas se não for minha) -->
|
||||
{#if !isMinha}
|
||||
<p class="text-xs text-base-content/60 mb-1 px-3">
|
||||
{mensagem.remetente?.nome || "Usuário"}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Balão da mensagem -->
|
||||
<div
|
||||
class={`rounded-2xl px-4 py-2 ${
|
||||
isMinha
|
||||
? "bg-primary text-primary-content rounded-br-sm"
|
||||
: "bg-base-200 text-base-content rounded-bl-sm"
|
||||
}`}
|
||||
>
|
||||
{#if mensagem.deletada}
|
||||
<p class="text-sm italic opacity-70">Mensagem deletada</p>
|
||||
{:else if mensagem.tipo === "texto"}
|
||||
<p class="text-sm whitespace-pre-wrap break-words">{mensagem.conteudo}</p>
|
||||
{:else if mensagem.tipo === "imagem"}
|
||||
<div class="mb-2">
|
||||
<img
|
||||
src={mensagem.arquivoUrl}
|
||||
alt={mensagem.arquivoNome}
|
||||
class="max-w-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
{#if mensagem.conteudo}
|
||||
<p class="text-sm whitespace-pre-wrap break-words">{mensagem.conteudo}</p>
|
||||
{/if}
|
||||
{:else if mensagem.tipo === "arquivo"}
|
||||
<a
|
||||
href={mensagem.arquivoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 hover:opacity-80"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium">{mensagem.arquivoNome}</p>
|
||||
{#if mensagem.arquivoTamanho}
|
||||
<p class="text-xs opacity-70">
|
||||
{(mensagem.arquivoTamanho / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- Reações -->
|
||||
{#if !mensagem.deletada && getEmojisReacao(mensagem).length > 0}
|
||||
<div class="flex items-center gap-1 mt-2">
|
||||
{#each getEmojisReacao(mensagem) as reacao}
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-base-300/50 hover:bg-base-300"
|
||||
onclick={() => handleReagir(mensagem._id, reacao.emoji)}
|
||||
>
|
||||
{reacao.emoji} {reacao.count}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<p
|
||||
class={`text-xs text-base-content/50 mt-1 px-3 ${isMinha ? "text-right" : "text-left"}`}
|
||||
>
|
||||
{formatarDataMensagem(mensagem.enviadaEm)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
<!-- Indicador de digitação -->
|
||||
{#if digitando && digitando.length > 0}
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-2 h-2 rounded-full bg-base-content/50 animate-bounce"></div>
|
||||
<div
|
||||
class="w-2 h-2 rounded-full bg-base-content/50 animate-bounce"
|
||||
style="animation-delay: 0.1s;"
|
||||
></div>
|
||||
<div
|
||||
class="w-2 h-2 rounded-full bg-base-content/50 animate-bounce"
|
||||
style="animation-delay: 0.2s;"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{digitando.map((u: any) => u.nome).join(", ")} {digitando.length === 1
|
||||
? "está digitando"
|
||||
: "estão digitando"}...
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if !mensagens}
|
||||
<!-- Loading -->
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Vazio -->
|
||||
<div class="flex flex-col items-center justify-center h-full text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-16 h-16 text-base-content/30 mb-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-base-content/70">Nenhuma mensagem ainda</p>
|
||||
<p class="text-sm text-base-content/50 mt-1">Envie a primeira mensagem!</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user