@adatechnology/conversations-ui 0.1.0-rc.26 → 0.1.0-rc.28

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 (81) hide show
  1. package/dist/chunk-DXPSPUWF.js +110 -0
  2. package/dist/{chunk-TV4OQRGH.js → chunk-UZJBYD5O.js} +123 -45
  3. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  4. package/dist/flows/index.d.ts +75 -2
  5. package/dist/flows/index.js +176 -95
  6. package/dist/index.d.ts +37 -10
  7. package/dist/index.js +694 -297
  8. package/dist/preview/index.js +3 -3
  9. package/dist/styles.css +119 -6
  10. package/package.json +2 -2
  11. package/src/AudioPlayer.tsx +8 -0
  12. package/src/AudioRecorderButton.test.tsx +3 -3
  13. package/src/AudioRecorderButton.tsx +3 -3
  14. package/src/AudioTranscription.tsx +4 -1
  15. package/src/Avatar.tsx +1 -1
  16. package/src/ConversationContextPanel.tsx +2 -2
  17. package/src/ConversationDocumentsPanel.tsx +11 -6
  18. package/src/ConversationHeader.test.tsx +66 -0
  19. package/src/ConversationHeader.tsx +147 -47
  20. package/src/ConversationListItem.tsx +3 -2
  21. package/src/ConversationRow.tsx +31 -7
  22. package/src/DocumentsLibrary.tsx +9 -4
  23. package/src/EmojiPicker.tsx +2 -1
  24. package/src/InteractiveMessage.tsx +3 -0
  25. package/src/Lightbox.tsx +1 -1
  26. package/src/MediaRenderer.tsx +2 -0
  27. package/src/MessageBubble.test.tsx +41 -0
  28. package/src/MessageBubble.tsx +27 -8
  29. package/src/MessageComposer.tsx +11 -8
  30. package/src/RichMessageComposer.test.tsx +42 -12
  31. package/src/RichMessageComposer.tsx +216 -45
  32. package/src/SimpleEmojiPicker.tsx +5 -3
  33. package/src/StatusTicks.tsx +1 -1
  34. package/src/Toast.tsx +4 -0
  35. package/src/Tooltip.test.ts +42 -0
  36. package/src/Tooltip.tsx +164 -0
  37. package/src/WhatsAppMessageEditor.tsx +4 -4
  38. package/src/WindowExpiredNotice.tsx +12 -4
  39. package/src/buildOutput.test.ts +79 -0
  40. package/src/composer.constant.ts +33 -0
  41. package/src/conversationWindow.ts +7 -5
  42. package/src/documents/DocumentsWorkspace.tsx +10 -6
  43. package/src/flows/FlowGroupHeader.tsx +2 -2
  44. package/src/flows/FlowMapNode.tsx +2 -1
  45. package/src/flows/FlowNodeCard.tsx +3 -3
  46. package/src/flows/FlowNodePanel.tsx +6 -5
  47. package/src/flows/FlowPalette.tsx +7 -1
  48. package/src/flows/FlowPortalNode.tsx +1 -1
  49. package/src/flows/FlowsWorkspace.tsx +69 -102
  50. package/src/flows/flowEditorOps.test.ts +241 -0
  51. package/src/flows/flowEditorOps.ts +177 -0
  52. package/src/flows/flowGraph.ts +1 -1
  53. package/src/flows/index.ts +16 -0
  54. package/src/flows/labels.ts +4 -0
  55. package/src/flows/workspaceContract.test.ts +95 -0
  56. package/src/hooks/useContainerWidth.ts +35 -0
  57. package/src/hooks/useConversationRealtime.ts +10 -8
  58. package/src/icon.constant.ts +12 -0
  59. package/src/index.ts +1 -0
  60. package/src/lib/composer-formatting.test.ts +78 -0
  61. package/src/lib/composer-formatting.ts +145 -0
  62. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  63. package/src/lib/whatsapp-formatting.tsx +28 -3
  64. package/src/listing/index.tsx +5 -1
  65. package/src/pagination.constant.ts +10 -0
  66. package/src/preview/ConversationSimulatorPanel.tsx +1 -1
  67. package/src/providers/ConversationsProvider.tsx +8 -6
  68. package/src/settings/MessagesWorkspace.tsx +3 -0
  69. package/src/settings/TopicsForm.tsx +2 -0
  70. package/src/settings/TranscriptionSettingsForm.test.tsx +1 -1
  71. package/src/settings/TranscriptionSettingsForm.tsx +1 -0
  72. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  73. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  74. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  75. package/src/settings/WhatsAppTemplatesSettings.tsx +2 -0
  76. package/src/styles.css +100 -4
  77. package/src/workspace/BulkTemplateModal.tsx +4 -2
  78. package/src/workspace/ConversationPane.tsx +49 -19
  79. package/src/workspace/ConversationsInboxList.tsx +17 -8
  80. package/src/workspace/ConversationsWorkspace.tsx +24 -16
  81. package/src/workspace/useConversationsInbox.ts +5 -2
@@ -89,6 +89,18 @@ export function MessageBubble({
89
89
  const isMedia = MEDIA_TYPES.has(message.type)
90
90
  const isTemplate = message.type === 'template'
91
91
  const isInteractive = message.type === 'interactive'
92
+
93
+ /**
94
+ * A legenda chega em `caption` quando o cliente manda, e em `content` quando somos nós — é lá que
95
+ * a API grava o texto do envio. Sem legenda a API repete o nome do arquivo em `content`, que não
96
+ * é conteúdo e não deve aparecer sob a mídia.
97
+ */
98
+ const mediaCaption = isMedia
99
+ ? [message.caption, message.content].find((text) => {
100
+ const trimmed = text?.trim()
101
+ return trimmed && trimmed !== message.filename
102
+ })
103
+ : undefined
92
104
  const displayName = message.sender === 'agent' && senderName ? senderName : bubble[message.sender] ?? message.sender
93
105
 
94
106
  const tooltipText = message.status === 'read' && message.readAt
@@ -104,7 +116,7 @@ export function MessageBubble({
104
116
  const checkbox = (
105
117
  <button
106
118
  onClick={(e) => { e.stopPropagation(); onToggleSelect?.() }}
107
- title={selection.select}
119
+ data-cv-tooltip={selection.select} aria-label={selection.select}
108
120
  className={`
109
121
  flex-shrink-0 self-end mb-1 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all
110
122
  ${isSelected ? 'bg-teal-600 border-teal-600' : 'bg-white/80 dark:bg-black/40 border-black/20 dark:border-white/30'}
@@ -148,7 +160,7 @@ export function MessageBubble({
148
160
  {message.moderation?.isOffensive && (
149
161
  <div
150
162
  className="mb-1 inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-950/60 dark:text-amber-300"
151
- title={message.moderation.terms.length > 0 ? message.moderation.terms.join(', ') : undefined}
163
+ data-cv-tooltip={message.moderation.terms.length > 0 ? message.moderation.terms.join(', ') : undefined}
152
164
  >
153
165
  <span aria-hidden>⚠️</span>
154
166
  <span>{bubble.moderationFlagged}</span>
@@ -156,12 +168,19 @@ export function MessageBubble({
156
168
  )}
157
169
 
158
170
  {isMedia ? (
159
- <MediaRenderer
160
- message={message}
161
- onLightbox={setLightboxSrc}
162
- onResolveUrl={resolveMediaUrl}
163
- {...(requestTranscription ? { onTranscribeAudio: requestTranscription } : {})}
164
- />
171
+ <>
172
+ <MediaRenderer
173
+ message={message}
174
+ onLightbox={setLightboxSrc}
175
+ onResolveUrl={resolveMediaUrl}
176
+ {...(requestTranscription ? { onTranscribeAudio: requestTranscription } : {})}
177
+ />
178
+ {mediaCaption ? (
179
+ <div className="mt-1 text-sm text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words leading-[19px]">
180
+ {parseWhatsAppFormatting(mediaCaption)}
181
+ </div>
182
+ ) : null}
183
+ </>
165
184
  ) : isInteractive && message.payload ? (
166
185
  // O texto da mensagem interativa mora dentro do payload (`body.text`), e `content` guarda
167
186
  // só uma cópia achatada para busca — renderizar `content` aqui duplicaria o corpo.
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent, ty
2
2
  import { AudioRecorderButton } from './AudioRecorderButton'
3
3
  import type { ConversationsFeatures } from './types'
4
4
  import { cn } from './lib/cn'
5
+ import { COMPOSER_BAR_CLASS, QUICK_REPLY_PILL_CLASS } from './composer.constant'
5
6
  import { EmojiPicker } from './EmojiPicker'
6
7
 
7
8
  /**
@@ -42,12 +43,14 @@ export interface MessageComposerLabels {
42
43
  emoji: string
43
44
  attach: string
44
45
  send: string
46
+ removeAttachment: string
45
47
  }
46
48
 
47
49
  export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
48
50
  emoji: 'Emoji',
49
51
  attach: 'Anexar',
50
52
  send: 'Enviar',
53
+ removeAttachment: 'Remover anexo',
51
54
  }
52
55
 
53
56
  export interface MessageComposerProps {
@@ -123,6 +126,7 @@ export const MessageComposer = ({
123
126
  const emojiLabel = labels?.emoji ?? DEFAULT_MESSAGE_COMPOSER_LABELS.emoji
124
127
  const attachLabel = labels?.attach ?? DEFAULT_MESSAGE_COMPOSER_LABELS.attach
125
128
  const sendLabel = labels?.send ?? DEFAULT_MESSAGE_COMPOSER_LABELS.send
129
+ const removeAttachmentLabel = labels?.removeAttachment ?? DEFAULT_MESSAGE_COMPOSER_LABELS.removeAttachment
126
130
  const [internalText, setInternalText] = useState('')
127
131
  const [showEmoji, setShowEmoji] = useState(false)
128
132
  const [attachments, setAttachments] = useState<FilePreview[]>([])
@@ -245,7 +249,7 @@ export const MessageComposer = ({
245
249
  /* A barra é a superfície (cinza, largura cheia, sem raio) e o campo dentro é que arredonda —
246
250
  ordem do WhatsApp. Invertido, o pill arredondado ia até a borda da tela e os cantos
247
251
  descobriam o fundo branco da página, que lia como defeito. */
248
- <div className={cn('bg-[#f0f2f5] px-2 py-2', className)}>
252
+ <div className={cn(COMPOSER_BAR_CLASS, className)}>
249
253
  {/* Uma linha com scroll no celular e wrap no desktop: em 375px seis chips em wrap empurrariam
250
254
  o campo para fora da tela, e o campo é a razão de a barra existir. */}
251
255
  {quickReplies && quickReplies.length > 0 && (
@@ -257,6 +261,7 @@ export const MessageComposer = ({
257
261
  >
258
262
  {quickReplies.map((quickReply) => (
259
263
  <button
264
+ data-cv-tooltip={quickReply.label} aria-label={quickReply.label}
260
265
  key={quickReply.key}
261
266
  type="button"
262
267
  // Preenche o campo em vez de enviar: mensagem pronta é ponto de partida, e quem
@@ -266,10 +271,7 @@ export const MessageComposer = ({
266
271
  setText(resolveQuickReply(quickReply, quickReplyVariables))
267
272
  textareaRef.current?.focus()
268
273
  }}
269
- className={cn(
270
- 'whitespace-nowrap rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400',
271
- classNames?.quickReply,
272
- )}
274
+ className={cn(QUICK_REPLY_PILL_CLASS, classNames?.quickReply)}
273
275
  >
274
276
  {quickReply.label}
275
277
  </button>
@@ -288,7 +290,7 @@ export const MessageComposer = ({
288
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>
289
291
  </div>
290
292
  )}
291
- <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>
292
294
  </div>
293
295
  ))}
294
296
  </div>
@@ -297,7 +299,7 @@ export const MessageComposer = ({
297
299
  <div className={cn('flex items-end gap-1.5 rounded-xl bg-white px-3 py-2', classNames?.field)}>
298
300
  {showEmojiButton && (
299
301
  <div className="relative flex-shrink-0">
300
- <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}>
301
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>
302
304
  </button>
303
305
  {showEmoji && (
@@ -323,7 +325,7 @@ export const MessageComposer = ({
323
325
  {showAttachButton && (
324
326
  <>
325
327
  <input ref={fileInputRef} type="file" multiple accept={acceptedFileTypes} onChange={handleFileChange} className="hidden" />
326
- <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}>
327
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>
328
330
  </button>
329
331
  </>
@@ -333,6 +335,7 @@ export const MessageComposer = ({
333
335
  <div className="flex-shrink-0">{effectiveIdleAction}</div>
334
336
  ) : (
335
337
  <button
338
+ data-cv-tooltip={sendLabel}
336
339
  onClick={sendMessage}
337
340
  disabled={!canSend || disabled}
338
341
  className={`w-10 h-10 flex items-center justify-center rounded-full flex-shrink-0 transition-all ${
@@ -20,7 +20,7 @@ describe('RichMessageComposer', () => {
20
20
  )
21
21
 
22
22
  for (const tooltip of ['Negrito', 'Itálico', 'Tachado', 'Monoespaçado', 'Anexar arquivo', 'Enviar']) {
23
- expect(markup).toContain(`title="${tooltip}"`)
23
+ expect(markup).toContain(`data-cv-tooltip="${tooltip}"`)
24
24
  }
25
25
  })
26
26
 
@@ -29,9 +29,9 @@ describe('RichMessageComposer', () => {
29
29
  <RichMessageComposer value="" onChange={noop} onSend={noop} tooltips={{ bold: 'Deixar em negrito' }} />,
30
30
  )
31
31
 
32
- expect(markup).toContain('title="Deixar em negrito"')
33
- expect(markup).not.toContain('title="Negrito"')
34
- expect(markup).toContain('title="Itálico"')
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
35
  })
36
36
 
37
37
  it('esconde só a ação que o host desligou', () => {
@@ -39,14 +39,14 @@ describe('RichMessageComposer', () => {
39
39
  <RichMessageComposer value="" onChange={noop} onSend={noop} toolbar={{ monospace: false }} />,
40
40
  )
41
41
 
42
- expect(markup).not.toContain('title="Monoespaçado"')
43
- expect(markup).toContain('title="Negrito"')
42
+ expect(markup).not.toContain('data-cv-tooltip="Monoespaçado"')
43
+ expect(markup).toContain('data-cv-tooltip="Negrito"')
44
44
  })
45
45
 
46
46
  it('não desenha o anexo quando não há como anexar', () => {
47
47
  const markup = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
48
48
 
49
- expect(markup).not.toContain('title="Anexar arquivo"')
49
+ expect(markup).not.toContain('data-cv-tooltip="Anexar arquivo"')
50
50
  })
51
51
 
52
52
  it('desenha os chips de resposta rápida com o rótulo e a dica do host', () => {
@@ -55,7 +55,7 @@ describe('RichMessageComposer', () => {
55
55
  )
56
56
 
57
57
  expect(markup).toContain('Saudação')
58
- expect(markup).toContain('title="Abre o atendimento"')
58
+ expect(markup).toContain('data-cv-tooltip="Abre o atendimento"')
59
59
  })
60
60
 
61
61
  it('só oferece variáveis quando o host manda alguma', () => {
@@ -64,8 +64,15 @@ describe('RichMessageComposer', () => {
64
64
  <RichMessageComposer value="" onChange={noop} onSend={noop} variables={VARIABLES} />,
65
65
  )
66
66
 
67
- expect(semVariaveis).not.toContain('title="Variáveis disponíveis"')
68
- expect(comVariaveis).toContain('title="Variáveis disponíveis"')
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"')
69
76
  })
70
77
 
71
78
  it('cede o lugar do enviar à ação ociosa enquanto não há texto', () => {
@@ -77,7 +84,30 @@ describe('RichMessageComposer', () => {
77
84
  )
78
85
 
79
86
  expect(vazio).toContain('aria-label="Gravar áudio"')
80
- expect(vazio).not.toContain('title="Enviar"')
81
- expect(comTexto).toContain('title="Enviar"')
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"')
82
112
  })
83
113
  })