feat(inbox): adicione mensagens flutuantes em todas as telas

This commit is contained in:
Saraiva
2026-09-16 02:11:06 -03:00
parent 7c109bc554
commit a87f55b6d5
13 changed files with 821 additions and 19 deletions
+10
View File
@@ -0,0 +1,10 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Responda conversas pelo painel de mensagens em qualquer tela
---
O botão Mensagens acompanha a navegação com contador de conversas não lidas, busca,
logos dos canais e atendimento compacto. Minimize sem perder o texto em edição ou
amplie a conversa no Inbox. Usa os mesmos canais, histórico e permissões existentes.
Crédito: @saraivabr.
+2
View File
@@ -6,6 +6,7 @@ import { BarraDeProgressoNavegacao } from "@/components/shell/BarraDeProgressoNa
import { useInboundMessageAlerts } from "@/hooks/notifications/useInboundMessageAlerts";
import { useCrmAlerts } from "@/hooks/notifications/useCrmAlerts";
import { useNotifyOpenFromServiceWorker } from "@/lib/notifications/notify_open";
import { FloatingInbox } from "@/components/inbox/FloatingInbox";
interface AppShellProps {
sidebarCollapsed: boolean;
@@ -45,6 +46,7 @@ export function AppShell({ sidebarCollapsed, children }: AppShellProps) {
<TopBar />
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
<FloatingInbox />
</div>
);
}
+15 -3
View File
@@ -3,6 +3,7 @@ import { useT } from "@/hooks/i18n/useT";
import {
forwardRef,
useImperativeHandle,
useEffect,
useRef,
useState,
type ClipboardEvent,
@@ -32,6 +33,10 @@ export interface ComposerHandle {
interface Props {
conversationId: string;
initialDraft?: string;
initialMode?: "reply" | "note";
onDraftChange?: (text: string, mode: "reply" | "note") => void;
active?: boolean;
disabled?: boolean;
/** Set true when contact is blocked / anonymized — explanation shown. */
blockedReason?: string | null;
@@ -63,6 +68,10 @@ interface Props {
export const Composer = forwardRef<ComposerHandle, Props>(function Composer(
{
conversationId,
initialDraft = "",
initialMode = "reply",
active = true,
onDraftChange,
disabled,
blockedReason,
janelaFechada,
@@ -74,11 +83,14 @@ export const Composer = forwardRef<ComposerHandle, Props>(function Composer(
ref,
) {
const t = useT();
const [text, setText] = useState("");
const [text, setText] = useState(initialDraft);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [contactPickerOpen, setContactPickerOpen] = useState(false);
const [menuDismissed, setMenuDismissed] = useState(false);
const [mode, setMode] = useState<"reply" | "note">("reply");
const [mode, setMode] = useState<"reply" | "note">(initialMode);
useEffect(() => {
onDraftChange?.(text, mode);
}, [text, mode, onDraftChange]);
const taRef = useRef<HTMLTextAreaElement | null>(null);
const send = useSendMessage();
const upload = useUploadMedia();
@@ -347,7 +359,7 @@ export const Composer = forwardRef<ComposerHandle, Props>(function Composer(
<PaperPlaneTilt size={16} weight="fill" aria-hidden />
</Button>
) : (
<AudioRecorder conversationId={conversationId} disabled={respostaBarrada} />
active && <AudioRecorder conversationId={conversationId} disabled={respostaBarrada} />
)}
</div>
</div>
+353
View File
@@ -0,0 +1,353 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/auth/AuthProvider";
import { useT } from "@/hooks/i18n/useT";
import { useConversationCounts } from "@/hooks/inbox/useConversationCounts";
import { useConversationsRealtime } from "@/hooks/inbox/useConversationsRealtime";
import { useConversation } from "@/hooks/inbox/useConversation";
import { useMarkAsRead } from "@/hooks/inbox/useMarkAsRead";
import { useRealtimeChannel } from "@/hooks/realtime/useRealtimeChannel";
import { OpenConversationProvider } from "@/hooks/notifications/OpenConversationContext";
import { useVoiceCall } from "@/components/voice/VoiceCallContext";
import { ConversationListItem } from "./ConversationListItem";
import { ChannelLogo } from "./ChannelLogo";
import { JanelaSelo } from "./JanelaSelo";
import { RetentionNotice } from "./RetentionNotice";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowsOutSimple, CaretLeft, ChatsCircle, PencilSimple, X } from "@/lib/ui/icons";
import { estadoDaJanela } from "@/lib/channels/janela";
import { rotuloDoContato } from "@/lib/contacts/rotulo-do-contato";
import { buscaValeConsulta } from "@/lib/inbox/termo-de-busca";
import { cn } from "@/lib/utils";
import type { Message } from "@/lib/types/messaging";
const ChatThread = dynamic(() => import("./ChatThread").then((m) => m.ChatThread));
const Composer = dynamic(() => import("./Composer").then((m) => m.Composer));
/** Mounted in the authenticated shell: navigation does not destroy the draft. */
export function FloatingInbox() {
const { activeOrg, user } = useAuth();
if (!activeOrg) return null;
return <InboxDock key={`${activeOrg.orgId}:${user.id}`} orgId={activeOrg.orgId} />;
}
function InboxDock({ orgId }: { orgId: string }) {
const t = useT();
const { call, minha } = useVoiceCall();
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, { text: string; mode: "reply" | "note" }>>(
{},
);
const trigger = useRef<HTMLButtonElement>(null);
const counts = useConversationCounts(orgId, { unread: true });
const qc = useQueryClient();
const refresh = useCallback(() => {
void qc.invalidateQueries({ queryKey: ["conversation-counts", orgId] });
if (selected) void qc.invalidateQueries({ queryKey: ["conversation", selected] });
}, [qc, orgId, selected]);
useRealtimeChannel({
name: `floating-inbox:${orgId}`,
postgresChanges: {
event: "*",
schema: "public",
table: "conversations",
filter: `organization_id=eq.${orgId}`,
},
onChange: refresh,
});
const saveDraft = useCallback(
(text: string, mode: "reply" | "note") => {
if (!selected) return;
setDrafts((previous) =>
previous[selected]?.text === text && previous[selected]?.mode === mode
? previous
: { ...previous, [selected]: { text, mode } },
);
},
[selected],
);
const unread = counts.data?.all ?? 0;
const elevated = minha && call && call.status !== "ended";
function minimize() {
setOpen(false);
requestAnimationFrame(() => trigger.current?.focus());
}
return (
<aside
aria-label={t("Mensagens rápidas")}
className={cn(
"fixed right-4 z-40 max-w-[calc(100vw-2rem)]",
elevated ? "bottom-24" : "bottom-4",
)}
onKeyDown={(event) => {
if (event.key === "Escape" && !event.defaultPrevented) {
event.stopPropagation();
minimize();
}
}}
>
<div
id="floating-inbox-panel"
className={cn(
"mb-3 flex h-[min(640px,calc(100dvh-8rem))] w-[380px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-2xl border border-border bg-background shadow-2xl",
!open && "hidden",
)}
>
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
{selected && (
<Button
size="icon"
variant="ghost"
aria-label={t("Voltar às conversas")}
onClick={() => setSelected(null)}
>
<CaretLeft size={18} aria-hidden />
</Button>
)}
<h2 className="min-w-0 flex-1 text-sm font-semibold">{t("Mensagens")}</h2>
{unread > 0 && (
<span className="rounded-full bg-destructive px-1.5 text-xs font-semibold text-destructive-foreground">
{unread > 99 ? "99+" : unread}
</span>
)}
<Link
href={selected ? `/app/inbox/${selected}` : "/app/inbox?filter=all"}
onClick={minimize}
aria-label={t("Abrir no Inbox")}
title={t("Abrir no Inbox")}
className="rounded-md p-2 hover:bg-muted focus-visible:outline-2"
>
<ArrowsOutSimple size={18} aria-hidden />
</Link>
<Button
size="icon"
variant="ghost"
aria-label={t("Minimizar mensagens")}
onClick={minimize}
>
<X size={18} aria-hidden />
</Button>
</div>
{selected ? (
<CompactConversation
key={selected}
conversationId={selected}
visible={open}
initialDraft={drafts[selected]?.text ?? ""}
initialMode={drafts[selected]?.mode ?? "reply"}
onDraftChange={saveDraft}
/>
) : open ? (
<DockList orgId={orgId} onSelect={setSelected} />
) : null}
</div>
<button
ref={trigger}
type="button"
aria-expanded={open}
aria-controls="floating-inbox-panel"
onClick={() => setOpen((value) => !value)}
className="ml-auto flex h-14 w-64 max-w-full items-center gap-3 rounded-full border border-border bg-background px-5 text-sm font-semibold shadow-xl transition-shadow hover:shadow-2xl focus-visible:outline-2 focus-visible:outline-offset-2"
>
<ChatsCircle size={24} aria-hidden />
<span>{t("Mensagens")}</span>
{unread > 0 && (
<span
aria-label={`${unread} ${t("conversas não lidas")}`}
className="ml-auto rounded-full bg-destructive px-2 py-0.5 text-xs text-destructive-foreground"
>
{unread > 99 ? "99+" : unread}
</span>
)}
{counts.isError && (
<span
className="ml-auto text-xs text-muted-foreground"
title={t("Não foi possível atualizar as mensagens.")}
>
!
</span>
)}
</button>
</aside>
);
}
function DockList({ orgId, onSelect }: { orgId: string; onSelect: (id: string) => void }) {
const t = useT();
const [search, setSearch] = useState("");
const [term, setTerm] = useState("");
useEffect(() => {
const timer = setTimeout(() => setTerm(search.trim()), 250);
return () => clearTimeout(timer);
}, [search]);
const query = useConversationsRealtime(
{ search: buscaValeConsulta(term) ? term : undefined },
orgId,
);
const conversations = query.data?.pages.flatMap((page) => page.data) ?? [];
return (
<>
<div className="shrink-0 p-3">
<Input
aria-label={t("Buscar conversas")}
placeholder={t("Buscar conversas")}
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{query.isPending ? (
<p className="p-5 text-sm text-muted-foreground" role="status">
{t("Carregando…")}
</p>
) : query.isError ? (
<div className="space-y-3 p-5 text-sm">
<p role="alert">{t("Não foi possível carregar as conversas.")}</p>
<Button variant="outline" onClick={() => void query.refetch()}>
{t("Tentar novamente")}
</Button>
</div>
) : conversations.length === 0 ? (
<p className="p-5 text-sm text-muted-foreground">{t("Nenhuma conversa encontrada.")}</p>
) : (
conversations.map((conversation) => (
<ConversationListItem
key={conversation.id}
conversation={conversation}
isSelected={false}
onSelect={onSelect}
mostrarCanal={false}
mostrarAutomatico={false}
/>
))
)}
{query.hasNextPage && (
<div className="p-3">
<Button
variant="ghost"
className="w-full"
disabled={query.isFetchingNextPage}
onClick={() => void query.fetchNextPage()}
>
{t("Carregar mais")}
</Button>
</div>
)}
</div>
<div className="flex shrink-0 justify-end border-t p-3">
<Link
href="/app/contacts"
aria-label={t("Escolher contato para conversar")}
title={t("Escolher contato para conversar")}
className="flex items-center gap-2 rounded-full border px-3 py-2 text-xs hover:bg-muted"
>
<PencilSimple size={18} aria-hidden />
{t("Contatos")}
</Link>
</div>
</>
);
}
function CompactConversation({
conversationId,
visible,
initialDraft,
initialMode,
onDraftChange,
}: {
conversationId: string;
visible: boolean;
initialDraft: string;
initialMode: "reply" | "note";
onDraftChange: (text: string, mode: "reply" | "note") => void;
}) {
const t = useT();
const { user } = useAuth();
const query = useConversation(conversationId, visible);
const conversation = query.data;
const [reply, setReply] = useState<Message | null>(null);
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 30000);
return () => clearInterval(timer);
}, []);
useMarkAsRead(
visible && conversation ? conversationId : null,
conversation?.unread_count_for_assignee ?? 0,
);
const window = estadoDaJanela(
conversation?.channel_sessions?.provider ?? null,
conversation?.last_inbound_at ?? null,
now,
);
const blocked =
user.support?.access_mode === "support_readonly"
? t("Acompanhamento somente leitura")
: conversation?.contacts?.is_blocked
? t("Contato bloqueado — envio de mensagens desabilitado.")
: conversation?.contacts?.is_anonymized
? t("Contato anonimizado — não é possível enviar mensagens.")
: null;
if (query.isError)
return (
<div className="space-y-3 p-4">
<p role="alert" className="text-sm">
{t("Conversa não encontrada ou fora do seu acesso.")}
</p>
<Button variant="outline" onClick={() => void query.refetch()}>
{t("Tentar novamente")}
</Button>
</div>
);
if (!conversation)
return (
<p role="status" className="p-4 text-sm text-muted-foreground">
{t("Carregando…")}
</p>
);
return (
<OpenConversationProvider conversationId={visible ? conversationId : null}>
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
<ChannelLogo channel={conversation.channel_sessions} />
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{rotuloDoContato(conversation.contacts, t)}
</span>
<JanelaSelo
provider={conversation.channel_sessions?.provider ?? null}
lastInboundAt={conversation.last_inbound_at}
/>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<ChatThread conversationId={conversationId} onResponder={setReply} />
</div>
<div className="max-h-[45%] shrink-0 overflow-y-auto">
<RetentionNotice conversationId={conversationId} />
<Composer
conversationId={conversationId}
initialDraft={initialDraft}
initialMode={initialMode}
active={visible}
onDraftChange={onDraftChange}
blockedReason={blocked}
janelaFechada={
window.tipo === "fechada"
? t("A janela de atendimento fechou. Abra o Inbox para ver as opções deste canal.")
: null
}
disabled={["closed", "resolved", "archived"].includes(conversation.status)}
contactName={conversation.contacts?.name ?? null}
currentContactId={conversation.contact_id}
respondendo={reply}
onCancelarResposta={() => setReply(null)}
/>
</div>
</OpenConversationProvider>
);
}
+17 -3
View File
@@ -29,6 +29,7 @@ export function AudioRecorder({ conversationId, disabled }: Props) {
const chunksRef = useRef<Blob[]>([]);
const discardRef = useRef(false);
const startingRef = useRef(false);
const captureGeneration = useRef(0);
const upload = useUploadMedia();
const send = useSendMessage();
@@ -46,6 +47,7 @@ export function AudioRecorder({ conversationId, disabled }: Props) {
// Trocar de conversa/rota no meio de uma gravação não pode deixar o mic aberto.
useEffect(
() => () => {
captureGeneration.current += 1;
discardRef.current = true;
if (recorderRef.current?.state === "recording") recorderRef.current.stop();
cleanupStream();
@@ -56,8 +58,13 @@ export function AudioRecorder({ conversationId, disabled }: Props) {
async function start() {
if (startingRef.current || recording) return;
startingRef.current = true;
const generation = captureGeneration.current;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (generation !== captureGeneration.current) {
stream.getTracks().forEach((track) => track.stop());
return;
}
streamRef.current = stream;
const mime = pickMime();
const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
@@ -74,7 +81,11 @@ export function AudioRecorder({ conversationId, disabled }: Props) {
const type = rec.mimeType || "audio/webm";
const blob = new Blob(chunksRef.current, { type });
void upload
.mutateAsync({ conversationId, file: blob, filename: `ptt.${type.includes("ogg") ? "ogg" : "webm"}` })
.mutateAsync({
conversationId,
file: blob,
filename: `ptt.${type.includes("ogg") ? "ogg" : "webm"}`,
})
.then((uploaded) =>
send.mutate(
{
@@ -95,10 +106,13 @@ export function AudioRecorder({ conversationId, disabled }: Props) {
rec.start();
setRecording(true);
} catch {
if (generation !== captureGeneration.current) return;
cleanupStream();
// permissão negada / sem mic — não gravar é o estado final; toast simples
const { showApiError } = await import("@/components/feedback/ApiErrorToast");
showApiError(new Error(t("Não consegui acessar o microfone. Verifique a permissão do navegador.")));
showApiError(
new Error(t("Não consegui acessar o microfone. Verifique a permissão do navegador.")),
);
} finally {
startingRef.current = false;
}
@@ -140,7 +154,7 @@ export function AudioRecorder({ conversationId, disabled }: Props) {
>
<Trash size={16} weight="regular" aria-hidden />
</Button>
<span className="flex items-center gap-1.5 text-sm tabular-nums text-destructive">
<span className="flex items-center gap-1.5 text-sm text-destructive tabular-nums">
<span className="h-2 w-2 animate-pulse rounded-full bg-destructive" aria-hidden />
{fmt(elapsed)}
</span>
@@ -0,0 +1,108 @@
{
"schema_version": 1,
"diagram_type": "architecture",
"meta": {
"title": "Mensagens rápidas em qualquer tela",
"subtitle": "Uma superfície do Inbox existente",
"output": "mensagens-rapidas.html",
"quality_profile": "standard"
},
"lanes": [
{
"id": "ui",
"label": "Interface"
},
{
"id": "data",
"label": "Runtime existente"
}
],
"mainPath": [
"shell",
"dock",
"composer",
"api"
],
"nodes": [
{
"id": "shell",
"lane": "ui",
"col": 1,
"type": "frontend",
"label": "AppShell"
},
{
"id": "dock",
"lane": "ui",
"col": 2,
"type": "frontend",
"label": "FloatingInbox"
},
{
"id": "composer",
"lane": "ui",
"col": 3,
"type": "frontend",
"label": "ChatThread + Composer"
},
{
"id": "api",
"lane": "data",
"col": 3,
"type": "api",
"label": "APIs de conversas e mensagens com RLS"
},
{
"id": "realtime",
"lane": "data",
"col": 2,
"type": "service",
"label": "Hooks existentes + realtime"
},
{
"id": "full",
"lane": "ui",
"col": 4,
"type": "frontend",
"label": "Inbox completo"
}
],
"edges": [
{
"id": "0",
"from": "shell",
"to": "dock",
"label": "botão global"
},
{
"id": "1",
"from": "realtime",
"to": "dock",
"label": "contagem e conversas"
},
{
"id": "2",
"from": "dock",
"to": "composer",
"label": "seleção"
},
{
"id": "3",
"from": "composer",
"to": "api",
"label": "leitura e envio"
},
{
"id": "4",
"from": "api",
"to": "realtime",
"label": "mensagens e estados"
},
{
"id": "5",
"from": "dock",
"to": "full",
"label": "ampliar conversa"
}
]
}
+39
View File
@@ -0,0 +1,39 @@
# Mensagens rápidas
O botão **Mensagens** no canto inferior direito acompanha a navegação autenticada.
Abra a lista, busque um contato e selecione uma conversa para ler e responder sem
sair da tela. O ícone de ampliar abre a mesma conversa no Inbox completo; o lápis
leva aos Contatos para iniciar um atendimento pelo fluxo existente.
Minimizar mantém a conversa, anexos e texto em edição. Voltar à lista mantém um
rascunho de texto por conversa durante esta navegação. Recarregar a página ou trocar
de organização limpa esses rascunhos locais. Não há persistência adicional de
conteúdo no navegador. O contador mostra conversas não lidas em todo o escopo de
acesso, inclusive fora da primeira página de resultados.
## Limites e recuperação
- As APIs, RLS, envio, anexos, notas e histórico são os mesmos do Inbox.
- Suporte somente leitura, contato bloqueado/anonimizado e conversa encerrada
continuam impedindo envio. Janela fechada orienta a abrir o Inbox completo.
- Conversa minimizada não é marcada como lida. As duas conversas visíveis (Inbox
e painel) são reconhecidas pelo controle de notificações.
- Falhas de leitura mostram **Tentar novamente**; falhas de envio usam o tratamento
e a restauração de texto do Composer existente.
- Uma chamada ativa desloca o botão para cima para preservar os controles da chamada.
## Living System Checklist
1. Entrada: `useConversationsRealtime`, `useConversationCounts` e `useConversation`.
2. Saída: `ChatThread`, `Composer` e link `/app/inbox/[id]`.
3. Registro: envio/nota passam por `useSendMessage`/`useCreateNote` e APIs existentes.
4. Visibilidade: histórico compartilhado no painel e no Inbox.
5. Porta: `AppShell` monta `FloatingInbox` em todas as telas autenticadas.
6. Anti-morte: contador global, atualização realtime e retry explícito; nenhuma
nova campanha ou resposta automática é criada por abrir o painel.
7. Configuração: permissões e canais existentes; sem nova credencial ou env.
8. Continuidade: o mesmo atendimento IA/humano e histórico; controles completos de
responsabilidade e retorno à IA permanecem disponíveis pelo botão de ampliar.
9. Retorno: erros de envio chegam ao Composer; operador corrige/reenvia ou amplia
para resolver bloqueios. Abrir/minimizar não toma decisões automáticas.
10. Mapa: `docs/architecture/mensagens-rapidas.architecture.json`.
@@ -3,13 +3,17 @@
import { createContext, useContext, useEffect, type ReactNode } from "react";
/**
* Id da conversa aberta no inbox. O listener de INSERT mora no AppShell
* Conversas visíveis no Inbox e no painel flutuante. Cada provider registra
* sua própria entrada: minimizar um painel não limpa a conversa do outro.
* O listener de INSERT mora no AppShell
* (ancestral), então além do contexto há um módulo-level lido no evento.
*/
let publishedId: string | null = null;
const publishedIds = new Map<symbol, string>();
export function getOpenConversationId(): string | null {
return publishedId;
export function getOpenConversationId(candidate?: string | null): string | null {
const ids = [...publishedIds.values()];
if (candidate !== undefined) return candidate && ids.includes(candidate) ? candidate : null;
return ids.at(-1) ?? null;
}
const Ctx = createContext<string | null>(null);
@@ -22,9 +26,10 @@ export function OpenConversationProvider({
children: ReactNode;
}) {
useEffect(() => {
publishedId = conversationId;
const owner = Symbol();
if (conversationId) publishedIds.set(owner, conversationId);
return () => {
publishedId = null;
publishedIds.delete(owner);
};
}, [conversationId]);
@@ -92,7 +92,7 @@ export function useInboundMessageAlerts(): void {
!shouldNotifyInbound({
direction,
conversationId,
openConversationId: getOpenConversationId(),
openConversationId: getOpenConversationId(conversationId),
tabFocused: tabFocused(),
tipo: (payload as { tipo?: unknown }).tipo,
})
+10
View File
@@ -37,6 +37,16 @@ import type { Idioma } from "./idiomas";
type Traducoes = Record<string, Partial<Record<Exclude<Idioma, "pt-BR">, string>>>;
export const DICIONARIO: Traducoes = {
"Mensagens rápidas": { es: "Mensajes rápidos" },
"Voltar às conversas": { es: "Volver a las conversaciones" },
"Minimizar mensagens": { es: "Minimizar mensajes" },
"conversas não lidas": { es: "conversaciones no leídas" },
"Não foi possível atualizar as mensagens.": { es: "No se pudieron actualizar los mensajes." },
"Não foi possível carregar as conversas.": { es: "No se pudieron cargar las conversaciones." },
"Escolher contato para conversar": { es: "Elegir contacto para conversar" },
"Acompanhamento somente leitura": { es: "Seguimiento de solo lectura" },
"A janela de atendimento fechou. Abra o Inbox para ver as opções deste canal.": { es: "La ventana de atención se cerró. Abre el Inbox para ver las opciones de este canal." },
"Assistente de voz": { es: "Asistente de voz" },
"Não foi possível concluir. Confira sua conexão e tente novamente.": { es: "No se pudo completar la operación. Revisa tu conexión e inténtalo de nuevo." },
"Não foi possível confirmar o encerramento. Feche esta aba para interromper o microfone.": { es: "No se pudo confirmar la finalización. Cierra esta pestaña para detener el micrófono." },
+27 -6
View File
@@ -1,12 +1,14 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const uploadMock = vi.fn(async (_args: { conversationId: string; file: File | Blob; filename?: string }) => ({
storage_path: "org/conv/out-a.ogg",
media_mime: "audio/ogg",
media_size_bytes: 5,
kind: "audio" as const,
}));
const uploadMock = vi.fn(
async (_args: { conversationId: string; file: File | Blob; filename?: string }) => ({
storage_path: "org/conv/out-a.ogg",
media_mime: "audio/ogg",
media_size_bytes: 5,
kind: "audio" as const,
}),
);
const sendMock = vi.fn();
vi.mock("@/hooks/inbox/useUploadMedia", () => ({
useUploadMedia: () => ({ mutateAsync: uploadMock, isPending: false }),
@@ -55,6 +57,25 @@ describe("AudioRecorder", () => {
});
});
it("stops a late microphone permission result after unmount without uploading", async () => {
let resolve!: (value: MediaStream) => void;
vi.mocked(navigator.mediaDevices.getUserMedia).mockReturnValue(
new Promise<MediaStream>((r) => {
resolve = r;
}),
);
const view = render(<AudioRecorder conversationId="conv-1" />);
fireEvent.click(screen.getByRole("button", { name: /gravar áudio/i }));
view.unmount();
await act(async () => {
resolve({ getTracks: () => [{ stop: trackStopMock }] } as unknown as MediaStream);
});
expect(trackStopMock).toHaveBeenCalledOnce();
expect(FakeRecorder.instances).toHaveLength(0);
expect(uploadMock).not.toHaveBeenCalled();
expect(sendMock).not.toHaveBeenCalled();
});
it("mic inicia gravação e mostra timer + cancelar", async () => {
render(<AudioRecorder conversationId="conv-1" />);
fireEvent.click(screen.getByRole("button", { name: /gravar áudio/i }));
+205
View File
@@ -0,0 +1,205 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
const state = vi.hoisted(() => ({
org: "org-a",
readonly: false,
blocked: false,
closed: false,
unread: 104,
mark: vi.fn(),
}));
vi.mock("@/hooks/auth/AuthProvider", () => ({
useAuth: () => ({
activeOrg: { orgId: state.org },
user: { id: "user", support: state.readonly ? { access_mode: "support_readonly" } : undefined },
}),
}));
vi.mock("@/hooks/i18n/useT", () => ({ useT: () => (s: string) => s }));
vi.mock("@/components/voice/VoiceCallContext", () => ({
useVoiceCall: () => ({ call: null, minha: false }),
}));
vi.mock("@/hooks/realtime/useRealtimeChannel", () => ({ useRealtimeChannel: vi.fn() }));
vi.mock("@/hooks/inbox/useConversationCounts", () => ({
useConversationCounts: () => ({ data: { all: state.unread } }),
}));
vi.mock("@/hooks/inbox/useConversationsRealtime", () => ({
useConversationsRealtime: () => ({ data: { pages: [{ data: [{ id: "one" }, { id: "two" }] }] } }),
}));
vi.mock("@/hooks/inbox/useConversation", () => ({
useConversation: () => ({
data: {
status: state.closed ? "closed" : "open",
contact_id: "contact",
unread_count_for_assignee: 3,
contacts: { name: "Cliente", is_blocked: state.blocked },
channel_sessions: { provider: "waha" },
},
}),
}));
vi.mock("@/hooks/inbox/useMarkAsRead", () => ({
useMarkAsRead: (...args: unknown[]) => state.mark(...args),
}));
vi.mock("@/components/inbox/ConversationListItem", () => ({
ConversationListItem: ({
conversation,
onSelect,
}: {
conversation: { id: string };
onSelect: (id: string) => void;
}) => <button onClick={() => onSelect(conversation.id)}>{conversation.id}</button>,
}));
vi.mock("@/components/inbox/ChannelLogo", () => ({ ChannelLogo: () => null }));
vi.mock("@/components/inbox/JanelaSelo", () => ({ JanelaSelo: () => null }));
vi.mock("@/components/inbox/RetentionNotice", () => ({ RetentionNotice: () => null }));
vi.mock("@/components/inbox/ChatThread", () => ({ ChatThread: () => <div>Histórico</div> }));
const mutations = vi.hoisted(() => ({ send: vi.fn(), note: vi.fn() }));
vi.mock("@/hooks/inbox/useSendMessage", () => ({
useSendMessage: () => ({ mutate: mutations.send, isPending: false }),
}));
vi.mock("@/hooks/inbox/useCreateNote", () => ({
useCreateNote: () => ({ mutate: mutations.note, isPending: false }),
}));
vi.mock("@/hooks/inbox/useUploadMedia", () => ({
useUploadMedia: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/inbox/useMessageTemplates", () => ({
useMessageTemplates: () => ({ data: [], isLoading: false }),
}));
vi.mock("@/hooks/inbox/useDraftReply", () => ({
useDraftReply: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("next/dynamic", async () => {
const { lazy, Suspense } = await import("react");
return {
default: (loader: () => Promise<unknown>) => {
const Component = lazy(async () => ({ default: (await loader()) as React.ComponentType }));
return function Lazy(props: Record<string, unknown>) {
return (
<Suspense fallback={null}>
<Component {...props} />
</Suspense>
);
};
},
};
});
import { FloatingInbox } from "@/components/inbox/FloatingInbox";
function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>;
}
function mount() {
return render(<FloatingInbox />, { wrapper: Wrapper });
}
async function select(id = "one") {
fireEvent.click(screen.getByRole("button", { name: /Mensagens/ }));
fireEvent.click(screen.getByRole("button", { name: id }));
if (state.readonly || state.blocked)
await screen.findByText(
state.readonly
? "Acompanhamento somente leitura"
: "Contato bloqueado — envio de mensagens desabilitado.",
);
else await screen.findByRole("textbox", { name: "Mensagem" });
}
beforeEach(() => {
Object.assign(state, {
org: "org-a",
readonly: false,
blocked: false,
closed: false,
unread: 104,
});
state.mark.mockClear();
});
it("starts minimized and counts all unread conversations, not loaded rows", () => {
mount();
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
expect(screen.getByLabelText("104 conversas não lidas")).toHaveTextContent("99+");
});
it("preserves the draft when minimized and stops marking the hidden thread as read", async () => {
mount();
await select();
fireEvent.change(screen.getByRole("textbox", { name: "Mensagem" }), {
target: { value: "rascunho" },
});
fireEvent.click(screen.getByRole("button", { name: "Minimizar mensagens" }));
expect(state.mark).toHaveBeenLastCalledWith(null, 3);
fireEvent.click(screen.getByRole("button", { name: /Mensagens/ }));
expect(screen.getByRole("textbox", { name: "Mensagem" })).toHaveValue("rascunho");
expect(state.mark).toHaveBeenLastCalledWith("one", 3);
});
it("keeps separate drafts when switching conversations", async () => {
mount();
await select();
fireEvent.change(screen.getByRole("textbox", { name: "Mensagem" }), {
target: { value: "para um" },
});
fireEvent.click(screen.getByRole("button", { name: "Voltar às conversas" }));
fireEvent.click(screen.getByRole("button", { name: "two" }));
expect(await screen.findByRole("textbox", { name: "Mensagem" })).toHaveValue("");
fireEvent.click(screen.getByRole("button", { name: "Voltar às conversas" }));
fireEvent.click(screen.getByRole("button", { name: "one" }));
expect(await screen.findByRole("textbox", { name: "Mensagem" })).toHaveValue("para um");
});
it.each(["readonly", "blocked", "closed"] as const)("preserves the %s send guard", async (key) => {
state[key] = true;
mount();
await select();
if (key === "closed") expect(screen.getByRole("textbox", { name: "Mensagem" })).toBeDisabled();
else expect(screen.queryByRole("textbox", { name: "Mensagem" })).not.toBeInTheDocument();
});
it("clears selection and drafts when organization changes", async () => {
const view = mount();
await select();
fireEvent.change(screen.getByRole("textbox", { name: "Mensagem" }), {
target: { value: "privado" },
});
state.org = "org-b";
view.rerender(<FloatingInbox />);
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
await select();
expect(screen.getByRole("textbox", { name: "Mensagem" })).toHaveValue("");
});
it("Escape minimizes and returns focus to the trigger; maximize points to the selected thread", async () => {
mount();
await select();
expect(screen.getByRole("link", { name: "Abrir no Inbox" })).toHaveAttribute(
"href",
"/app/inbox/one",
);
fireEvent.keyDown(screen.getByRole("textbox", { name: "Mensagem" }), { key: "Escape" });
await waitFor(() => expect(screen.getByRole("button", { name: /Mensagens/ })).toHaveFocus());
});
it("restores a private note as a note, never as an external reply", async () => {
mutations.send.mockClear();
mutations.note.mockClear();
mount();
await select();
fireEvent.click(screen.getByRole("button", { name: "Nota interna" }));
fireEvent.change(screen.getByRole("textbox", { name: "Mensagem" }), {
target: { value: "anotação confidencial" },
});
fireEvent.click(screen.getByRole("button", { name: "Voltar às conversas" }));
fireEvent.click(screen.getByRole("button", { name: "two" }));
await screen.findByRole("textbox", { name: "Mensagem" });
fireEvent.click(screen.getByRole("button", { name: "Voltar às conversas" }));
fireEvent.click(screen.getByRole("button", { name: "one" }));
expect(await screen.findByPlaceholderText(/nota interna/i)).toHaveValue("anotação confidencial");
fireEvent.click(screen.getByRole("button", { name: "Enviar" }));
expect(mutations.note).toHaveBeenCalled();
expect(mutations.send).not.toHaveBeenCalled();
});
it("unmounts the microphone control while minimized", async () => {
mount();
await select();
expect(screen.getByRole("button", { name: "Gravar áudio" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Minimizar mensagens" }));
expect(
screen.queryByRole("button", { name: "Gravar áudio", hidden: true }),
).not.toBeInTheDocument();
});
@@ -0,0 +1,23 @@
import { render } from "@testing-library/react";
import { expect, it } from "vitest";
import {
getOpenConversationId,
OpenConversationProvider,
} from "@/hooks/notifications/OpenConversationContext";
it("tracks both visible conversations and unregisters only the minimized panel", () => {
const inbox = render(
<OpenConversationProvider conversationId="inbox">Inbox</OpenConversationProvider>,
);
const dock = render(
<OpenConversationProvider conversationId="dock">Dock</OpenConversationProvider>,
);
expect(getOpenConversationId("inbox")).toBe("inbox");
expect(getOpenConversationId("dock")).toBe("dock");
expect(getOpenConversationId("other")).toBeNull();
dock.rerender(<OpenConversationProvider conversationId={null}>Dock</OpenConversationProvider>);
expect(getOpenConversationId("dock")).toBeNull();
expect(getOpenConversationId("inbox")).toBe("inbox");
dock.unmount();
inbox.unmount();
expect(getOpenConversationId()).toBeNull();
});