@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.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.
Files changed (129) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2502 -676
  7. package/dist/index.d.ts +919 -17
  8. package/dist/index.js +3676 -675
  9. package/dist/preview/index.d.ts +62 -105
  10. package/dist/preview/index.js +162 -284
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +1 -1
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +11 -6
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +147 -47
  23. package/src/ConversationListItem.tsx +8 -6
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +53 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +67 -7
  29. package/src/EmojiPicker.tsx +2 -1
  30. package/src/InteractiveMessage.tsx +3 -0
  31. package/src/Lightbox.tsx +1 -1
  32. package/src/MediaRenderer.tsx +88 -15
  33. package/src/MessageBubble.test.tsx +41 -0
  34. package/src/MessageBubble.tsx +47 -5
  35. package/src/MessageComposer.test.tsx +35 -0
  36. package/src/MessageComposer.tsx +122 -17
  37. package/src/MessageText.tsx +2 -1
  38. package/src/MessageTimestamp.tsx +2 -1
  39. package/src/RichMessageComposer.test.tsx +113 -0
  40. package/src/RichMessageComposer.tsx +551 -0
  41. package/src/SimpleEmojiPicker.tsx +5 -3
  42. package/src/StatusTicks.tsx +1 -1
  43. package/src/Toast.tsx +4 -0
  44. package/src/Tooltip.test.ts +42 -0
  45. package/src/Tooltip.tsx +167 -0
  46. package/src/Wallpaper.tsx +27 -13
  47. package/src/WhatsAppMessageEditor.tsx +10 -7
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +550 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/flows/FlowConnectionEdge.tsx +104 -0
  60. package/src/flows/FlowGroupHeader.tsx +12 -2
  61. package/src/flows/FlowLegend.tsx +125 -0
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +219 -34
  65. package/src/flows/FlowNodePanel.tsx +153 -39
  66. package/src/flows/FlowPalette.tsx +156 -70
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1255 -0
  70. package/src/flows/flowCanvasModel.test.ts +456 -0
  71. package/src/flows/flowCanvasModel.ts +378 -0
  72. package/src/flows/flowEditorOps.test.ts +276 -0
  73. package/src/flows/flowEditorOps.ts +202 -0
  74. package/src/flows/flowGraph.ts +78 -53
  75. package/src/flows/flowMenuPlacement.test.ts +130 -0
  76. package/src/flows/flowMenuPlacement.ts +86 -0
  77. package/src/flows/index.ts +51 -2
  78. package/src/flows/labels.ts +180 -0
  79. package/src/flows/workspaceContract.test.ts +126 -0
  80. package/src/hooks/useContainerWidth.ts +35 -0
  81. package/src/hooks/useConversationRealtime.ts +10 -8
  82. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  83. package/src/hooks/useUrlFilterState.ts +107 -0
  84. package/src/icon.constant.ts +12 -0
  85. package/src/index.ts +100 -0
  86. package/src/lib/composer-formatting.test.ts +78 -0
  87. package/src/lib/composer-formatting.ts +145 -0
  88. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  89. package/src/lib/whatsapp-formatting.tsx +28 -3
  90. package/src/listing/index.tsx +202 -0
  91. package/src/pagination.constant.ts +10 -0
  92. package/src/preview/ConversationPreview.tsx +84 -45
  93. package/src/preview/ConversationSimulatorClient.ts +143 -0
  94. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  95. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  96. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  97. package/src/preview/createPreviewBridgeClient.ts +124 -0
  98. package/src/preview/createPreviewMediaUploader.ts +82 -0
  99. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  100. package/src/preview/createPreviewWebhookClient.ts +99 -3
  101. package/src/preview/index.ts +36 -2
  102. package/src/preview/previewMediaUploader.test.ts +61 -0
  103. package/src/providers/ConversationsProvider.tsx +8 -6
  104. package/src/providers/types.ts +59 -2
  105. package/src/quickReply.test.ts +58 -0
  106. package/src/replyLatency.test.ts +71 -0
  107. package/src/replyLatency.ts +57 -0
  108. package/src/settings/MessagesWorkspace.tsx +571 -0
  109. package/src/settings/TopicsForm.tsx +2 -0
  110. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  111. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  112. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  113. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  114. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  115. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  116. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  117. package/src/styles.css +858 -0
  118. package/src/theme.ts +13 -0
  119. package/src/types.ts +26 -0
  120. package/src/workspace/BulkTemplateModal.tsx +132 -0
  121. package/src/workspace/ConversationPane.tsx +432 -0
  122. package/src/workspace/ConversationsInboxList.tsx +194 -0
  123. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  124. package/src/workspace/index.ts +17 -0
  125. package/src/workspace/labels.test.ts +17 -0
  126. package/src/workspace/labels.ts +85 -0
  127. package/src/workspace/useConversationsInbox.ts +332 -0
  128. package/dist/types-B5C1DLu1.d.ts +0 -365
  129. package/src/preview/AudioRecorderButton.tsx +0 -117
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { MessageComposer } from './MessageComposer'
5
+
6
+ describe('MessageComposer', () => {
7
+ it('mostra a ação ociosa no lugar do enviar quando não há nada para enviar', () => {
8
+ const markup = renderToStaticMarkup(
9
+ <MessageComposer onSend={() => {}} idleAction={<button aria-label="Gravar áudio" />} />,
10
+ )
11
+
12
+ expect(markup).toContain('aria-label="Gravar áudio"')
13
+ expect(markup).not.toContain('aria-label="Enviar"')
14
+ })
15
+
16
+ it('mantém o botão de enviar quando não há ação ociosa', () => {
17
+ const markup = renderToStaticMarkup(<MessageComposer onSend={() => {}} />)
18
+
19
+ expect(markup).toContain('aria-label="Enviar"')
20
+ })
21
+
22
+ it('mostra o enviar assim que há texto, mesmo com ação ociosa configurada', () => {
23
+ const markup = renderToStaticMarkup(
24
+ <MessageComposer
25
+ onSend={() => {}}
26
+ value="oi"
27
+ onChange={() => {}}
28
+ idleAction={<button aria-label="Gravar áudio" />}
29
+ />,
30
+ )
31
+
32
+ expect(markup).toContain('aria-label="Enviar"')
33
+ expect(markup).not.toContain('aria-label="Gravar áudio"')
34
+ })
35
+ })
@@ -1,18 +1,56 @@
1
- import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent } from 'react'
1
+ import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent, type ReactNode } from 'react'
2
+ import { AudioRecorderButton } from './AudioRecorderButton'
2
3
  import type { ConversationsFeatures } from './types'
3
4
  import { cn } from './lib/cn'
5
+ import { COMPOSER_BAR_CLASS, QUICK_REPLY_PILL_CLASS } from './composer.constant'
4
6
  import { EmojiPicker } from './EmojiPicker'
5
7
 
8
+ /**
9
+ * Mensagem pronta que o atendente cola no campo com um clique.
10
+ *
11
+ * `text` aceita string com `{{variavel}}` ou função: a string cobre o caso comum (copy fixa com o
12
+ * nome do cliente no meio) sem o host escrever código, e a função cobre o que precisa de lógica —
13
+ * escolher texto por produto, pluralizar, formatar moeda.
14
+ */
15
+ export interface QuickReply {
16
+ key: string
17
+ /** O que aparece no chip, emoji incluso: `👋 Saudação`. */
18
+ label: string
19
+ text: string | ((variables: Readonly<Record<string, string>>) => string)
20
+ }
21
+
22
+ /**
23
+ * Troca `{{nome}}` pelos valores passados. Variável ausente vira string vazia, e não o literal
24
+ * `{{nome}}`: mandar "Olá {{nome}}!" para o cliente é pior que mandar "Olá !".
25
+ */
26
+ export function applyQuickReplyVariables(
27
+ template: string,
28
+ variables: Readonly<Record<string, string>> = {},
29
+ ): string {
30
+ return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, chave: string) => variables[chave] ?? '')
31
+ }
32
+
33
+ export function resolveQuickReply(
34
+ quickReply: QuickReply,
35
+ variables: Readonly<Record<string, string>> = {},
36
+ ): string {
37
+ return typeof quickReply.text === 'function'
38
+ ? quickReply.text(variables)
39
+ : applyQuickReplyVariables(quickReply.text, variables)
40
+ }
41
+
6
42
  export interface MessageComposerLabels {
7
43
  emoji: string
8
44
  attach: string
9
45
  send: string
46
+ removeAttachment: string
10
47
  }
11
48
 
12
49
  export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
13
50
  emoji: 'Emoji',
14
51
  attach: 'Anexar',
15
52
  send: 'Enviar',
53
+ removeAttachment: 'Remover anexo',
16
54
  }
17
55
 
18
56
  export interface MessageComposerProps {
@@ -26,12 +64,23 @@ export interface MessageComposerProps {
26
64
  maxLength?: number
27
65
  disabled?: boolean
28
66
  acceptedFileTypes?: string
67
+ /**
68
+ * Ocupa o lugar do botão de enviar enquanto não há nada para enviar — é onde o WhatsApp põe o
69
+ * microfone. Fora do campo, o botão vira um bloco solto ao lado do pill e quebra a barra.
70
+ */
71
+ idleAction?: ReactNode
72
+ /** Mensagens prontas exibidas acima do campo. Vazio ou ausente, a faixa não é renderizada. */
73
+ quickReplies?: readonly QuickReply[]
74
+ /** Valores para `{{variavel}}` — tipicamente nome do cliente, produto, protocolo. */
75
+ quickReplyVariables?: Readonly<Record<string, string>>
29
76
  className?: string
30
77
  classNames?: Partial<MessageComposerClassNames>
31
78
  }
32
79
 
33
80
  export interface MessageComposerClassNames {
34
81
  root: string
82
+ quickReplies: string
83
+ quickReply: string
35
84
  field: string
36
85
  }
37
86
 
@@ -62,11 +111,14 @@ export const MessageComposer = ({
62
111
  onAttach,
63
112
  value: externalValue,
64
113
  onChange: externalOnChange,
114
+ quickReplies,
115
+ quickReplyVariables,
65
116
  features,
66
117
  placeholder = 'Digite uma mensagem...',
67
118
  maxLength,
68
119
  disabled = false,
69
120
  acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
121
+ idleAction,
70
122
  className,
71
123
  classNames,
72
124
  labels,
@@ -74,6 +126,7 @@ export const MessageComposer = ({
74
126
  const emojiLabel = labels?.emoji ?? DEFAULT_MESSAGE_COMPOSER_LABELS.emoji
75
127
  const attachLabel = labels?.attach ?? DEFAULT_MESSAGE_COMPOSER_LABELS.attach
76
128
  const sendLabel = labels?.send ?? DEFAULT_MESSAGE_COMPOSER_LABELS.send
129
+ const removeAttachmentLabel = labels?.removeAttachment ?? DEFAULT_MESSAGE_COMPOSER_LABELS.removeAttachment
77
130
  const [internalText, setInternalText] = useState('')
78
131
  const [showEmoji, setShowEmoji] = useState(false)
79
132
  const [attachments, setAttachments] = useState<FilePreview[]>([])
@@ -171,6 +224,24 @@ export const MessageComposer = ({
171
224
  }
172
225
  }, [text, setText])
173
226
 
227
+ /**
228
+ * Microfone por padrão, sem o host precisar compor nada.
229
+ *
230
+ * Antes o gravador era só um componente exportado e um slot vazio: cada inbox tinha que lembrar de
231
+ * juntar os dois. Dois produtos, dois resultados — um ligou, o outro não, e a ausência não dava
232
+ * erro nenhum. Um composer de WhatsApp sem microfone está incompleto, então o default certo é ter.
233
+ *
234
+ * Depende de `onAttach` porque áudio gravado é um arquivo para entregar, e microfone que grava sem
235
+ * ter para onde mandar é pior que microfone nenhum — o operador fala e o áudio evapora. É a mesma
236
+ * regra de capacidade usada no resto do pacote: sem a porta, a afordância não aparece.
237
+ *
238
+ * `idleAction` continua vencendo: quem já compunha o próprio gravador (com rótulos, limite de
239
+ * duração ou revisão diferentes) não muda de comportamento ao atualizar.
240
+ */
241
+ const effectiveIdleAction =
242
+ idleAction ??
243
+ (onAttach ? <AudioRecorderButton onRecorded={(file) => onAttach(file)} /> : undefined)
244
+
174
245
  const canSend = text.trim().length > 0 || attachments.length > 0
175
246
  const remaining = maxLength ? maxLength - text.length : null
176
247
 
@@ -178,7 +249,36 @@ export const MessageComposer = ({
178
249
  /* A barra é a superfície (cinza, largura cheia, sem raio) e o campo dentro é que arredonda —
179
250
  ordem do WhatsApp. Invertido, o pill arredondado ia até a borda da tela e os cantos
180
251
  descobriam o fundo branco da página, que lia como defeito. */
181
- <div className={cn('bg-[#f0f2f5] px-2 py-2', className)}>
252
+ <div className={cn(COMPOSER_BAR_CLASS, className)}>
253
+ {/* Uma linha com scroll no celular e wrap no desktop: em 375px seis chips em wrap empurrariam
254
+ o campo para fora da tela, e o campo é a razão de a barra existir. */}
255
+ {quickReplies && quickReplies.length > 0 && (
256
+ <div
257
+ className={cn(
258
+ 'mb-2 flex flex-nowrap gap-1 overflow-x-auto px-1 sm:flex-wrap sm:overflow-x-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
259
+ classNames?.quickReplies,
260
+ )}
261
+ >
262
+ {quickReplies.map((quickReply) => (
263
+ <button
264
+ data-cv-tooltip={quickReply.label} aria-label={quickReply.label}
265
+ key={quickReply.key}
266
+ type="button"
267
+ // Preenche o campo em vez de enviar: mensagem pronta é ponto de partida, e quem
268
+ // atende quase sempre ajusta uma palavra antes de mandar. Enviar direto no clique
269
+ // transformaria um toque errado em mensagem entregue ao cliente.
270
+ onClick={() => {
271
+ setText(resolveQuickReply(quickReply, quickReplyVariables))
272
+ textareaRef.current?.focus()
273
+ }}
274
+ className={cn(QUICK_REPLY_PILL_CLASS, classNames?.quickReply)}
275
+ >
276
+ {quickReply.label}
277
+ </button>
278
+ ))}
279
+ </div>
280
+ )}
281
+
182
282
  {attachments.length > 0 && (
183
283
  <div className="flex gap-2 px-1 pb-2 overflow-x-auto">
184
284
  {attachments.map((a, i) => (
@@ -190,7 +290,7 @@ export const MessageComposer = ({
190
290
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" strokeWidth="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
191
291
  </div>
192
292
  )}
193
- <button onClick={() => removeAttachment(i)} className="absolute -top-2 -right-2 w-5 h-5 bg-gray-600 text-white rounded-full flex items-center justify-center hover:bg-gray-800 text-xs">✕</button>
293
+ <button data-cv-tooltip={removeAttachmentLabel} aria-label={removeAttachmentLabel} onClick={() => removeAttachment(i)} className="absolute -top-2 -right-2 w-5 h-5 bg-gray-600 text-white rounded-full flex items-center justify-center hover:bg-gray-800 text-xs">✕</button>
194
294
  </div>
195
295
  ))}
196
296
  </div>
@@ -199,7 +299,7 @@ export const MessageComposer = ({
199
299
  <div className={cn('flex items-end gap-1.5 rounded-xl bg-white px-3 py-2', classNames?.field)}>
200
300
  {showEmojiButton && (
201
301
  <div className="relative flex-shrink-0">
202
- <button onClick={() => setShowEmoji(v => !v)} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors" aria-label={emojiLabel}>
302
+ <button data-cv-tooltip={emojiLabel} onClick={() => setShowEmoji(v => !v)} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors" aria-label={emojiLabel}>
203
303
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><circle cx="9" cy="9" r="0.5" fill="currentColor"/><circle cx="15" cy="9" r="0.5" fill="currentColor"/></svg>
204
304
  </button>
205
305
  {showEmoji && (
@@ -225,24 +325,29 @@ export const MessageComposer = ({
225
325
  {showAttachButton && (
226
326
  <>
227
327
  <input ref={fileInputRef} type="file" multiple accept={acceptedFileTypes} onChange={handleFileChange} className="hidden" />
228
- <button onClick={() => fileInputRef.current?.click()} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors" aria-label={attachLabel}>
328
+ <button data-cv-tooltip={attachLabel} onClick={() => fileInputRef.current?.click()} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors" aria-label={attachLabel}>
229
329
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><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"/></svg>
230
330
  </button>
231
331
  </>
232
332
  )}
233
333
 
234
- <button
235
- onClick={sendMessage}
236
- disabled={!canSend || disabled}
237
- className={`w-10 h-10 flex items-center justify-center rounded-full flex-shrink-0 transition-all ${
238
- canSend && !disabled
239
- ? 'bg-[#00a884] text-white hover:bg-[#06cf9c] shadow-sm'
240
- : 'bg-gray-200 text-gray-400 cursor-not-allowed'
241
- }`}
242
- aria-label={sendLabel}
243
- >
244
- <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
245
- </button>
334
+ {!canSend && effectiveIdleAction ? (
335
+ <div className="flex-shrink-0">{effectiveIdleAction}</div>
336
+ ) : (
337
+ <button
338
+ data-cv-tooltip={sendLabel}
339
+ onClick={sendMessage}
340
+ disabled={!canSend || disabled}
341
+ className={`w-10 h-10 flex items-center justify-center rounded-full flex-shrink-0 transition-all ${
342
+ canSend && !disabled
343
+ ? 'bg-[#00a884] text-white hover:bg-[#06cf9c] shadow-sm'
344
+ : 'bg-gray-200 text-gray-400 cursor-not-allowed'
345
+ }`}
346
+ aria-label={sendLabel}
347
+ >
348
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
349
+ </button>
350
+ )}
246
351
  </div>
247
352
 
248
353
  {remaining !== null && (
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useState } from 'react'
2
+ import { CHAT_TEXT_SECONDARY_CLASS } from './theme'
2
3
  import type { MessagePayload } from './types'
3
4
  import { useConversations } from './providers/ConversationsProvider'
4
5
  import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
@@ -22,7 +23,7 @@ export function MessageText({ message }: MessageTextProps) {
22
23
  }, [message.content])
23
24
 
24
25
  if (message.type === 'template')
25
- return <p className="text-sm italic text-[#667781]">{message.content ?? 'Template message'}</p>
26
+ return <p className={`text-sm italic ${CHAT_TEXT_SECONDARY_CLASS}`}>{message.content ?? 'Template message'}</p>
26
27
 
27
28
  return (
28
29
  <div
@@ -1,4 +1,5 @@
1
1
  import { StatusTicks } from './StatusTicks'
2
+ import { CHAT_TEXT_SECONDARY_CLASS } from './theme'
2
3
 
3
4
  export interface MessageTimestampProps {
4
5
  timestamp: string
@@ -9,7 +10,7 @@ export interface MessageTimestampProps {
9
10
  export function MessageTimestamp({ timestamp, status, isOutbound }: MessageTimestampProps) {
10
11
  return (
11
12
  <div className="flex items-center justify-end gap-[3px]">
12
- <span className="text-[11px] text-[#667781] leading-[15px] select-none">
13
+ <span className={`text-[11px] ${CHAT_TEXT_SECONDARY_CLASS} leading-[15px] select-none`}>
13
14
  {timestamp}
14
15
  </span>
15
16
  {isOutbound && status && (
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { RichMessageComposer, type RichComposerQuickReply, type RichComposerVariable } from './RichMessageComposer'
5
+
6
+ const noop = () => {}
7
+
8
+ const QUICK_REPLIES: RichComposerQuickReply[] = [
9
+ { id: 'greeting', label: 'Saudação', text: 'Olá!', tooltip: 'Abre o atendimento' },
10
+ ]
11
+
12
+ const VARIABLES: RichComposerVariable[] = [
13
+ { id: 'name', label: 'Nome do cliente', value: '{{nome}}' },
14
+ ]
15
+
16
+ describe('RichMessageComposer', () => {
17
+ it('mostra as dicas padrão em todas as ações da barra', () => {
18
+ const markup = renderToStaticMarkup(
19
+ <RichMessageComposer value="" onChange={noop} onSend={noop} onAttachFiles={noop} />,
20
+ )
21
+
22
+ for (const tooltip of ['Negrito', 'Itálico', 'Tachado', 'Monoespaçado', 'Anexar arquivo', 'Enviar']) {
23
+ expect(markup).toContain(`data-cv-tooltip="${tooltip}"`)
24
+ }
25
+ })
26
+
27
+ it('deixa o host trocar o texto de uma dica sem mexer nas outras', () => {
28
+ const markup = renderToStaticMarkup(
29
+ <RichMessageComposer value="" onChange={noop} onSend={noop} tooltips={{ bold: 'Deixar em negrito' }} />,
30
+ )
31
+
32
+ expect(markup).toContain('data-cv-tooltip="Deixar em negrito"')
33
+ expect(markup).not.toContain('data-cv-tooltip="Negrito"')
34
+ expect(markup).toContain('data-cv-tooltip="Itálico"')
35
+ })
36
+
37
+ it('esconde só a ação que o host desligou', () => {
38
+ const markup = renderToStaticMarkup(
39
+ <RichMessageComposer value="" onChange={noop} onSend={noop} toolbar={{ monospace: false }} />,
40
+ )
41
+
42
+ expect(markup).not.toContain('data-cv-tooltip="Monoespaçado"')
43
+ expect(markup).toContain('data-cv-tooltip="Negrito"')
44
+ })
45
+
46
+ it('não desenha o anexo quando não há como anexar', () => {
47
+ const markup = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
48
+
49
+ expect(markup).not.toContain('data-cv-tooltip="Anexar arquivo"')
50
+ })
51
+
52
+ it('desenha os chips de resposta rápida com o rótulo e a dica do host', () => {
53
+ const markup = renderToStaticMarkup(
54
+ <RichMessageComposer value="" onChange={noop} onSend={noop} quickReplies={QUICK_REPLIES} />,
55
+ )
56
+
57
+ expect(markup).toContain('Saudação')
58
+ expect(markup).toContain('data-cv-tooltip="Abre o atendimento"')
59
+ })
60
+
61
+ it('só oferece variáveis quando o host manda alguma', () => {
62
+ const semVariaveis = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
63
+ const comVariaveis = renderToStaticMarkup(
64
+ <RichMessageComposer value="" onChange={noop} onSend={noop} variables={VARIABLES} />,
65
+ )
66
+
67
+ expect(semVariaveis).not.toContain('data-cv-tooltip="Variáveis disponíveis"')
68
+ expect(comVariaveis).toContain('data-cv-tooltip="Variáveis disponíveis"')
69
+ })
70
+
71
+ it('nasce com toda formatação apagada, e diz isso ao leitor de tela', () => {
72
+ const markup = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
73
+
74
+ expect(markup).toContain('aria-pressed="false"')
75
+ expect(markup).not.toContain('aria-pressed="true"')
76
+ })
77
+
78
+ it('cede o lugar do enviar à ação ociosa enquanto não há texto', () => {
79
+ const vazio = renderToStaticMarkup(
80
+ <RichMessageComposer value="" onChange={noop} onSend={noop} idleAction={<button aria-label="Gravar áudio" />} />,
81
+ )
82
+ const comTexto = renderToStaticMarkup(
83
+ <RichMessageComposer value="oi" onChange={noop} onSend={noop} idleAction={<button aria-label="Gravar áudio" />} />,
84
+ )
85
+
86
+ expect(vazio).toContain('aria-label="Gravar áudio"')
87
+ expect(vazio).not.toContain('data-cv-tooltip="Enviar"')
88
+ expect(comTexto).toContain('data-cv-tooltip="Enviar"')
89
+ })
90
+
91
+ it('desenha as quatro formatações abertas enquanto a barra não foi medida', () => {
92
+ const markup = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
93
+
94
+ expect(markup).toContain('data-cv-tooltip="Negrito"')
95
+ // O botão que recolhe as quatro só aparece com a barra medida e estreita.
96
+ expect(markup).not.toContain('data-cv-tooltip="Formatação"')
97
+ })
98
+
99
+ it('mostra o enviar com anexo na fila e nenhum texto, senão a imagem fica presa', () => {
100
+ const markup = renderToStaticMarkup(
101
+ <RichMessageComposer
102
+ value=""
103
+ onChange={noop}
104
+ onSend={noop}
105
+ hasQueuedAttachments
106
+ idleAction={<button aria-label="Gravar áudio" />}
107
+ />,
108
+ )
109
+
110
+ expect(markup).toContain('data-cv-tooltip="Enviar"')
111
+ expect(markup).not.toContain('aria-label="Gravar áudio"')
112
+ })
113
+ })