@adatechnology/conversations-ui 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
- import { M as MessagePayload, C as ConversationSummary, a as ConversationEventSource, b as ConversationsApi, L as ListConversationsParams, c as ConversationPage, S as SSEProvider, d as ConversationDocument, e as SendPreviewMediaParams, P as PreviewUploadedMedia, f as PreviewWebhookClient, R as ResolveMediaUrl } from '../ConversationSimulatorPanel-DmV8uaFE.js';
2
- export { A as AudioRecorderButton, g as AudioRecorderButtonLabels, h as AudioRecorderButtonProps, i as ConversationPreview, j as ConversationPreviewProps, k as ConversationSimulatorClient, l as ConversationSimulatorPanel, m as ConversationSimulatorPanelLabels, n as ConversationSimulatorPanelProps, o as CreatePreviewMediaUploaderParams, p as CreatePreviewWebhookClientParams, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS, q as DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS, r as DEFAULT_MEDIA_UPLOAD_PATH, s as PreviewInProductionError, t as PreviewMediaUploadRejectedError, u as PreviewMediaUploadRequest, v as PreviewUploadedMedia, w as PreviewWebhookRejectedError, x as SIMULATOR_FILE_MEDIA_KINDS, y as SendSimulatorMediaParams, z as SimulatorMediaKind, T as ToSimulatorClientParams, B as acceptsMediaKind, E as assertPreviewEnvironment, F as createPreviewMediaPoster, G as createPreviewMediaUploader, H as createPreviewWebhookClient, I as isConversationSimulatorClient, J as mediaKindOf, K as mediaTypeOf, N as signPreviewPayload, O as simulatorPanelLabelsOf, Q as toConversationSimulatorClient } from '../ConversationSimulatorPanel-DmV8uaFE.js';
1
+ import { M as MessagePayload, C as ConversationSummary, a as ConversationEventSource, b as ConversationsApi, L as ListConversationsParams, c as ConversationPage, S as SSEProvider, d as ConversationDocument, e as SendPreviewMediaParams, P as PreviewUploadedMedia, f as PreviewWebhookClient, R as ResolveMediaUrl } from '../ConversationSimulatorPanel-DnXElfY8.js';
2
+ export { A as AudioRecorderButton, g as AudioRecorderButtonLabels, h as AudioRecorderButtonProps, i as ConversationPreview, j as ConversationPreviewProps, k as ConversationSimulatorClient, l as ConversationSimulatorPanel, m as ConversationSimulatorPanelLabels, n as ConversationSimulatorPanelProps, o as CreatePreviewMediaUploaderParams, p as CreatePreviewWebhookClientParams, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS, q as DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS, r as DEFAULT_MEDIA_UPLOAD_PATH, s as PreviewInProductionError, t as PreviewMediaUploadRejectedError, u as PreviewMediaUploadRequest, v as PreviewUploadedMedia, w as PreviewWebhookRejectedError, x as SIMULATOR_FILE_MEDIA_KINDS, y as SendSimulatorMediaParams, z as SimulatorMediaKind, T as ToSimulatorClientParams, B as acceptsMediaKind, E as assertPreviewEnvironment, F as createPreviewMediaPoster, G as createPreviewMediaUploader, H as createPreviewWebhookClient, I as isConversationSimulatorClient, J as mediaKindOf, K as mediaTypeOf, N as signPreviewPayload, O as simulatorPanelLabelsOf, Q as toConversationSimulatorClient } from '../ConversationSimulatorPanel-DnXElfY8.js';
3
3
  import { InteractiveReplyOption } from '@adatechnology/meta-whatsapp-contracts/testing';
4
4
  import * as react from 'react';
5
5
  export { PREVIEW_MEDIA_ID_PREFIX } from '@adatechnology/meta-whatsapp-contracts';
@@ -16,7 +16,7 @@ import {
16
16
  mediaTypeOf,
17
17
  simulatorPanelLabelsOf,
18
18
  toConversationSimulatorClient
19
- } from "../chunk-L4OE4ORQ.js";
19
+ } from "../chunk-4P5KMWOI.js";
20
20
  import "../chunk-PJNR6UMN.js";
21
21
 
22
22
  // src/preview/previewStore.ts
@@ -705,7 +705,9 @@ function createMockConversationsApi(params) {
705
705
  },
706
706
  fetchMessages(conversationId, fetchParams) {
707
707
  return withLatency(() => {
708
- const messages = params.store.listMessages(conversationId);
708
+ const allMessages = params.store.listMessages(conversationId);
709
+ const before = fetchParams?.before;
710
+ const messages = before ? allMessages.filter((message) => message.timestamp < before) : allMessages;
709
711
  const limit = fetchParams?.limit;
710
712
  return limit ? messages.slice(-limit) : messages;
711
713
  });
package/dist/styles.css CHANGED
@@ -526,6 +526,22 @@
526
526
  font-size: 0.875rem;
527
527
  color: #6b7280;
528
528
  }
529
+ .cv-workspace-load-older {
530
+ display: flex;
531
+ justify-content: center;
532
+ padding: 0.5rem 0;
533
+ }
534
+ .cv-workspace-load-older button {
535
+ min-height: 2.75rem;
536
+ border-radius: 0.375rem;
537
+ border: 1px solid var(--cv-border, #e5e7eb);
538
+ padding: 0 0.75rem;
539
+ font-size: 0.8125rem;
540
+ color: #6b7280;
541
+ }
542
+ .cv-workspace-load-older button:disabled {
543
+ opacity: 0.6;
544
+ }
529
545
  .dark .cv-workspace-notice {
530
546
  border-top-color: #374151;
531
547
  background: #1f2937;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -31,7 +31,8 @@
31
31
  "clsx": "^2.1.1",
32
32
  "lucide-react": "^1.21.0",
33
33
  "tailwind-merge": "^3.6.0",
34
- "@adatechnology/meta-whatsapp-contracts": "0.4.0"
34
+ "@adatechnology/meta-whatsapp-contracts": "0.4.0",
35
+ "@adatechnology/conversation-contracts": "0.3.0"
35
36
  },
36
37
  "peerDependencies": {
37
38
  "@xyflow/react": "^12",
@@ -27,4 +27,22 @@ describe('AudioRecorderButton', () => {
27
27
 
28
28
  expect(markup).toContain('h-10 w-10')
29
29
  })
30
+
31
+ // D3: a dica do controle desabilitado tem de ser legível por leitor de tela, não só `title` (que
32
+ // o pacote nem usa) nem só o tooltip visual (`data-cv-tooltip`, que não é lido por AT sozinho).
33
+ it('desabilitado com dica, expõe a dica via aria-describedby para leitor de tela', () => {
34
+ const markup = renderToStaticMarkup(
35
+ <AudioRecorderButton onRecorded={noop} disabled disabledHint="Este canal não grava áudio." />,
36
+ )
37
+
38
+ expect(markup).toContain('disabled=""')
39
+ expect(markup).toContain('aria-describedby=')
40
+ expect(markup).toContain('Este canal não grava áudio.')
41
+ })
42
+
43
+ it('sem dica, o botão desabilitado não ganha aria-describedby à toa', () => {
44
+ const markup = renderToStaticMarkup(<AudioRecorderButton onRecorded={noop} disabled />)
45
+
46
+ expect(markup).not.toContain('aria-describedby=')
47
+ })
30
48
  })
@@ -8,7 +8,7 @@
8
8
  * hospeda e devolve o `mediaId` é o host, via `uploadMedia`.
9
9
  */
10
10
 
11
- import { useCallback, useEffect, useRef, useState } from 'react'
11
+ import { useCallback, useEffect, useId, useRef, useState } from 'react'
12
12
 
13
13
  import { AudioPlayer } from './AudioPlayer'
14
14
 
@@ -56,6 +56,12 @@ export interface AudioRecorderButtonProps {
56
56
  maxDurationMilliseconds?: number
57
57
  labels?: Partial<AudioRecorderButtonLabels>
58
58
  disabled?: boolean
59
+ /**
60
+ * Por que o microfone está desabilitado (RF10, D3) — o canal atual não grava áudio. Sai como
61
+ * `aria-describedby`, não só `data-cv-tooltip`: o tooltip visual não é lido por leitor de tela
62
+ * sozinho, e uma dica que só aparece no hover falha exatamente para quem mais precisa dela.
63
+ */
64
+ disabledHint?: string
59
65
  }
60
66
 
61
67
  /**
@@ -96,9 +102,12 @@ export function AudioRecorderButton({
96
102
  maxDurationMilliseconds = DEFAULT_MAX_RECORDING_MILLISECONDS,
97
103
  labels,
98
104
  disabled,
105
+ disabledHint,
99
106
  }: AudioRecorderButtonProps) {
100
107
  const labelOf = (key: keyof AudioRecorderButtonLabels): string =>
101
108
  labels?.[key] ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS[key]
109
+ const disabledHintId = useId()
110
+ const showDisabledHint = Boolean(disabled && disabledHint)
102
111
  const [isRecording, setIsRecording] = useState(false)
103
112
  const [pending, setPending] = useState<PendingRecording | undefined>(undefined)
104
113
  const recorderRef = useRef<MediaRecorder | null>(null)
@@ -226,8 +235,9 @@ export function AudioRecorderButton({
226
235
  type="button"
227
236
  disabled={disabled || pending !== undefined}
228
237
  onClick={() => (isRecording ? stop() : void start())}
229
- data-cv-tooltip={toggleLabel}
238
+ data-cv-tooltip={showDisabledHint ? disabledHint : toggleLabel}
230
239
  aria-label={toggleLabel}
240
+ aria-describedby={showDisabledHint ? disabledHintId : undefined}
231
241
  aria-pressed={isRecording}
232
242
  /* Mesma caixa do botão de enviar: o microfone ocupa o lugar dele enquanto o campo está
233
243
  vazio, e qualquer diferença de tamanho faz a barra pular a cada letra digitada. */
@@ -243,6 +253,11 @@ export function AudioRecorderButton({
243
253
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 11a7 7 0 0 1-14 0" /><line x1="12" y1="18" x2="12" y2="22" /></svg>
244
254
  )}
245
255
  </button>
256
+ {showDisabledHint && (
257
+ <span id={disabledHintId} className="sr-only">
258
+ {disabledHint}
259
+ </span>
260
+ )}
246
261
  </div>
247
262
  )
248
263
  }
@@ -15,6 +15,7 @@ export const CHANNEL_BRAND_COLOR: Readonly<Record<ConversationChannel, string>>
15
15
  [CONVERSATION_CHANNEL.MESSENGER]: '#0084FF',
16
16
  [CONVERSATION_CHANNEL.INSTAGRAM]: '#E4405F',
17
17
  [CONVERSATION_CHANNEL.WEBCHAT]: '#64748B',
18
+ [CONVERSATION_CHANNEL.EMAIL]: '#EA4335',
18
19
  }
19
20
 
20
21
  export interface ChannelIconProps {
@@ -60,6 +61,16 @@ function Glyph({ channel }: { channel: ConversationChannel }) {
60
61
  )
61
62
  }
62
63
 
64
+ if (channel === CONVERSATION_CHANNEL.EMAIL) {
65
+ return (
66
+ <>
67
+ <rect x="2" y="4" width="20" height="16" rx="3" fill={color} />
68
+ {/* Envelope aberto: o traço mínimo que ainda lê como e-mail numa fila de conversas. */}
69
+ <path d="M3.5 6.5 12 13l8.5-6.5" fill="none" stroke="#fff" strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round" />
70
+ </>
71
+ )
72
+ }
73
+
63
74
  return (
64
75
  <>
65
76
  <circle cx="12" cy="12" r="11" fill={color} />
@@ -39,3 +39,36 @@ describe('MessageBubble com mídia', () => {
39
39
  expect(markup).not.toContain('>planta-baixa.png<')
40
40
  })
41
41
  })
42
+
43
+ // D4, CA04: o e-mail não confirma leitura — o selo de lida não pode aparecer, nem por engano se o
44
+ // backend mandar `status: 'read'` por algum caminho antigo. A tela nunca finge o que o canal não sabe.
45
+ describe('MessageBubble e o selo de lida por canal (D4, CA04)', () => {
46
+ const TEXT_MESSAGE: MessagePayload = {
47
+ id: 'msg-2',
48
+ type: 'text',
49
+ direction: 'outbound',
50
+ sender: 'agent',
51
+ timestamp: '2026-08-05T12:00:00.000Z',
52
+ content: 'Já enviamos a nota',
53
+ status: 'read',
54
+ }
55
+
56
+ it('email não confirma leitura — o selo de lida não aparece mesmo com status "read"', () => {
57
+ const markup = renderToStaticMarkup(<MessageBubble message={TEXT_MESSAGE} isMine channel="email" />)
58
+
59
+ expect(markup).not.toContain('text-sky-500')
60
+ })
61
+
62
+ it('whatsapp confirma leitura — o selo de lida aparece normalmente', () => {
63
+ const markup = renderToStaticMarkup(<MessageBubble message={TEXT_MESSAGE} isMine channel="whatsapp" />)
64
+
65
+ expect(markup).toContain('text-sky-500')
66
+ })
67
+
68
+ it('sem channel, o padrão continua sendo whatsapp — comportamento de antes desta mudança', () => {
69
+ const withChannel = renderToStaticMarkup(<MessageBubble message={TEXT_MESSAGE} isMine channel="whatsapp" />)
70
+ const withoutChannel = renderToStaticMarkup(<MessageBubble message={TEXT_MESSAGE} isMine />)
71
+
72
+ expect(withoutChannel).toEqual(withChannel)
73
+ })
74
+ })
@@ -2,6 +2,8 @@ import { useMemo, useState } from 'react'
2
2
  import { Check } from 'lucide-react'
3
3
  import type { InteractiveSelection, MessagePayload, MessageTranscription } from './types'
4
4
  import { useConversationLocales } from './ConversationLocalesProvider'
5
+ import { channelCapabilityFor } from './channelCapability'
6
+ import type { ConversationChannel } from './conversationChannel'
5
7
  import { StatusTicks } from './StatusTicks'
6
8
  import { MediaRenderer, type ResolveMediaUrl } from './MediaRenderer'
7
9
  import { Lightbox } from './Lightbox'
@@ -36,6 +38,12 @@ export interface MessageBubbleProps {
36
38
  * o que já veio pronto do backend.
37
39
  */
38
40
  onTranscribeAudio?: (messageId: string) => Promise<MessageTranscription | void>
41
+ /**
42
+ * Canal da conversa (RF10, D4). Ausente = `whatsapp`, o comportamento de antes desta mudança.
43
+ * E-mail não confirma leitura (CA04): o selo de lida nunca aparece nesse canal, mesmo que o
44
+ * status chegue como `read` por algum caminho antigo — a tela não finge o que o canal não sabe.
45
+ */
46
+ channel?: ConversationChannel
39
47
  className?: string
40
48
  }
41
49
 
@@ -57,8 +65,13 @@ const MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker'])
57
65
  // tailwind.config do host expondo as cores `whatsapp.*` — ver Wallpaper.tsx e T6.2.
58
66
  export function MessageBubble({
59
67
  message, isMine, senderName, isFirstInGroup = true, isSelecting = false, isSelected = false, onToggleSelect,
60
- onResolveMediaUrl, onInteractiveSelect, onTranscribeAudio, className,
68
+ onResolveMediaUrl, onInteractiveSelect, onTranscribeAudio, channel, className,
61
69
  }: MessageBubbleProps) {
70
+ // D4, CA04: sem confirmação de leitura no canal, `read` nunca aparece — mesmo que chegue assim do
71
+ // backend. Rebaixar para `delivered` em vez de esconder o tick inteiro preserva sent/delivered/
72
+ // failed, que continuam informação válida em qualquer canal.
73
+ const confirmsRead = channelCapabilityFor(channel).confirmsRead
74
+ const displayStatus = message.status === 'read' && !confirmsRead ? 'delivered' : message.status
62
75
  const { bubble, selection } = useConversationLocales()
63
76
  const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
64
77
  const context = useConversations()
@@ -103,9 +116,9 @@ export function MessageBubble({
103
116
  : undefined
104
117
  const displayName = message.sender === 'agent' && senderName ? senderName : bubble[message.sender] ?? message.sender
105
118
 
106
- const tooltipText = message.status === 'read' && message.readAt
119
+ const tooltipText = displayStatus === 'read' && message.readAt
107
120
  ? `${bubble.readAt}${formatDateTime(message.readAt)}`
108
- : message.status === 'failed'
121
+ : displayStatus === 'failed'
109
122
  ? bubble.windowExpired
110
123
  : undefined
111
124
 
@@ -205,8 +218,8 @@ export function MessageBubble({
205
218
  <span className="text-xs text-black/40 dark:text-white/40 font-medium">
206
219
  {formatTimestamp(message.timestamp)}
207
220
  </span>
208
- {isMine && message.status && (
209
- <StatusTicks status={message.status} title={tooltipText} />
221
+ {isMine && displayStatus && (
222
+ <StatusTicks status={displayStatus} title={tooltipText} />
210
223
  )}
211
224
  </div>
212
225
  </div>
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
2
  import { renderToStaticMarkup } from 'react-dom/server'
3
3
 
4
- import { MessageComposer } from './MessageComposer'
4
+ import { MessageComposer, DEFAULT_MESSAGE_COMPOSER_LABELS } from './MessageComposer'
5
5
 
6
6
  describe('MessageComposer', () => {
7
7
  it('mostra a ação ociosa no lugar do enviar quando não há nada para enviar', () => {
@@ -47,3 +47,41 @@ describe('MessageComposer', () => {
47
47
  expect(withoutPort).not.toContain('role="combobox"')
48
48
  })
49
49
  })
50
+
51
+ // RF10, D3: recurso que o canal não tem aparece desabilitado com dica legível por leitor de tela —
52
+ // nunca escondido sem explicação, e nunca só com `title` (inacessível a leitor de tela).
53
+ describe('MessageComposer e a capacidade do canal (RF10, D3)', () => {
54
+ it('webchat não grava áudio — o microfone vem desabilitado com dica em aria-describedby', () => {
55
+ const markup = renderToStaticMarkup(<MessageComposer onSend={() => {}} onAttach={() => {}} channel="webchat" />)
56
+
57
+ expect(markup).toContain('aria-describedby=')
58
+ expect(markup).toContain(DEFAULT_MESSAGE_COMPOSER_LABELS.audioDisabledHint)
59
+ })
60
+
61
+ it('whatsapp grava áudio — o microfone continua habilitado, sem a dica de bloqueio', () => {
62
+ const markup = renderToStaticMarkup(<MessageComposer onSend={() => {}} onAttach={() => {}} channel="whatsapp" />)
63
+
64
+ expect(markup).not.toContain(DEFAULT_MESSAGE_COMPOSER_LABELS.audioDisabledHint)
65
+ })
66
+
67
+ it('todo canal atual aceita anexo e resposta rápida — os dois continuam habilitados', () => {
68
+ const markup = renderToStaticMarkup(
69
+ <MessageComposer
70
+ onSend={() => {}}
71
+ onAttach={() => {}}
72
+ channel="email"
73
+ savedQuickReplies={{ listQuickReplies: async () => [], conversationId: 'c1' }}
74
+ />,
75
+ )
76
+
77
+ expect(markup).not.toContain(DEFAULT_MESSAGE_COMPOSER_LABELS.attachDisabledHint)
78
+ expect(markup).not.toContain(DEFAULT_MESSAGE_COMPOSER_LABELS.quickRepliesDisabledHint)
79
+ })
80
+
81
+ it('sem channel, o padrão continua sendo whatsapp — nada muda para quem já usava o pacote', () => {
82
+ const withChannel = renderToStaticMarkup(<MessageComposer onSend={() => {}} onAttach={() => {}} channel="whatsapp" />)
83
+ const withoutChannel = renderToStaticMarkup(<MessageComposer onSend={() => {}} onAttach={() => {}} />)
84
+
85
+ expect(withoutChannel).toEqual(withChannel)
86
+ })
87
+ })
@@ -9,6 +9,8 @@ import {
9
9
  type ReactNode,
10
10
  } from 'react'
11
11
  import { AudioRecorderButton } from './AudioRecorderButton'
12
+ import { channelCapabilityFor } from './channelCapability'
13
+ import type { ConversationChannel } from './conversationChannel'
12
14
  import type { ConversationsFeatures } from './types'
13
15
  import { cn } from './lib/cn'
14
16
  import { COMPOSER_BAR_CLASS, QUICK_REPLY_PILL_CLASS } from './composer.constant'
@@ -53,6 +55,12 @@ export interface MessageComposerLabels {
53
55
  send: string
54
56
  removeAttachment: string
55
57
  quickReplies: string
58
+ /** RF10, D3: por que o anexo está desabilitado no canal atual. */
59
+ attachDisabledHint: string
60
+ /** RF10, D3: por que o microfone está desabilitado no canal atual. */
61
+ audioDisabledHint: string
62
+ /** RF10, D3: por que a mensagem pronta está desabilitada no canal atual. */
63
+ quickRepliesDisabledHint: string
56
64
  }
57
65
 
58
66
  export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
@@ -61,6 +69,9 @@ export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
61
69
  send: 'Enviar',
62
70
  removeAttachment: 'Remover anexo',
63
71
  quickReplies: 'Mensagens prontas',
72
+ attachDisabledHint: 'Este canal não aceita anexo.',
73
+ audioDisabledHint: 'Este canal não grava áudio.',
74
+ quickRepliesDisabledHint: 'Este canal não aceita mensagem pronta.',
64
75
  }
65
76
 
66
77
  /**
@@ -90,6 +101,12 @@ export interface MessageComposerProps {
90
101
  value?: string
91
102
  onChange?: (value: string) => void
92
103
  features?: ConversationsFeatures
104
+ /**
105
+ * Canal da conversa (RF10, D3). Ausente = `whatsapp`, o comportamento de antes desta mudança.
106
+ * Decide o que aparece desabilitado com dica: anexo, microfone e mensagem pronta que o canal não
107
+ * suporta nunca somem sem explicação — ficam visíveis e desabilitados.
108
+ */
109
+ channel?: ConversationChannel
93
110
  placeholder?: string
94
111
  maxLength?: number
95
112
  disabled?: boolean
@@ -147,6 +164,7 @@ export const MessageComposer = ({
147
164
  quickReplyVariables,
148
165
  savedQuickReplies,
149
166
  features,
167
+ channel,
150
168
  placeholder = 'Digite uma mensagem...',
151
169
  maxLength,
152
170
  disabled = false,
@@ -161,6 +179,16 @@ export const MessageComposer = ({
161
179
  const sendLabel = labels?.send ?? DEFAULT_MESSAGE_COMPOSER_LABELS.send
162
180
  const removeAttachmentLabel = labels?.removeAttachment ?? DEFAULT_MESSAGE_COMPOSER_LABELS.removeAttachment
163
181
  const quickRepliesLabel = labels?.quickReplies ?? DEFAULT_MESSAGE_COMPOSER_LABELS.quickReplies
182
+ const attachDisabledHintLabel = labels?.attachDisabledHint ?? DEFAULT_MESSAGE_COMPOSER_LABELS.attachDisabledHint
183
+ const audioDisabledHintLabel = labels?.audioDisabledHint ?? DEFAULT_MESSAGE_COMPOSER_LABELS.audioDisabledHint
184
+ const quickRepliesDisabledHintLabel =
185
+ labels?.quickRepliesDisabledHint ?? DEFAULT_MESSAGE_COMPOSER_LABELS.quickRepliesDisabledHint
186
+ const capability = channelCapabilityFor(channel)
187
+ const attachmentsAllowed = capability.attachments.accepted
188
+ const audioRecordingAllowed = capability.audio.records
189
+ const quickRepliesAllowed = capability.quickReplies
190
+ const attachHintId = useId()
191
+ const quickRepliesHintId = useId()
164
192
  const [internalText, setInternalText] = useState('')
165
193
  const [showEmoji, setShowEmoji] = useState(false)
166
194
  const [attachments, setAttachments] = useState<FilePreview[]>([])
@@ -397,7 +425,14 @@ export const MessageComposer = ({
397
425
  * duração ou revisão diferentes) não muda de comportamento ao atualizar.
398
426
  */
399
427
  const effectiveIdleAction =
400
- idleAction ?? (onAttach ? <AudioRecorderButton onRecorded={(file) => onAttach(file)} /> : undefined)
428
+ idleAction ??
429
+ (onAttach ? (
430
+ <AudioRecorderButton
431
+ onRecorded={(file) => onAttach(file)}
432
+ disabled={!audioRecordingAllowed}
433
+ disabledHint={audioRecordingAllowed ? undefined : audioDisabledHintLabel}
434
+ />
435
+ ) : undefined)
401
436
 
402
437
  const canSend = text.trim().length > 0 || attachments.length > 0
403
438
  const remaining = maxLength ? maxLength - text.length : null
@@ -546,18 +581,27 @@ export const MessageComposer = ({
546
581
  </div>
547
582
 
548
583
  {showQuickRepliesButton && (
549
- <button
550
- ref={quickRepliesTriggerRef}
551
- type="button"
552
- data-cv-tooltip={quickRepliesLabel}
553
- aria-label={quickRepliesLabel}
554
- onClick={openQuickRepliesViaButton}
555
- className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors"
556
- >
557
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
558
- <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
559
- </svg>
560
- </button>
584
+ <>
585
+ <button
586
+ ref={quickRepliesTriggerRef}
587
+ type="button"
588
+ disabled={!quickRepliesAllowed}
589
+ data-cv-tooltip={quickRepliesAllowed ? quickRepliesLabel : quickRepliesDisabledHintLabel}
590
+ aria-label={quickRepliesLabel}
591
+ aria-describedby={quickRepliesAllowed ? undefined : quickRepliesHintId}
592
+ onClick={openQuickRepliesViaButton}
593
+ className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent"
594
+ >
595
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
596
+ <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
597
+ </svg>
598
+ </button>
599
+ {!quickRepliesAllowed && (
600
+ <span id={quickRepliesHintId} className="sr-only">
601
+ {quickRepliesDisabledHintLabel}
602
+ </span>
603
+ )}
604
+ </>
561
605
  )}
562
606
 
563
607
  {showAttachButton && (
@@ -571,15 +615,22 @@ export const MessageComposer = ({
571
615
  className="hidden"
572
616
  />
573
617
  <button
574
- data-cv-tooltip={attachLabel}
618
+ disabled={!attachmentsAllowed}
619
+ data-cv-tooltip={attachmentsAllowed ? attachLabel : attachDisabledHintLabel}
575
620
  onClick={() => fileInputRef.current?.click()}
576
- className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors"
621
+ aria-describedby={attachmentsAllowed ? undefined : attachHintId}
622
+ className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent"
577
623
  aria-label={attachLabel}
578
624
  >
579
625
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
580
626
  <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
581
627
  </svg>
582
628
  </button>
629
+ {!attachmentsAllowed && (
630
+ <span id={attachHintId} className="sr-only">
631
+ {attachDisabledHintLabel}
632
+ </span>
633
+ )}
583
634
  </>
584
635
  )}
585
636
 
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ CONVERSATION_CHANNEL as CORE_CONVERSATION_CHANNEL,
4
+ CHANNEL_CAPABILITIES as CORE_CHANNEL_CAPABILITIES,
5
+ } from '@adatechnology/conversation-contracts'
6
+
7
+ import { CONVERSATION_CHANNEL } from './conversationChannel'
8
+ import { channelCapabilityFor } from './channelCapability'
9
+
10
+ const UI_CHANNELS: readonly string[] = Object.values(CONVERSATION_CHANNEL)
11
+
12
+ describe('channelCapabilityFor', () => {
13
+ // O ponto da spec 211 (RF10): "duas verdades sobre capacidade de canal" é o risco que o plano
14
+ // registra. Para canal que o contrato conhece, a capacidade tem de ser exatamente o objeto do
15
+ // contrato — nunca uma cópia redeclarada aqui, porque cópia diverge no dia em que só uma muda.
16
+ it('para canal que o contrato conhece, devolve o objeto do contrato — nunca redeclarado', () => {
17
+ for (const channel of CORE_CONVERSATION_CHANNEL) {
18
+ if (!UI_CHANNELS.includes(channel)) continue
19
+
20
+ expect(channelCapabilityFor(channel as never)).toBe(CORE_CHANNEL_CAPABILITIES[channel])
21
+ }
22
+ })
23
+
24
+ // Messenger e Instagram são canais que a UI já servia antes do núcleo existir, e o contrato não
25
+ // os conhece (RF1). Convivência decidida: eles caem no fallback documentado do WhatsApp, o outro
26
+ // canal da Meta com a mesma janela de 24h — não ganham tabela própria.
27
+ it('canal que o contrato não conhece cai no fallback documentado, não numa tabela própria', () => {
28
+ expect(channelCapabilityFor(CONVERSATION_CHANNEL.MESSENGER)).toBe(CORE_CHANNEL_CAPABILITIES.whatsapp)
29
+ expect(channelCapabilityFor(CONVERSATION_CHANNEL.INSTAGRAM)).toBe(CORE_CHANNEL_CAPABILITIES.whatsapp)
30
+ })
31
+
32
+ it('ausência de canal continua sendo whatsapp, como antes desta mudança', () => {
33
+ expect(channelCapabilityFor(undefined)).toBe(CORE_CHANNEL_CAPABILITIES.whatsapp)
34
+ })
35
+ })
36
+
37
+ // As duas linhas que a tela usa para não mentir (D3, D4) — fixadas contra o contrato para que uma
38
+ // mudança futura nele quebre este teste em vez de quebrar a tela em silêncio.
39
+ describe('linhas da capacidade que a tela não pode fingir', () => {
40
+ it('email não confirma leitura — não existe pixel de rastreio aqui (D4)', () => {
41
+ expect(CORE_CHANNEL_CAPABILITIES.email.confirmsRead).toBe(false)
42
+ })
43
+
44
+ it('portal ouve áudio e não grava — Permissions-Policy nega o microfone lá (ADR-0073 §2)', () => {
45
+ expect(CORE_CHANNEL_CAPABILITIES.portal.audio).toEqual({ plays: true, records: false })
46
+ })
47
+ })
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Capacidade **comportamental** do canal (RF10, D3): confirma leitura, tem janela de sessão (e de
3
+ * quantas horas), aceita anexo e de que tamanho, aceita áudio, aceita resposta rápida, precisa de
4
+ * transporte próprio. Vem de `@adatechnology/conversation-contracts`, nunca redeclarada aqui — é a
5
+ * "duas verdades sobre capacidade de canal" que o plano da spec 211 lista como risco.
6
+ *
7
+ * `messenger` e `instagram` são os dois canais que esta UI já servia antes do núcleo de conversa
8
+ * existir, e que o contrato não conhece (ele nasceu do que a spec 183 exercitava: `email`,
9
+ * `whatsapp`, `app`, `portal`, `webchat`). Convivência decidida aqui: o vocabulário de canal desta
10
+ * UI (`conversationChannel.ts`) é a união dos dois, e a capacidade comportamental de Messenger e
11
+ * Instagram cai no fallback do WhatsApp — o outro canal da Meta, com a mesma janela de 24h, a mesma
12
+ * confirmação de leitura e o mesmo anexo/áudio. Não é aproximação arbitrária: as três plataformas
13
+ * (WhatsApp, Messenger, Instagram) compartilham a política de sessão de 24h da Meta.
14
+ */
15
+ import {
16
+ CHANNEL_CAPABILITIES as CORE_CHANNEL_CAPABILITIES,
17
+ CONVERSATION_CHANNEL as CORE_CONVERSATION_CHANNEL,
18
+ type ChannelCapability,
19
+ type ConversationChannel as CoreConversationChannel,
20
+ } from '@adatechnology/conversation-contracts'
21
+
22
+ import { DEFAULT_CONVERSATION_CHANNEL, type ConversationChannel } from './conversationChannel'
23
+
24
+ const CORE_CHANNELS: readonly string[] = CORE_CONVERSATION_CHANNEL
25
+
26
+ function isCoreChannel(channel: ConversationChannel): channel is ConversationChannel & CoreConversationChannel {
27
+ return CORE_CHANNELS.includes(channel)
28
+ }
29
+
30
+ /** Fallback documentado para os canais da Meta que o contrato ainda não descreve. */
31
+ const META_ONLY_CHANNEL_CAPABILITY: ChannelCapability = CORE_CHANNEL_CAPABILITIES.whatsapp
32
+
33
+ export function channelCapabilityFor(channel: ConversationChannel | undefined): ChannelCapability {
34
+ const resolved = channel ?? DEFAULT_CONVERSATION_CHANNEL
35
+
36
+ return isCoreChannel(resolved) ? CORE_CHANNEL_CAPABILITIES[resolved] : META_ONLY_CHANNEL_CAPABILITY
37
+ }
@@ -17,6 +17,7 @@ export const CONVERSATION_CHANNEL = {
17
17
  MESSENGER: 'messenger',
18
18
  INSTAGRAM: 'instagram',
19
19
  WEBCHAT: 'webchat',
20
+ EMAIL: 'email',
20
21
  } as const
21
22
  export type ConversationChannel = (typeof CONVERSATION_CHANNEL)[keyof typeof CONVERSATION_CHANNEL]
22
23
 
@@ -35,14 +36,21 @@ export const HANDLE_KIND = {
35
36
  PHONE: 'phone',
36
37
  USERNAME: 'username',
37
38
  SESSION: 'session',
39
+ EMAIL: 'email',
38
40
  } as const
39
41
  export type HandleKind = (typeof HANDLE_KIND)[keyof typeof HANDLE_KIND]
40
42
 
43
+ /**
44
+ * Só o que é **exibição** do canal — rótulo, ícone, como reabrir, como mostrar o identificador.
45
+ * Capacidade de **comportamento** (confirma leitura, tem janela e de quantas horas, aceita anexo e
46
+ * áudio, precisa de transporte próprio) não mora mais aqui: para canal que
47
+ * `@adatechnology/conversation-contracts` conhece, ela vem de `channelCapabilityFor` (RF10). Manter
48
+ * os dois tipos de capacidade juntos foi o que criou a "janela" duplicada (`windowHours`) que a
49
+ * spec 211 mandou apagar — a mesma verdade não pode morar em dois lugares.
50
+ */
41
51
  export type ChannelCapabilities = {
42
52
  readonly label: string
43
53
  readonly icon: string
44
- readonly hasSessionWindow: boolean
45
- readonly windowHours: number
46
54
  readonly reopenMechanism: ReopenMechanism
47
55
  readonly handleKind: HandleKind
48
56
  }
@@ -51,8 +59,6 @@ export const CHANNEL_CAPABILITIES: Readonly<Record<ConversationChannel, ChannelC
51
59
  [CONVERSATION_CHANNEL.WHATSAPP]: {
52
60
  label: 'WhatsApp',
53
61
  icon: '💬',
54
- hasSessionWindow: true,
55
- windowHours: 24,
56
62
  reopenMechanism: REOPEN_MECHANISM.TEMPLATE,
57
63
  handleKind: HANDLE_KIND.PHONE,
58
64
  },
@@ -60,16 +66,12 @@ export const CHANNEL_CAPABILITIES: Readonly<Record<ConversationChannel, ChannelC
60
66
  // Messenger também tem 24h, mas reabre com message tag — não com template aprovado.
61
67
  label: 'Messenger',
62
68
  icon: '📨',
63
- hasSessionWindow: true,
64
- windowHours: 24,
65
69
  reopenMechanism: REOPEN_MECHANISM.TAG,
66
70
  handleKind: HANDLE_KIND.USERNAME,
67
71
  },
68
72
  [CONVERSATION_CHANNEL.INSTAGRAM]: {
69
73
  label: 'Instagram',
70
74
  icon: '📷',
71
- hasSessionWindow: true,
72
- windowHours: 24,
73
75
  reopenMechanism: REOPEN_MECHANISM.TAG,
74
76
  handleKind: HANDLE_KIND.USERNAME,
75
77
  },
@@ -77,11 +79,16 @@ export const CHANNEL_CAPABILITIES: Readonly<Record<ConversationChannel, ChannelC
77
79
  // Chat próprio: sem intermediário, sem janela. Bloquear o composer aqui seria inventar limite.
78
80
  label: 'Chat do site',
79
81
  icon: '🌐',
80
- hasSessionWindow: false,
81
- windowHours: 0,
82
82
  reopenMechanism: REOPEN_MECHANISM.NONE,
83
83
  handleKind: HANDLE_KIND.SESSION,
84
84
  },
85
+ [CONVERSATION_CHANNEL.EMAIL]: {
86
+ // RF10: e-mail entra no vocabulário; ele não reabre por template nem por tag — é só resposta.
87
+ label: 'E-mail',
88
+ icon: '✉️',
89
+ reopenMechanism: REOPEN_MECHANISM.NONE,
90
+ handleKind: HANDLE_KIND.EMAIL,
91
+ },
85
92
  }
86
93
 
87
94
  export function capabilitiesOf(channel: ConversationChannel | undefined): ChannelCapabilities {
@@ -135,6 +142,8 @@ export function formatContactHandle(params: FormatContactHandleParams): string {
135
142
 
136
143
  if (handleKind === HANDLE_KIND.PHONE) return formatPhone(params.handle)
137
144
  if (handleKind === HANDLE_KIND.USERNAME) return params.handle.startsWith('@') ? params.handle : `@${params.handle}`
145
+ // Endereço de e-mail já é o identificador exibível — nem telefone, nem "@usuário".
146
+ if (handleKind === HANDLE_KIND.EMAIL) return params.handle
138
147
 
139
148
  // Sessão de chat de site é um id opaco: mostrar o hash inteiro não ajuda ninguém.
140
149
  return `Visitante ${params.handle.slice(-6)}`