@adatechnology/conversations-ui 0.3.2 → 0.4.1
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.
- package/dist/{ConversationSimulatorPanel-DmV8uaFE.d.ts → ConversationSimulatorPanel-DnXElfY8.d.ts} +87 -73
- package/dist/{chunk-L4OE4ORQ.js → chunk-4P5KMWOI.js} +219 -161
- package/dist/index.d.ts +44 -6
- package/dist/index.js +16 -5
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +1 -1
- package/package.json +3 -2
- package/src/AudioRecorderButton.test.tsx +18 -0
- package/src/AudioRecorderButton.tsx +17 -2
- package/src/ChannelIcon.tsx +11 -0
- package/src/MessageBubble.test.tsx +33 -0
- package/src/MessageBubble.tsx +18 -5
- package/src/MessageComposer.test.tsx +39 -1
- package/src/MessageComposer.tsx +66 -15
- package/src/channelCapability.test.ts +47 -0
- package/src/channelCapability.ts +37 -0
- package/src/conversationChannel.ts +19 -10
- package/src/conversationWindow.ts +7 -5
- package/src/index.ts +5 -1
- package/src/preview/ConversationSimulatorPanel.tsx +4 -0
- package/src/workspace/ConversationPane.tsx +2 -0
package/dist/index.js
CHANGED
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
StatusTicks,
|
|
53
53
|
applyQuickReplyVariables,
|
|
54
54
|
capabilitiesOf,
|
|
55
|
+
channelCapabilityFor,
|
|
55
56
|
channelFiltersFor,
|
|
56
57
|
contactFlag,
|
|
57
58
|
conversationsOf,
|
|
@@ -76,7 +77,7 @@ import {
|
|
|
76
77
|
useConversationLocales,
|
|
77
78
|
useConversations,
|
|
78
79
|
useQuickRepliesPicker
|
|
79
|
-
} from "./chunk-
|
|
80
|
+
} from "./chunk-4P5KMWOI.js";
|
|
80
81
|
import {
|
|
81
82
|
ZERO_WIDTH_SPACE,
|
|
82
83
|
cn,
|
|
@@ -1380,10 +1381,10 @@ var WINDOW_FILTERS = [
|
|
|
1380
1381
|
var WARNING_RATIO = 0.5;
|
|
1381
1382
|
var CRITICAL_RATIO = 0.875;
|
|
1382
1383
|
function windowOf(params) {
|
|
1383
|
-
const
|
|
1384
|
-
if (
|
|
1384
|
+
const capability = channelCapabilityFor(params.channel);
|
|
1385
|
+
if (capability.sessionWindowHours === null) return CONVERSATION_WINDOW.FRESH;
|
|
1385
1386
|
if (!params.lastInboundAt) return CONVERSATION_WINDOW.EXPIRED;
|
|
1386
|
-
const windowMs =
|
|
1387
|
+
const windowMs = capability.sessionWindowHours * HOUR_MS;
|
|
1387
1388
|
const elapsed = params.now - new Date(params.lastInboundAt).getTime();
|
|
1388
1389
|
if (elapsed >= windowMs) return CONVERSATION_WINDOW.EXPIRED;
|
|
1389
1390
|
if (elapsed >= windowMs * CRITICAL_RATIO) return CONVERSATION_WINDOW.CRITICAL;
|
|
@@ -1438,7 +1439,8 @@ var CHANNEL_BRAND_COLOR = {
|
|
|
1438
1439
|
[CONVERSATION_CHANNEL.WHATSAPP]: "#25D366",
|
|
1439
1440
|
[CONVERSATION_CHANNEL.MESSENGER]: "#0084FF",
|
|
1440
1441
|
[CONVERSATION_CHANNEL.INSTAGRAM]: "#E4405F",
|
|
1441
|
-
[CONVERSATION_CHANNEL.WEBCHAT]: "#64748B"
|
|
1442
|
+
[CONVERSATION_CHANNEL.WEBCHAT]: "#64748B",
|
|
1443
|
+
[CONVERSATION_CHANNEL.EMAIL]: "#EA4335"
|
|
1442
1444
|
};
|
|
1443
1445
|
function Glyph({ channel }) {
|
|
1444
1446
|
const color = CHANNEL_BRAND_COLOR[channel];
|
|
@@ -1468,6 +1470,12 @@ function Glyph({ channel }) {
|
|
|
1468
1470
|
/* @__PURE__ */ jsx10("circle", { cx: "16.4", cy: "7.6", r: "1", fill: "#fff" })
|
|
1469
1471
|
] });
|
|
1470
1472
|
}
|
|
1473
|
+
if (channel === CONVERSATION_CHANNEL.EMAIL) {
|
|
1474
|
+
return /* @__PURE__ */ jsxs8(Fragment3, { children: [
|
|
1475
|
+
/* @__PURE__ */ jsx10("rect", { x: "2", y: "4", width: "20", height: "16", rx: "3", fill: color }),
|
|
1476
|
+
/* @__PURE__ */ jsx10("path", { d: "M3.5 6.5 12 13l8.5-6.5", fill: "none", stroke: "#fff", strokeWidth: "1.6", strokeLinejoin: "round", strokeLinecap: "round" })
|
|
1477
|
+
] });
|
|
1478
|
+
}
|
|
1471
1479
|
return /* @__PURE__ */ jsxs8(Fragment3, { children: [
|
|
1472
1480
|
/* @__PURE__ */ jsx10("circle", { cx: "12", cy: "12", r: "11", fill: color }),
|
|
1473
1481
|
/* @__PURE__ */ jsx10("path", { d: "M6.5 8.5h11v7h-5.5L8.5 18v-2.5h-2v-7Z", fill: "#fff" })
|
|
@@ -6269,6 +6277,7 @@ function ConversationPane({
|
|
|
6269
6277
|
{
|
|
6270
6278
|
message,
|
|
6271
6279
|
isMine: message.direction === "outbound",
|
|
6280
|
+
channel: conversation.channel,
|
|
6272
6281
|
isFirstInGroup: !previous || previous.sender !== message.sender,
|
|
6273
6282
|
...messageSelection ? {
|
|
6274
6283
|
isSelecting: selectedMessageIds.size > 0,
|
|
@@ -6375,6 +6384,7 @@ function ConversationPane({
|
|
|
6375
6384
|
value: draft,
|
|
6376
6385
|
onChange: setDraft,
|
|
6377
6386
|
onSend: (text) => void handleSend(text),
|
|
6387
|
+
channel: conversation.channel,
|
|
6378
6388
|
...onAttach ? { onAttach: (file) => void handleAttach(file) } : {},
|
|
6379
6389
|
placeholder: labels.composerPlaceholder,
|
|
6380
6390
|
...quickReplies ? { quickReplies } : {},
|
|
@@ -7141,6 +7151,7 @@ export {
|
|
|
7141
7151
|
buildTranscriptFilename,
|
|
7142
7152
|
buildTranscriptText,
|
|
7143
7153
|
capabilitiesOf,
|
|
7154
|
+
channelCapabilityFor,
|
|
7144
7155
|
channelFiltersFor,
|
|
7145
7156
|
contactFlag,
|
|
7146
7157
|
createMediaUrlResolver,
|
package/dist/preview/index.d.ts
CHANGED
|
@@ -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-
|
|
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-
|
|
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';
|
package/dist/preview/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adatechnology/conversations-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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/
|
|
34
|
+
"@adatechnology/conversation-contracts": "0.3.0",
|
|
35
|
+
"@adatechnology/meta-whatsapp-contracts": "0.5.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
|
}
|
package/src/ChannelIcon.tsx
CHANGED
|
@@ -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
|
+
})
|
package/src/MessageBubble.tsx
CHANGED
|
@@ -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 =
|
|
119
|
+
const tooltipText = displayStatus === 'read' && message.readAt
|
|
107
120
|
? `${bubble.readAt}${formatDateTime(message.readAt)}`
|
|
108
|
-
:
|
|
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 &&
|
|
209
|
-
<StatusTicks status={
|
|
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
|
+
})
|
package/src/MessageComposer.tsx
CHANGED
|
@@ -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 ??
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
618
|
+
disabled={!attachmentsAllowed}
|
|
619
|
+
data-cv-tooltip={attachmentsAllowed ? attachLabel : attachDisabledHintLabel}
|
|
575
620
|
onClick={() => fileInputRef.current?.click()}
|
|
576
|
-
|
|
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
|
+
}
|