mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
feat(inbox): adicione mensagens flutuantes em todas as telas
This commit is contained in:
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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." },
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user