@adatechnology/conversations-ui
v0.11.0
Published
WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags
Readme
@adatechnology/conversations-ui
Telas de conversa parametrizáveis por endpoint, tema e capacidades. Subpaths: . (inbox/mensagens),
/flows, /whatsapp, /preview, /participant e /styles.css.
Visão do participante (/participant)
Tela de conversa do lado de quem conversa com a empresa (app de campo, portal do cliente). Não usa
ConversationsProvider, não puxa @xyflow/react nem o ConversationsWorkspace, e não depende de Tailwind:
todas as classes são .cv-p-* e vêm de @adatechnology/conversations-ui/styles.css.
@adatechnology/conversations-ui/styles.css é requisito da visão do participante: sem ele a tela fica sem layout.
A visão do participante renderiza MessageText e StatusTicks com appearance="stylesheet" (classes .cv-*, sem
Tailwind). Fora dela, os dois componentes mantêm o padrão da 0.4.2 (classes Tailwind do host).
Props opt-in (o padrão é sempre o comportamento da 0.4.2):
MessageText:appearance?: 'tailwind' | 'stylesheet'(padrão'tailwind');accessibleCopy?: boolean(padrãofalse; ligarole="button",tabIndex=0e Enter/Espaço para copiar);copyOnClick(padrãotrue;falsedesliga a cópia);copiedLabel(padrãoCopiado!).StatusTicks:appearance?: 'tailwind' | 'stylesheet'(padrão'tailwind'). Só em'stylesheet'valem o estadoqueued(relógio),bouncedtratado comofailede oaria-label;queuedLabel?: string(padrãoQueued) define o rótulo do relógio. No padrão, qualquer status diferente desentefaileddesenha dois tiques, como na 0.4.2; em'stylesheet', um status desconhecido cai emsent(um tique).
Uso mínimo
import { ParticipantConversations } from '@adatechnology/conversations-ui/participant'
import '@adatechnology/conversations-ui/styles.css'
const SUBJECT_GROUPS = [
{ subjectType: 'invoice', label: 'Faturas' },
{ subjectType: 'trip', label: 'Viagens' },
] as const
function ConversationsRoute() {
const { subjectType, subjectId } = useParams()
const navigate = useNavigate()
const selected = subjectType && subjectId ? { subjectType, subjectId } : undefined
return (
<ParticipantConversations
api={participantApi}
subjectGroups={SUBJECT_GROUPS}
selected={selected}
onSelect={(subject) => navigate(`/conversas/${subject.subjectType}/${subject.subjectId}`)}
onBack={() => navigate('/conversas')}
/>
)
}Contrato de roteamento
O componente é controlado pela rota: selected === undefined mostra a lista, definido mostra a conversa.
Monte-o UMA vez acima das duas rotas (/conversas e /conversas/:subjectType/:subjectId), com o mesmo
elemento. O cache da lista e os rascunhos por conversa (texto e anexos) vivem dentro dele; se cada rota
montasse a sua instância, voltar da conversa para a lista recarregaria tudo e perderia o rascunho.
<Route path="/conversas" element={<ConversationsRoute />} />
<Route path="/conversas/:subjectType/:subjectId" element={<ConversationsRoute />} />Capacidades opcionais existem por ausência de prop: sem onBack não há botão de voltar, sem
renderSubjectCard não há cartão do assunto, sem onOpenSubject o título não é clicável, sem
quickReplies não há faixa de respostas rápidas, sem pendingMessages não há fila do host. Sem
api.subscribe, a tela revalida ao montar e em focus, visibilitychange e online; com subscribe, revalida também em online (um evento pode ter se perdido) e marca como lido ao voltar a ficar visível. Sem
api.openConversation, uma conversa fora da primeira página da lista aparece como "não encontrada"
(labels.notFound).
Adapter REST de ParticipantConversationsApi
import { z } from 'zod'
import { participantConversationPageSchema, participantMessageSchema } from '@adatechnology/conversation-contracts'
import type { ParticipantConversationsApi } from '@adatechnology/conversations-ui/participant'
async function request(path: string, init?: RequestInit): Promise<unknown> {
const response = await fetch(`/api/v1${path}`, { ...init, credentials: 'include' })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return ((await response.json()) as { data: unknown }).data
}
function conversationPath({ subjectType, subjectId }: { subjectType: string; subjectId: string }): string {
return `/conversations/${encodeURIComponent(subjectType)}/${encodeURIComponent(subjectId)}`
}
export const participantApi: ParticipantConversationsApi = {
listConversations: async ({ cursor } = {}) =>
participantConversationPageSchema.parse(
await request(`/conversations${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`),
),
fetchMessages: async (subject, { before, limit } = {}) =>
z
.array(participantMessageSchema)
.parse(
await request(
`${conversationPath(subject)}/messages?limit=${limit ?? 30}${before ? `&before=${encodeURIComponent(before)}` : ''}`,
),
),
sendMessage: async ({ subject, clientMessageId, text }) => {
const message = participantMessageSchema.parse(
await request(`${conversationPath(subject)}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': clientMessageId },
body: JSON.stringify({ text }),
}),
)
return { outcome: 'sent', message }
},
markRead: async (subject) => {
await request(`${conversationPath(subject)}/read`, { method: 'POST' })
},
resolveAttachmentUrl: async (attachment) =>
z.object({ url: z.string() }).parse(await request(`/attachments/${encodeURIComponent(attachment.id)}/url`)).url,
}A resposta do servidor é entrada não confiável: valide com os schemas do @adatechnology/conversation-contracts
(participantConversationPageSchema, participantMessageSchema) e use encodeURIComponent em todo segmento de URL
que vem de subjectType, subjectId, cursor ou id.
Eco de clientMessageId (obrigatório). Toda mensagem criada por sendMessage volta com o clientMessageId
recebido (o mesmo valor do Idempotency-Key): é por ele que a bolha local é substituída pela mensagem do servidor.
Sem o eco, a bolha local fica duplicada ao lado da mensagem real. O servidor também precisa tratar com segurança
duas requisições simultâneas com a mesma chave (toque duplo, reenvio): uma cria, a outra devolve a mesma mensagem.
Ao tocar em enviar, o campo é limpo na hora e a bolha passa a ser a dona do texto e dos arquivos (o segundo
toque não envia nada). sendMessage que lança significa falha: a bolha mostra "reenviar" (repete o mesmo
clientMessageId; use-o como Idempotency-Key), "editar" (devolve texto e arquivos ao campo e remove a bolha) e
"descartar". Uma bolha que falhou sobrevive a sair e voltar da conversa. Para guardar na fila offline do host,
devolva { outcome: 'queued' } e devolva a fila durável em pendingMessages: a bolha fica como "na fila" até o host
ou o servidor refletirem o mesmo clientMessageId. Falha de uma mensagem que veio do servidor mostra só "Falhou";
falha de uma pendente do host só ganha "reenviar" se onRetryPending for passado. O pacote não conhece IndexedDB.
Limites do host: devolver queued sem refletir a mensagem em pendingMessages deixa uma bolha "na fila" sem
nenhuma ação até a conversa desmontar (ela segura os File); sendMessage precisa terminar com timeout (uma
promise que nunca resolve trava o botão de enviar daquele assunto); e se o host tira a mensagem da fila antes de o
servidor refletir, deve emitir conversation-changed para a conversa recarregar.
api precisa ser estável (crie o adapter uma vez, fora do render, ou memorize-o): uma instância nova a cada
render refaz a carga da lista e reinscreve os eventos de subscribe a cada renderização. Métodos de adapter escritos como classe funcionam, o pacote preserva o this.
Aviso: direction é sempre na perspectiva da empresa
ParticipantMessage.direction descreve a mensagem do ponto de vista da empresa: inbound chegou à
empresa, ou seja, foi escrita pelo participante (é a bolha "minha"); outbound saiu da empresa. O adapter do
app não inverte nada.
Protocolo legível da conversa
ParticipantConversationSummary.protocol? (1 a 32 caracteres, [A-Za-z0-9-], ex.: 261009-K7M2) é um código
curto para citar a conversa por telefone ou mensagem. Quem gera é o produto, no servidor, uma vez e imutável: o
pacote nunca gera nem conhece o formato, só exibe, copia e busca. Campo ausente = nada é desenhado.
- Lista: a linha mostra o protocolo abaixo do título (fonte mono); o prefixo
labels.protocolPrefixé um<span>só para leitor de tela (semaria-labelem elemento genérico, que o ARIA 1.2 proíbe). - Cabeçalho da conversa: ver "Cabeçalho da conversa" abaixo. O botão de copiar usa
navigator.clipboard.writeText; falha de cópia é silenciosa. É só um ícone (Copy, viraCheckpor 3 s) comaria-label= "copyProtocol+ código"; durante os 3 s aparece ao lado do ícone o texto curtolabels.protocolCopiede o aviso também vai numa regiãoaria-live="polite"sempre na árvore; copiar de novo dentro dos 3 s reinicia o prazo e reanuncia. - Busca: um campo
type="search"acima da lista filtra por título (sem acento nem caixa) ou protocolo (parcial, sem traço, sem caixa:k7m2acha261009-K7M2). Aparece só quando alguma conversa temprotocolou há mais de 8 conversas na lista; sem nada disso, nada é desenhado. Sem resultado:labels.noResults.- Os chips de assunto e suas contagens são sempre calculados sobre todas as conversas carregadas; a busca só
restringe as linhas. Se o chip ativo não contém o que a busca acha (mas outro assunto contém), os chips continuam
visíveis e o estado vazio mostra
labels.noResultsInFilter("tente Todas") em vez de esconder a barra. - O campo permanece enquanto houver consulta, mesmo que um refresh reduza a lista abaixo do limiar.
- Limitação: a busca cobre só as conversas já carregadas. Com
hasMoree sem resultado, o estado vazio mostralabels.noResultsLoadedOnlye o botão "carregar mais" fica à mão. Busca no servidor fica como evolução.
- Os chips de assunto e suas contagens são sempre calculados sobre todas as conversas carregadas; a busca só
restringe as linhas. Se o chip ativo não contém o que a busca acha (mas outro assunto contém), os chips continuam
visíveis e o estado vazio mostra
- Labels novas (defaults em inglês):
searchLabel,searchPlaceholder,noResults,noResultsInFilter,noResultsLoadedOnly,protocolPrefix,copyProtocol(padrão "Copy protocol"),protocolCopied. - Classes novas:
.cv-p-protocol,.cv-p-protocol__copy,.cv-p-search(mesmos tokens--cv-p-*).
Canais e ícone do assunto na lista
ParticipantConversationSummary.channels? (até 5 canais do vocabulário email | whatsapp | app | portal | webchat, sem
repetição, pode ser vazio) e iconName? (1 a 32 caracteres, [a-z0-9-], nome opaco: o pacote não conhece o
catálogo) chegam calculados pelo servidor: channels são os canais distintos das mensagens da conversa e
iconName o nome escolhido pelo produto para o assunto. O pacote não deduz nada; campo ausente = nada é desenhado.
- Lista: a linha mostra à esquerda o ícone do assunto e, ao lado do protocolo, os selos de canal. O ícone vem de
renderSubjectIcon?: (conversation) => ReactNode(emParticipantConversations); se a prop faltar, ou devolvernull/undefined, cai no ícone do grupo (subjectGroups[].icon). O host usaconversation.iconNamepara escolher. - Selos de canal: um por canal, na ordem fixa app, whatsapp, email, portal, webchat (duplicata e canal desconhecido são
ignorados). O sinal visual é o ícone (cor é só reforço); o nome do canal está sempre em
<span class="cv-p-sr-only">. Semchannelsou vazio, nada é desenhado. Duas marcações (ParticipantChannelBadges variant): no cabeçalho da conversa,<ul class="cv-p-channels" aria-label>com<li class="cv-p-channel cv-p-channel--{canal}">; na linha da lista (dentro de um<button>, onde lista não é HTML válido),<span class="cv-p-channels">com<span class="cv-p-channel cv-p-channel--{canal}">, um prefixo sr-onlyChannels in this conversation:e o nome seguido de vírgula e espaço, para o leitor dizer "Channels in this conversation: App, WhatsApp". renderSubjectIcondeve ser puro e não lançar (o pacote não tem error boundary);null,undefined,falsee''valem como ausente.- Cabeçalho da conversa: os mesmos selos, na variante
inline(ícone de 16px), na mesma linha do protocolo. - Labels novas (defaults em inglês):
channelsGroup("Channels in this conversation"),channelApp,channelWhatsapp,channelEmail,channelPortal,channelWebchat. - Classes novas:
.cv-p-channels,.cv-p-channel,.cv-p-row__tags,.cv-p-inbox__icon; cores opcionais--cv-p-channel-app(padrão: o destaque) e--cv-p-channel-whatsapp(padrão#20914f, ≥ 3:1 sobre as superfícies claras e escuras; ao trocar, mantenha 3:1).
Conversa no desenho do WhatsApp, no nosso estilo
A conversa segue os padrões de chat do WhatsApp, mas só com os tokens --cv-p-* (por exemplo, em um tema escuro de cantos
retos, rótulos em mono). Tudo é .cv-p-*; nenhum componente do painel do operador é usado.
Cabeçalho (barra única com piso de 4rem (com título longo, protocolo longo e 3 canais a linha meta quebra e a barra chega a cerca de 110px), fundo --cv-p-surface-raised, borda inferior de 1px), da esquerda para a direita:
(1) voltar só com ícone (seta de 24px, sem caixa nem borda, área de toque 44x44, aria-label = label back, foco visível;
ausente sem onBack); (2) avatar da conversa: tile de 2.5rem (border-radius: var(--cv-p-radius)) com o ícone do assunto, o
mesmo da lista (renderSubjectIcon do host; se devolver vazio, o icon do grupo em subjectGroups; sem nenhum dos dois não há
tile e o título recua). o renderSubjectIcon e o icon do grupo, que já existiam, passam a desenhar também esse tile (o cabeçalho não tem o
fallback de duas letras da linha da lista); (3) título em duas linhas: o subjectLabel (uma linha com reticências, cortada só no span interno
.cv-p-thread__title-text para o anel de foco do botão não ser recortado; clicável só com onOpenSubject) e a linha meta
(.cv-p-thread__meta) com o protocolo, o botão de copiar só com ícone e os selos de canal inline. O rótulo do grupo (eyebrow) some quando há tile: o tile já representa o grupo e o espaço em 320px é curto. A área de toque do copiar tem 44px de
largura e 36px de altura; a margem negativa de cima é coberta pelo padding-top da linha meta, então nunca invade o título.
Sem protocol não há protocolo nem botão; sem channels não há selos; sem nenhum dos dois a linha meta não existe. O cartão de
renderSubjectCard (do host) deve trazer só o que o cabeçalho não diz.
Fundo: a lista de mensagens tem um padrão geométrico de pontos em CSS puro, derivado de --cv-p-border, de contraste
mínimo. --cv-p-wallpaper o substitui (qualquer valor de background-image, em geral um gradiente; none desliga),
com --cv-p-wallpaper-size para o tamanho do ladrilho (padrão 24px 24px). Some em forced-colors: active e na impressão.
Separador de dia: pílula centralizada (fundo elevado, borda de 1px, texto mono pequeno, raio do token), role="separator"
com um <time datetime="AAAA-MM-DD"> do dia local.
Rolagem: as áreas verticais (mensagens, lista de conversas e campo de texto) têm barra fina (scrollbar-width: thin, 6px
em WebKit, trilho transparente, sem setas), polegar --cv-p-scrollbar-thumb (padrão --cv-p-border) e --cv-p-scrollbar-thumb-hover
(padrão --cv-p-accent) ao passar o mouse ou arrastar, sem reservar espaço (scrollbar-gutter) para as bolhas não se mexerem, e
overscroll-behavior-y: contain para não encadear a rolagem na página. As faixas horizontais (filtros e respostas rápidas) rolam
sem barra, com esmaecimento nas bordas, scroll-snap-type: x proximity e o filtro ativo trazido à vista ao ser escolhido. Em
forced-colors: active voltam a barra do sistema e a faixa sem máscara. scroll-behavior: smooth só vale sob
prefers-reduced-motion: no-preference (lista e faixas; o scroller da conversa continua instantâneo, pois o posicionamento inicial e a
âncora ao carregar mensagens antigas atribuem scrollTop).
Ir para a última mensagem: com o leitor longe do fim da conversa (o mesmo limiar de shouldStickToBottom, 80px), um botão
flutuante de 44x44 com ChevronDown aparece no canto inferior do scroller; o aria-label é o label novo scrollToLatest
(padrão "Scroll to latest message"). Um selo mostra quantas mensagens do outro lado chegaram enquanto o leitor estava longe (limite
"99+"); ele é decorativo, e a região aria-live continua anunciando só o label newMessages. O clique rola ao fim (suave só sem
prefers-reduced-motion: reduce) e o botão some ao chegar. Nada é desenhado perto do fim ou sem mensagens.
Direção do texto (RTL): margens, alinhamento e hora da bolha usam propriedades lógicas e espelham sozinhos; o rabinho do balão (triângulos e canto reto) usa posições físicas e em RTL continua à esquerda/direita como em LTR (limitação conhecida).
Bolha: hora e ticks ficam na mesma bolha, juntos ao pé e à direita (.cv-p-bubble__meta); se o texto cabe com folga, a
hora fica na mesma linha dele, senão cai para a linha de baixo, à direita, sem sobrepor. Largura máxima de 85%.
Compositor: [anexar] [campo] [enviar] alinhados pela base, em barra elevada com borda superior; anexar e enviar são ícones
(clipe e avião de papel, com aria-label), o campo é multilinha, parte de 44px e cresce até cerca de 4 linhas (depois rola),
enviar é o botão primário e fica desabilitado sem conteúdo. As respostas rápidas continuam como chips acima da linha.
Classes novas: .cv-p-thread__avatar, .cv-p-thread__eyebrow, .cv-p-thread__meta, .cv-p-protocol__copied, .cv-p-day__label,
.cv-p-composer__attach, .cv-p-composer__send.
Avatar do autor (opt-in)
O SDK não tem foto de usuário: o cadastro só traz authorName. O avatar é opt-in e o host fornece a foto pelo slot.
avatars="initials"(emParticipantConversations): desenha, à esquerda da bolha recebida, um quadrado de 32px (border-radius: var(--cv-p-radius), respeita tema reto) com até 2 iniciais do autor (primeiro e último nome, ignorando preposições e pontuação); nome vazio,?ou só emoji cai no ícone neutro. Aparece só na primeira mensagem de uma sequência do mesmo autor (as seguintes reservam o espaço para alinhar as bolhas); mensagem própria nunca tem avatar. O avatar éaria-hidden: o nome do autor já está no texto da bolha.renderAuthorAvatar?: (author: { name: string | null }) => ReactNode: slot do host (uma<img>, por exemplo) desenhado dentro do mesmo quadrado de 32px; tem precedência sobre as iniciais, liga o avatar sozinho e, se devolvernull,undefined,falseou'', cai nas iniciais. Deve ser puro e não lançar.- Sem nenhuma das duas props o markup da bolha é idêntico ao anterior. Classes novas:
.cv-p-avatar,.cv-p-bubble-row,.cv-p-bubble-row__avatar. Nenhum label novo.
Rabinho da bolha (tail)
A bolha da conversa é um balão de fala: um rabinho triangular de 9px cola no canto inferior esquerdo da bolha recebida e
no inferior direito da própria (convenção de chat). Ligado por padrão; tail={false} (em ParticipantConversations)
remove o rabinho e devolve o canto arredondado (.cv-p-bubble--no-tail).
- O rabo são dois triângulos de
border(::beforena cor da borda,::afterna cor do fundo, 1px deslocado), então a borda de 1px continua contínua. As cores saem dos mesmos tokens da bolha (--cv-p-border,--cv-p-surface-raised; na própria,--cv-p-accente--cv-p-highlight) e acompanham.dark. - Funciona com tema reto (
--cv-p-radius: 0) e arredondado: o canto inferior correspondente fica reto para o rabo emendar. - É decorativo (
pointer-events: none, pseudo-elemento): não muda área de toque, conteúdo nem altura mínima. Com avatar a bolha recua 0.25rem para o rabo não encostar no quadrado. Sem animação. - Em
forced-colors: activee na impressão os triângulos somem (viram quadrados sólidos semborder-colortransparente) e o canto volta ao raio do token.
Texto da mensagem (formatação, links e valores copiáveis)
A bolha usa um renderizador próprio da visão do participante (ParticipantMessageText); o MessageText e a formatação do painel e dos outros fluxos não mudam. O texto vira nós React (nunca innerHTML); acima de 8000 caracteres aparece como foi digitado.
| Digitado | Resultado |
|---|---|
| *negrito*, _itálico_, ~riscado~, *_ambos_* | <strong>, <em>, <del>, aninhados; o marcador cola no texto (* a *, 2 * 3 * 4, snake_case_nome ficam como estão) |
| `código` e bloco entre três crases | <code> / <pre><code>; nada é formatado nem detectado dentro |
| > citação (linhas seguidas = um bloco) | <blockquote> |
| - item, * item, • item / 1. item, 1) item | <ul> / <ol> (exige o espaço depois do marcador) |
| http(s)://… | <a> com rel="noopener noreferrer nofollow ugc" e target="_blank", mais um botão de copiar a URL ao lado; pontuação final fica fora |
| telefone BR/internacional, e-mail, CPF, CNPJ (numérico e alfanumérico), chave de acesso de 44 dígitos | botão que copia o valor como escrito |
Allowlist de links: só http: e https: são gerados no texto (a allowlist de isAllowedLinkHref aceita também mailto: e tel:, mas o renderizador nunca os produz a partir do texto digitado); javascript:, data:, vbscript:, qualquer outro protocolo e URL com usuário/senha (https://user:pass@host) ficam como texto. Em bloco de código, um rótulo de linguagem sozinho na primeira linha (```js) é descartado.
Valores copiáveis: a detecção é conservadora. CPF e CNPJ só valem com dígitos verificadores corretos (sequências repetidas não valem), a chave exige exatamente 44 dígitos (grupos de 4 opcionais) e o telefone exige 10 a 15 dígitos (BR: DDD 11–99) com formatação: sem máscara, só vale com +55, DDD entre parênteses ou separador depois do DDD (1234567890 solto, como em "pedido 1234567890", não é telefone); número internacional exige +. Valores monetários, datas e números curtos não são destacados. O toque copia, mostra "Copied" no próprio valor e anuncia na região aria-live única da conversa. Os rótulos copyValue, valueCopied e externalLinkHint vêm de labels (padrão em inglês). O SDK só escreve na área de transferência quando a pessoa toca: não envia nem armazena nada.
ConversationThread: uma conversa, vista por qualquer um dos lados
ConversationThread desenha uma conversa (um assunto) no mesmo desenho da visão do participante, sem lista
e sem inbox. Serve ao app do participante e, com perspective="operator", ao painel de quem atende. O SDK não
conhece as ações do host: encerrar, reabrir ou qualquer outra entram pelo slot headerActions.
import { ConversationThread } from '@adatechnology/conversations-ui/participant'
<ConversationThread
api={operatorApi}
subject={{ subjectType: 'case', subjectId: caseId }}
perspective="operator"
title={caseTitle}
protocol={protocol}
channels={['app', 'whatsapp']}
status={isClosed ? 'closed' : 'open'}
counterpartLabel={`Participante: ${participantName}`}
headerActions={<button type="button" onClick={handleClose}>Fechar</button>}
avatars="initials"
quickReplies={quickReplies}
labels={{ messageInputPlaceholder: 'Mensagem ao participante' }}
/>| Prop | Tipo | Efeito |
| ---- | ---- | ------ |
| api | ConversationThreadApi | fetchMessages, sendMessage e resolveAttachmentUrl obrigatórios; markRead e subscribe opcionais (sem markRead não há marcação de leitura nem onMarkedRead). Deve ser estável (constante de módulo ou useMemo): um objeto novo a cada render recria o adapter e a revalidação. É o mesmo adapter de ParticipantConversationsApi, sem listConversations e openConversation |
| subject | ParticipantSubjectRef | o assunto da conversa |
| perspective | 'participant' \| 'operator' | padrão participant (comportamento da tela de sempre) |
| title | string | título do cabeçalho |
| protocol?, channels? | | selo copiável e selos de canal; ausentes não desenham nada |
| status? | 'open' \| 'closed' | closed tira o compositor e mostra labels.closedNotice |
| counterpartLabel? | string | linha curta sob o título, montada pelo host |
| headerActions? | ReactNode | slot à direita do cabeçalho; ausente não desenha nada. Espera botões curtos ou só ícone, cada um com aria-label: com ações, a barra quebra de linha e o título mantém no mínimo 6rem |
| renderComposerLock? | (conversation) => ReactNode \| undefined | devolver um nó troca o compositor por ele (aviso + ações do host), dentro de cv-p-thread__lock; undefined mantém o compositor. closed continua tendo prioridade. Avaliado a cada render; o rascunho fica no controlador e volta ao destravar. Também existe em ParticipantConversations |
| onBack? | () => void | ausente = sem seta (o diálogo do host já tem o próprio fechar) |
| quickReplies? | QuickReply[] | chips acima do campo; tocar preenche, nunca envia |
| renderSubjectIcon?, subjectGroups? | | ícone do assunto no cabeçalho, como na lista |
| avatars?, renderAuthorAvatar?, tail? | | iguais aos de ParticipantConversations |
| labels?, theme?, className?, locale?, channel? | | iguais aos de ParticipantConversations |
| pendingMessages?, onRetryPending?, onMarkedRead? | | fila durável do host e aviso de leitura registrada |
Perspectiva. direction é sempre do ponto de vista da empresa. Em participant, "minha" é inbound.
Em operator, "minha" é outbound: a bolha própria vai à direita, os tiques de enviada, entregue e lida
aparecem nas mensagens do operador, e o avatar de iniciais e o rótulo de autor (authorName) aparecem nas da
contraparte (inbound). O envio, o rascunho local, os estados sending/queued/failed e o retry são os de
sempre. A leitura segue a perspectiva: o componente chama markRead(subject) quando há mensagens da outra
parte sem readAt e sem status: 'read'; o operador marca só as inbound. Sem subscribe, a conversa
revalida ao voltar o foco, a visibilidade e a conexão.
Exemplo de adapter do operador. O painel já tem as rotas dele; o adapter só traduz:
import { z } from 'zod'
import { participantMessageSchema } from '@adatechnology/conversation-contracts'
import type { ConversationThreadApi } from '@adatechnology/conversations-ui/participant'
export const operatorThreadApi: ConversationThreadApi = {
fetchMessages: async ({ subjectType, subjectId }, params) =>
z.array(participantMessageSchema).parse(await http.get(`/conversations/${subjectType}/${subjectId}/messages`, { params })),
sendMessage: async ({ subject, clientMessageId, text, files }) => {
const body = await http.post(`/conversations/${subject.subjectType}/${subject.subjectId}/messages`, { text, files }, {
headers: { 'Idempotency-Key': clientMessageId },
})
return { outcome: 'sent', message: participantMessageSchema.parse(body) }
},
markRead: ({ subjectType, subjectId }) => http.post(`/conversations/${subjectType}/${subjectId}/read`),
resolveAttachmentUrl: async (attachment, disposition) => (await http.get(`/attachments/${attachment.id}/url`, { params: { disposition } })).url,
}Tema claro do painel. Os tokens padrão já são claros. Para um painel bege com acento laranja, basta mapear:
.panel-conversation {
--cv-p-surface: #f2efe9;
--cv-p-surface-raised: #ffffff;
--cv-p-text: #1f2a30;
--cv-p-text-muted: #59636a;
--cv-p-border: #cfc8bb;
--cv-p-accent: #a85a1c;
--cv-p-highlight: #fbf3ea;
}Os contrastes são verificados por teste (participantThemeContrast.test.ts) com os padrões claro e escuro e com este
mapeamento: texto, hora, autor, links e valores copiáveis nas duas bolhas a 4,5:1 ou mais; tiques, borda da bolha
própria e sublinhado dos valores copiáveis a 3:1 ou mais; o fundo pontilhado se mantém sutil (no máximo 2:1 contra a
superfície) e nenhum texto depende dele (o erro de carga ganha painel sólido). A borda da bolha recebida é
decorativa (cerca de 1,4:1): quem a distingue da página é o preenchimento, e o texto passa de 4,5:1. Ao mapear o
acento, mantenha-o a 4,5:1 contra --cv-p-highlight, que é onde ficam os links da bolha própria. Os contrastes só valem com --cv-p-highlight opaco: o padrão do tema escuro do pacote é translúcido (rgb(213 138 71 / 10%)), então o host que mapeia o token deve fornecer um valor opaco.
Gravação de áudio e transcrição (audioRecording)
Capacidade opt-in: sem a prop audioRecording, não há microfone, e o compositor e a bolha mantêm o markup de
antes, byte a byte. A prop existe em ParticipantConversations e em ConversationThread.
import { ParticipantConversations } from '@adatechnology/conversations-ui/participant'
;<ParticipantConversations
api={participantApi}
audioRecording={{
maxDurationMs: 120_000,
maxSizeBytes: 8 * 1024 * 1024,
onCaptureChange: (isCapturing) => captureRegistry.set('audio', isCapturing),
}}
/>Tipo ParticipantAudioRecordingOptions, exportado por @adatechnology/conversations-ui/participant:
| Campo | Efeito |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| maxDurationMs? | teto da gravação. Só baixa: acima de 5 min (padrão) é cortado para 5 min |
| maxSizeBytes? | teto do arquivo. Só baixa: fica no menor valor entre DEFAULT_MAX_RECORDING_BYTES (16 MiB, a política de áudio da API) e o teto do canal |
| onCaptureChange?(isCapturing) | true de requesting até sair de review; false ao sair. Nunca recebe o áudio |
Regras de exibição e envio:
- O microfone só aparece com a prop e com o campo de texto vazio. Com texto ou anexo, o lugar dele é do botão de enviar.
- O canal só desliga a gravação.
portalewebchatnão gravam;app,emailewhatsappgravam. - Sem
MediaRecorderougetUserMediano navegador, o microfone não aparece. - O áudio gravado vai como mensagem própria, pelo mesmo caminho do anexo (
send({ files: [file] })por dentro). O rascunho de texto e os anexos do rascunho não são alterados. Dois toques seguidos enviam uma mensagem só. onSendAudioé fiação interna do controlador da thread, não prop do host. O host só passaaudioRecording.
Estados do gravador: idle → requesting → recording → review (ouvir, enviar ou descartar). Os caminhos de erro
são denied, unsupported, empty e tooLarge, com mensagem no compositor. Durante a gravação, uma interrupção
(aba oculta, pagehide, trilha do microfone encerrada ou muda por mais de 3 s) para a captura e vai para review; em
review, pausa e mantém. Desmontar o componente descarta. O microfone é liberado em toda saída.
Player. Cada anexo audio/* mostra o player .cv-p-audio (tocar e pausar, posição, velocidade 1×, 1,5× e 2×) e
depois o nome do arquivo. Nada é baixado antes do primeiro toque (preload="none"); a URL é resolvida ao montar, por
id do anexo. A duração aparece depois do toque quando o servidor não manda durationMs. Tocar um áudio pausa o que
estava tocando em qualquer player da visão do participante na página (o player do operador e uma segunda cópia do
pacote não são coordenados).
Bloco de transcrição (por transcription.status do anexo):
pending: linha "Transcribing…" (default em inglês).ready: aviso "gerada por máquina" e o texto, com expandir e recolher quando passa do espaço. Texto vazio mostra "Nothing was said in the audio" (é resultado, não falha).failedcomreason: 'too_long'emaxDurationMs: aviso com o limite aplicado.- Qualquer outro
failed,disabledou ausência do campo: só o player.
Rótulos novos. Todos opcionais em ParticipantConversationsLabels; os defaults também entraram em DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS. {minutes} é calculado pela UI a partir de maxDurationMs (piso em minutos inteiros; abaixo de 1 minuto nada é desenhado), não vem do servidor. Os defaults em inglês estão em
DEFAULT_PARTICIPANT_AUDIO_LABELS (tipo ParticipantAudioLabels). Placeholders entre chaves são preenchidos pela
própria UI.
| Chave | Default | Onde aparece |
| ----------------------- | ------------------------------------------------------------ | -------------------------------------------- |
| recordAudio | Record audio | botão do microfone |
| recordingInProgress | Recording | grupo em recording |
| stopRecording | Stop recording | parar |
| recordingElapsed | {elapsed} of {limit} | relógio da gravação |
| reviewAudio | Listen before sending | dica em review |
| sendAudio | Send audio | enviar |
| discardAudio | Discard audio | descartar |
| microphoneDenied | No permission to use the microphone. | denied |
| recordingUnsupported | This device cannot record audio. | unsupported |
| audioEmpty | Nothing was captured by the microphone. | empty |
| audioTooLarge | Audio too large to send. | tooLarge |
| audioPlayer | Audio | grupo do player |
| playAudio | Play audio | tocar |
| pauseAudio | Pause audio | pausar |
| audioPosition | Audio position | nome do controle de posição |
| audioPositionValue | {position} of {duration} | aria-valuetext (sem duração, só a posição) |
| playbackRate | Playback speed {rate} | velocidade |
| audioUnavailable | Audio unavailable | falha do player |
| transcribing | Transcribing… | pending |
| transcriptionReady | Transcription ready | região viva ao terminar |
| machineTranscription | Transcription generated by machine | aviso de ready |
| emptyTranscription | Nothing was said in the audio | ready vazio |
| transcriptionTooLong | Transcription only for audios shorter than {minutes} minutes | too_long ({minutes} vem do servidor) |
| showFullTranscription | Show all | expandir |
| collapseTranscription | Show less | recolher |
Classes novas (todas .cv-p-*, em styles.css): .cv-p-audio, .cv-p-audio__toggle, .cv-p-audio__seek,
.cv-p-audio__time, .cv-p-audio__rate, .cv-p-audio__media, .cv-p-audio__error, .cv-p-composer__record,
.cv-p-recorder (com --review), .cv-p-recorder__dot, .cv-p-recorder__elapsed, .cv-p-recorder__hint,
.cv-p-recorder__stop, .cv-p-recorder__send, .cv-p-recorder__discard, .cv-p-transcription (com --pending e
--too-long), .cv-p-transcription__notice, .cv-p-transcription__text, .cv-p-transcription__empty e
.cv-p-transcription__toggle. Não há variável --cv-p-* nova: o áudio usa as que já existem (--cv-p-accent,
--cv-p-text-muted, --cv-p-danger e as demais da tabela acima).
Limites conhecidos desta versão
- WhatsApp: não ative o microfone com
channel="whatsapp"antes da validação: o Chromium grava WebM, que o WhatsApp não aceita. A capacidade do canalwhatsappdeclara gravação, mas o envio de áudio de volta ao WhatsApp não foi validado ponta a ponta nesta branch. Não trate a gravação nessa conversa como pronta até a validação da spec 264. - Safari e iPhone: o formato e a gravação no Safari/iPhone (
audio/mp4) e o pedido de permissão no toque ficam a conferir em aparelho real na T7.1 do consumidor. - Evento
stopausente: se ostopdoMediaRecordernunca chegar (a especificação o garante), a UI permanece emrecordingaté o usuário descartar a gravação. Um vigia opcional fica para uma versão futura.
Validação do anexo. transcription e durationMs inválidos reprovam o anexo inteiro (só chaves desconhecidas são
descartadas): valide por anexo com participantTranscriptionSchema.safeParse e descarte o campo que falhar.
Limitações conhecidas
- O avatar alinha pelo topo da bolha, não pelo rabinho no canto inferior.
- Sem suporte a RTL: o rabinho e o avatar assumem escrita da esquerda para a direita.
Variáveis --cv-p-*
Defina no .cv-p, no wrapper (className) ou em qualquer ancestral. Há também a prop theme
(primaryColor → --cv-p-accent, backgroundColor → --cv-p-surface, textPrimary → --cv-p-text,
textSecondary → --cv-p-text-muted).
| Variável | Para que serve | Padrão (claro) |
| ------------------------- | -------------------------------------------- | -------------- |
| --cv-p-surface | fundo da tela | #ffffff |
| --cv-p-surface-raised | fundo de bolhas recebidas e campos | #f3f4f6 |
| --cv-p-text | texto principal | #111827 |
| --cv-p-text-muted | texto secundário | #5b6573 |
| --cv-p-border | bordas | #d5d9df |
| --cv-p-accent | destaque, botão primário, bolha própria | #a85a1c |
| --cv-p-accent-contrast | texto sobre o destaque | #ffffff |
| --cv-p-highlight | fundo de "espera sua resposta" e da bolha própria | #fcf3ea (use cor opaca: é fundo de bolha, e translúcido deixa o wallpaper aparecer) |
| --cv-p-danger | erro e falha de envio | #c62828 |
| --cv-p-radius | raio dos cantos | 0 |
| --cv-p-wallpaper | fundo da lista de mensagens (none desliga) | pontos sutis |
| --cv-p-scrollbar-thumb | polegar das barras de rolagem finas | --cv-p-border |
| --cv-p-scrollbar-thumb-hover | polegar ao passar o mouse ou arrastar | --cv-p-accent |
| --cv-p-tick | tiques de enviada/entregue na bolha própria | --cv-p-text-muted |
| --cv-p-tick-read | tique de lida | #0369a1 (escuro #5cc8ff) |
| --cv-p-channel-app | cor do selo do canal app | o destaque |
| --cv-p-channel-whatsapp | cor do selo do canal WhatsApp | #20914f |
O tema escuro segue a classe .dark (mesma do restante do pacote). Os nomes .cv-p-* e --cv-p-* são API
pública e não mudam sem nova versão maior.
Aviso: ConversationChannel foi ampliado
ConversationChannel agora inclui 'app' e 'portal'. Quem tem um switch exaustivo sobre o tipo (sem
default) passa a ter erro de compilação até tratar os dois novos valores. Quem só escreve o tipo não é
afetado.
