@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.
- 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 +51 -6
- package/dist/index.js +273 -135
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +4 -2
- package/dist/styles.css +16 -0
- 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/hooks/useConversationMessages.test.ts +90 -0
- package/src/hooks/useConversationMessages.ts +116 -2
- package/src/hooks/useLoadOlderMessagesScroll.test.ts +21 -0
- package/src/hooks/useLoadOlderMessagesScroll.ts +76 -0
- package/src/index.ts +5 -1
- package/src/preview/ConversationSimulatorPanel.tsx +4 -0
- package/src/preview/createMockConversationsApi.ts +5 -1
- package/src/styles.css +19 -0
- package/src/workspace/ConversationPane.tsx +32 -5
- package/src/workspace/labels.ts +4 -0
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
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
mediaTypeOf,
|
|
17
17
|
simulatorPanelLabelsOf,
|
|
18
18
|
toConversationSimulatorClient
|
|
19
|
-
} from "../chunk-
|
|
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
|
|
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
|
+
"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
|
}
|
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
|
+
}
|
|
@@ -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)}`
|