refactor: enhance chat components with type safety and response functionality
- Updated type definitions in ChatWindow and MessageList components for better type safety. - Improved MessageInput to handle message responses, including a preview feature for replying to messages. - Enhanced the chat message handling logic to support message references and improve user interaction. - Refactored notification utility functions to support push notifications and rate limiting for email sending. - Updated backend schema to accommodate new features related to message responses and notifications.
This commit is contained in:
75
apps/web/src/lib/components/PushNotificationManager.svelte
Normal file
75
apps/web/src/lib/components/PushNotificationManager.svelte
Normal file
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
import {
|
||||
registrarServiceWorker,
|
||||
solicitarPushSubscription,
|
||||
subscriptionToJSON,
|
||||
removerPushSubscription,
|
||||
} from "$lib/utils/notifications";
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
onMount(async () => {
|
||||
// Aguardar usuário estar autenticado
|
||||
const checkAuth = setInterval(async () => {
|
||||
if (authStore.usuario) {
|
||||
clearInterval(checkAuth);
|
||||
await registrarPushSubscription();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Limpar intervalo após 30 segundos (timeout)
|
||||
setTimeout(() => {
|
||||
clearInterval(checkAuth);
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
clearInterval(checkAuth);
|
||||
};
|
||||
});
|
||||
|
||||
async function registrarPushSubscription() {
|
||||
try {
|
||||
// Solicitar subscription
|
||||
const subscription = await solicitarPushSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
console.log("ℹ️ Push subscription não disponível ou permissão negada");
|
||||
return;
|
||||
}
|
||||
|
||||
// Converter para formato serializável
|
||||
const subscriptionData = subscriptionToJSON(subscription);
|
||||
|
||||
// Registrar no backend
|
||||
const resultado = await client.mutation(api.pushNotifications.registrarPushSubscription, {
|
||||
endpoint: subscriptionData.endpoint,
|
||||
keys: subscriptionData.keys,
|
||||
userAgent: navigator.userAgent,
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
console.log("✅ Push subscription registrada com sucesso");
|
||||
} else {
|
||||
console.error("❌ Erro ao registrar push subscription:", resultado.erro);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao configurar push notifications:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Remover subscription ao fazer logout
|
||||
$effect(() => {
|
||||
if (!authStore.usuario) {
|
||||
removerPushSubscription().then(() => {
|
||||
console.log("Push subscription removida");
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Componente invisível - apenas lógica -->
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
const encontrada = conversas.data.find((c: any) => c._id === conversaId);
|
||||
const encontrada = conversas.data.find((c: { _id: string }) => c._id === conversaId);
|
||||
console.log("✅ [ChatWindow] Conversa encontrada:", encontrada);
|
||||
return encontrada;
|
||||
});
|
||||
@@ -54,10 +54,10 @@
|
||||
return "👤";
|
||||
}
|
||||
|
||||
function getStatusConversa(): any {
|
||||
function getStatusConversa(): "online" | "offline" | "ausente" | "externo" | "em_reuniao" | null {
|
||||
const c = conversa();
|
||||
if (c && c.tipo === "individual" && c.outroUsuario) {
|
||||
return c.outroUsuario.statusPresenca || "offline";
|
||||
return (c.outroUsuario.statusPresenca as "online" | "offline" | "ausente" | "externo" | "em_reuniao") || "offline";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -169,20 +169,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Mensagens -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<MessageList conversaId={conversaId as any} />
|
||||
<div class="flex-1 overflow-hidden min-h-0">
|
||||
<MessageList conversaId={conversaId as Id<"conversas">} />
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="border-t border-base-300">
|
||||
<MessageInput conversaId={conversaId as any} />
|
||||
<div class="border-t border-base-300 flex-shrink-0">
|
||||
<MessageInput conversaId={conversaId as Id<"conversas">} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Agendamento -->
|
||||
{#if showScheduleModal}
|
||||
<ScheduleMessageModal
|
||||
conversaId={conversaId as any}
|
||||
conversaId={conversaId as Id<"conversas">}
|
||||
onClose={() => (showScheduleModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
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);
|
||||
|
||||
// Emojis mais usados
|
||||
const emojis = [
|
||||
@@ -62,6 +63,7 @@
|
||||
conversaId,
|
||||
conteudo: texto,
|
||||
tipo: "texto",
|
||||
respostaPara: mensagemRespondendo?.id,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -70,11 +72,13 @@
|
||||
conversaId,
|
||||
conteudo: texto,
|
||||
tipo: "texto",
|
||||
respostaPara: mensagemRespondendo?.id,
|
||||
});
|
||||
|
||||
console.log("✅ [MessageInput] Mensagem enviada com sucesso! ID:", result);
|
||||
|
||||
mensagem = "";
|
||||
mensagemRespondendo = null;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
}
|
||||
@@ -86,6 +90,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
function cancelarResposta() {
|
||||
mensagemRespondendo = 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.find((m: any) => 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) {
|
||||
// Enter sem Shift = enviar
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
@@ -154,6 +186,24 @@
|
||||
</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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { onMount, tick } from "svelte";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<"conversas">;
|
||||
@@ -18,33 +19,43 @@
|
||||
|
||||
let messagesContainer: HTMLDivElement;
|
||||
let shouldScrollToBottom = true;
|
||||
let lastMessageCount = 0;
|
||||
|
||||
// DEBUG: Log quando mensagens mudam
|
||||
$effect(() => {
|
||||
console.log("💬 [MessageList] Mensagens atualizadas:", {
|
||||
conversaId,
|
||||
count: mensagens?.data?.length || 0,
|
||||
mensagens: mensagens?.data,
|
||||
});
|
||||
});
|
||||
// Obter ID do usuário atual
|
||||
const usuarioAtualId = $derived(authStore.usuario?._id);
|
||||
|
||||
// Auto-scroll para a última mensagem
|
||||
// Auto-scroll para a última mensagem quando novas mensagens chegam
|
||||
$effect(() => {
|
||||
if (mensagens?.data && shouldScrollToBottom && messagesContainer) {
|
||||
tick().then(() => {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
});
|
||||
if (mensagens?.data && messagesContainer) {
|
||||
const currentCount = mensagens.data.length;
|
||||
const isNewMessage = currentCount > lastMessageCount;
|
||||
|
||||
if (isNewMessage || shouldScrollToBottom) {
|
||||
// Usar requestAnimationFrame para garantir que o DOM foi atualizado
|
||||
requestAnimationFrame(() => {
|
||||
tick().then(() => {
|
||||
if (messagesContainer) {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
lastMessageCount = currentCount;
|
||||
}
|
||||
});
|
||||
|
||||
// Marcar como lida quando mensagens carregam
|
||||
$effect(() => {
|
||||
if (mensagens?.data && mensagens.data.length > 0) {
|
||||
if (mensagens?.data && mensagens.data.length > 0 && usuarioAtualId) {
|
||||
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
|
||||
client.mutation(api.chat.marcarComoLida, {
|
||||
conversaId,
|
||||
mensagemId: ultimaMensagem._id as any,
|
||||
});
|
||||
// Só marcar como lida se não for minha mensagem
|
||||
if (ultimaMensagem.remetente?._id !== usuarioAtualId) {
|
||||
client.mutation(api.chat.marcarComoLida, {
|
||||
conversaId,
|
||||
mensagemId: ultimaMensagem._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,8 +75,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
function agruparMensagensPorDia(msgs: any[]): Record<string, any[]> {
|
||||
const grupos: Record<string, any[]> = {};
|
||||
interface Mensagem {
|
||||
_id: Id<"mensagens">;
|
||||
remetente?: {
|
||||
_id: Id<"usuarios">;
|
||||
nome: string;
|
||||
} | null;
|
||||
conteudo: string;
|
||||
tipo: "texto" | "arquivo" | "imagem";
|
||||
enviadaEm: number;
|
||||
editadaEm?: number;
|
||||
deletada?: boolean;
|
||||
agendadaPara?: number;
|
||||
respostaPara?: Id<"mensagens">;
|
||||
mensagemOriginal?: {
|
||||
_id: Id<"mensagens">;
|
||||
conteudo: string;
|
||||
remetente: {
|
||||
_id: Id<"usuarios">;
|
||||
nome: string;
|
||||
} | null;
|
||||
deletada: boolean;
|
||||
} | null;
|
||||
reagiuPor?: Array<{
|
||||
usuarioId: Id<"usuarios">;
|
||||
emoji: string;
|
||||
}>;
|
||||
arquivoUrl?: string | null;
|
||||
arquivoNome?: string;
|
||||
arquivoTamanho?: number;
|
||||
linkPreview?: {
|
||||
url: string;
|
||||
titulo?: string;
|
||||
descricao?: string;
|
||||
imagem?: string;
|
||||
site?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function agruparMensagensPorDia(msgs: Mensagem[]): Record<string, Mensagem[]> {
|
||||
const grupos: Record<string, Mensagem[]> = {};
|
||||
for (const msg of msgs) {
|
||||
const dia = formatarDiaMensagem(msg.enviadaEm);
|
||||
if (!grupos[dia]) {
|
||||
@@ -83,14 +132,14 @@
|
||||
shouldScrollToBottom = isAtBottom;
|
||||
}
|
||||
|
||||
async function handleReagir(mensagemId: string, emoji: string) {
|
||||
async function handleReagir(mensagemId: Id<"mensagens">, emoji: string) {
|
||||
await client.mutation(api.chat.reagirMensagem, {
|
||||
mensagemId: mensagemId as any,
|
||||
mensagemId,
|
||||
emoji,
|
||||
});
|
||||
}
|
||||
|
||||
function getEmojisReacao(mensagem: any): Array<{ emoji: string; count: number }> {
|
||||
function getEmojisReacao(mensagem: Mensagem): Array<{ emoji: string; count: number }> {
|
||||
if (!mensagem.reagiuPor || mensagem.reagiuPor.length === 0) return [];
|
||||
|
||||
const emojiMap: Record<string, number> = {};
|
||||
@@ -100,6 +149,64 @@
|
||||
|
||||
return Object.entries(emojiMap).map(([emoji, count]) => ({ emoji, count }));
|
||||
}
|
||||
|
||||
let mensagemEditando: Mensagem | null = $state(null);
|
||||
let novoConteudoEditado = $state("");
|
||||
|
||||
async function editarMensagem(mensagem: Mensagem) {
|
||||
mensagemEditando = mensagem;
|
||||
novoConteudoEditado = mensagem.conteudo;
|
||||
}
|
||||
|
||||
async function salvarEdicao() {
|
||||
if (!mensagemEditando || !novoConteudoEditado.trim()) return;
|
||||
|
||||
try {
|
||||
const resultado = await client.mutation(api.chat.editarMensagem, {
|
||||
mensagemId: mensagemEditando._id,
|
||||
novoConteudo: novoConteudoEditado.trim(),
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
mensagemEditando = null;
|
||||
novoConteudoEditado = "";
|
||||
} else {
|
||||
alert(resultado.erro || "Erro ao editar mensagem");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao editar mensagem:", error);
|
||||
alert("Erro ao editar mensagem");
|
||||
}
|
||||
}
|
||||
|
||||
function cancelarEdicao() {
|
||||
mensagemEditando = null;
|
||||
novoConteudoEditado = "";
|
||||
}
|
||||
|
||||
async function deletarMensagem(mensagemId: Id<"mensagens">) {
|
||||
if (!confirm("Tem certeza que deseja deletar esta mensagem?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await client.mutation(api.chat.deletarMensagem, {
|
||||
mensagemId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao deletar mensagem:", error);
|
||||
alert("Erro ao deletar mensagem");
|
||||
}
|
||||
}
|
||||
|
||||
// Função para responder mensagem (será passada via props ou event)
|
||||
function responderMensagem(mensagem: Mensagem) {
|
||||
// Disparar evento customizado para o componente pai
|
||||
const event = new CustomEvent("responderMensagem", {
|
||||
detail: { mensagemId: mensagem._id },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -119,9 +226,9 @@
|
||||
|
||||
<!-- Mensagens do dia -->
|
||||
{#each mensagensDia as mensagem (mensagem._id)}
|
||||
{@const isMinha = mensagem.remetente?._id === mensagens.data[0]?.remetente?._id}
|
||||
<div class={`flex mb-4 ${isMinha ? "justify-end" : "justify-start"}`}>
|
||||
<div class={`max-w-[75%] ${isMinha ? "items-end" : "items-start"}`}>
|
||||
{@const isMinha = mensagem.remetente?._id === usuarioAtualId}
|
||||
<div class={`flex mb-4 w-full ${isMinha ? "justify-end" : "justify-start"}`}>
|
||||
<div class={`flex flex-col 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">
|
||||
@@ -137,10 +244,92 @@
|
||||
: "bg-base-200 text-base-content rounded-bl-sm"
|
||||
}`}
|
||||
>
|
||||
{#if mensagem.deletada}
|
||||
{#if mensagem.mensagemOriginal}
|
||||
<!-- Preview da mensagem respondida -->
|
||||
<div class="mb-2 pl-3 border-l-2 border-base-content/20 opacity-70">
|
||||
<p class="text-xs font-medium">
|
||||
{mensagem.mensagemOriginal.remetente?.nome || "Usuário"}
|
||||
</p>
|
||||
<p class="text-xs truncate">
|
||||
{mensagem.mensagemOriginal.deletada
|
||||
? "Mensagem deletada"
|
||||
: mensagem.mensagemOriginal.conteudo}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if mensagemEditando?._id === mensagem._id}
|
||||
<!-- Modo de edição -->
|
||||
<div class="space-y-2">
|
||||
<textarea
|
||||
bind:value={novoConteudoEditado}
|
||||
class="w-full p-2 rounded-lg bg-base-100 text-base-content text-sm resize-none"
|
||||
rows="3"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
salvarEdicao();
|
||||
} else if (e.key === "Escape") {
|
||||
cancelarEdicao();
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={cancelarEdicao}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick={salvarEdicao}
|
||||
>
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else 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>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<p class="text-sm whitespace-pre-wrap break-words flex-1">{mensagem.conteudo}</p>
|
||||
{#if mensagem.editadaEm}
|
||||
<span class="text-xs opacity-50 italic" title="Editado">(editado)</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Preview de link -->
|
||||
{#if mensagem.linkPreview}
|
||||
<a
|
||||
href={mensagem.linkPreview.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block border border-base-300 rounded-lg overflow-hidden hover:border-primary transition-colors"
|
||||
>
|
||||
{#if mensagem.linkPreview.imagem}
|
||||
<img
|
||||
src={mensagem.linkPreview.imagem}
|
||||
alt={mensagem.linkPreview.titulo || "Preview"}
|
||||
class="w-full h-48 object-cover"
|
||||
onerror={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<div class="p-3 bg-base-200">
|
||||
{#if mensagem.linkPreview.site}
|
||||
<p class="text-xs text-base-content/50 mb-1">{mensagem.linkPreview.site}</p>
|
||||
{/if}
|
||||
{#if mensagem.linkPreview.titulo}
|
||||
<p class="text-sm font-medium text-base-content mb-1">{mensagem.linkPreview.titulo}</p>
|
||||
{/if}
|
||||
{#if mensagem.linkPreview.descricao}
|
||||
<p class="text-xs text-base-content/70 line-clamp-2">{mensagem.linkPreview.descricao}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if mensagem.tipo === "imagem"}
|
||||
<div class="mb-2">
|
||||
<img
|
||||
@@ -198,14 +387,45 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Botão de responder -->
|
||||
{#if !mensagem.deletada}
|
||||
<button
|
||||
class="text-xs text-base-content/50 hover:text-primary transition-colors mt-1"
|
||||
onclick={() => responderMensagem(mensagem)}
|
||||
title="Responder"
|
||||
>
|
||||
↪️ Responder
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<p
|
||||
class={`text-xs text-base-content/50 mt-1 px-3 ${isMinha ? "text-right" : "text-left"}`}
|
||||
>
|
||||
<!-- Timestamp e ações -->
|
||||
<div
|
||||
class={`flex items-center gap-2 mt-1 px-3 ${isMinha ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<p class="text-xs text-base-content/50">
|
||||
{formatarDataMensagem(mensagem.enviadaEm)}
|
||||
</p>
|
||||
{#if isMinha && !mensagem.deletada && !mensagem.agendadaPara}
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="text-xs text-base-content/50 hover:text-primary transition-colors"
|
||||
onclick={() => editarMensagem(mensagem)}
|
||||
title="Editar mensagem"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-base-content/50 hover:text-error transition-colors"
|
||||
onclick={() => deletarMensagem(mensagem._id)}
|
||||
title="Deletar mensagem"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -226,7 +446,7 @@
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{digitando.data.map((u: any) => u.nome).join(", ")} {digitando.data.length === 1
|
||||
{digitando.data.map((u: { nome: string }) => u.nome).join(", ")} {digitando.data.length === 1
|
||||
? "está digitando"
|
||||
: "estão digitando"}...
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user