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

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 (75) 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 +2 -0
  5. package/dist/flows/index.js +61 -18
  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 +1 -1
  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/composer.constant.ts +33 -0
  40. package/src/conversationWindow.ts +7 -5
  41. package/src/documents/DocumentsWorkspace.tsx +10 -6
  42. package/src/flows/FlowGroupHeader.tsx +2 -2
  43. package/src/flows/FlowMapNode.tsx +2 -1
  44. package/src/flows/FlowNodeCard.tsx +3 -3
  45. package/src/flows/FlowNodePanel.tsx +6 -5
  46. package/src/flows/FlowPalette.tsx +7 -1
  47. package/src/flows/FlowPortalNode.tsx +1 -1
  48. package/src/flows/FlowsWorkspace.tsx +13 -4
  49. package/src/flows/labels.ts +4 -0
  50. package/src/hooks/useContainerWidth.ts +35 -0
  51. package/src/hooks/useConversationRealtime.ts +10 -8
  52. package/src/icon.constant.ts +12 -0
  53. package/src/index.ts +1 -0
  54. package/src/lib/composer-formatting.test.ts +78 -0
  55. package/src/lib/composer-formatting.ts +145 -0
  56. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  57. package/src/lib/whatsapp-formatting.tsx +28 -3
  58. package/src/listing/index.tsx +5 -1
  59. package/src/pagination.constant.ts +10 -0
  60. package/src/preview/ConversationSimulatorPanel.tsx +1 -1
  61. package/src/providers/ConversationsProvider.tsx +8 -6
  62. package/src/settings/MessagesWorkspace.tsx +3 -0
  63. package/src/settings/TopicsForm.tsx +2 -0
  64. package/src/settings/TranscriptionSettingsForm.test.tsx +1 -1
  65. package/src/settings/TranscriptionSettingsForm.tsx +1 -0
  66. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  67. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  68. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  69. package/src/settings/WhatsAppTemplatesSettings.tsx +2 -0
  70. package/src/styles.css +100 -4
  71. package/src/workspace/BulkTemplateModal.tsx +4 -2
  72. package/src/workspace/ConversationPane.tsx +49 -19
  73. package/src/workspace/ConversationsInboxList.tsx +17 -8
  74. package/src/workspace/ConversationsWorkspace.tsx +24 -16
  75. package/src/workspace/useConversationsInbox.ts +5 -2
@@ -21,6 +21,8 @@ export interface FlowEditorLabels {
21
21
  question: string
22
22
  options: string
23
23
  addOption: string
24
+ removeOption: string
25
+ close: string
24
26
  optionId: string
25
27
  optionLabel: string
26
28
  next: string
@@ -186,6 +188,8 @@ export const DEFAULT_FLOW_EDITOR_LABELS: FlowEditorLabels = {
186
188
  question: 'Texto da pergunta',
187
189
  options: 'Opções (choice)',
188
190
  addOption: 'Adicionar opção',
191
+ removeOption: 'Remover opção',
192
+ close: 'Fechar painel',
189
193
  optionId: 'Valor',
190
194
  optionLabel: 'Texto exibido',
191
195
  next: 'Próximo nó',
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Largura do próprio elemento, para decidir layout pelo espaço que ele tem — e não pelo tamanho da
3
+ * janela.
4
+ *
5
+ * Os breakpoints do Tailwind (`sm:`, `lg:`) leem a janela, e é aí que erram nestas telas: abrir a
6
+ * prévia do simulador, ou a lista de conversas ao lado, estreita a coluna sem a janela mudar de
7
+ * tamanho. O layout continua achando que está no desktop e espreme o conteúdo flexível — o campo de
8
+ * texto do composer e o nome do cliente no cabeçalho — para caber os botões, que não cedem.
9
+ *
10
+ * `undefined` enquanto não mediu (SSR e teste de markup incluídos): quem consome trata ausência de
11
+ * medida como espaçoso, que é o layout completo.
12
+ */
13
+
14
+ import { useEffect, useState, type RefObject } from 'react'
15
+
16
+ export function useContainerWidth(ref: RefObject<HTMLElement | null>): number | undefined {
17
+ const [width, setWidth] = useState<number | undefined>(undefined)
18
+
19
+ useEffect(() => {
20
+ const element = ref.current
21
+ if (!element || typeof ResizeObserver === 'undefined') return
22
+
23
+ // Sempre a mesma medida nos dois caminhos: o `contentRect` do observer desconta o padding e o
24
+ // `getBoundingClientRect` não. Misturar os dois desloca o limiar pela largura do padding — no
25
+ // cabeçalho, 32px — e o layout decide uma coisa ao montar e outra ao redimensionar.
26
+ const measure = () => setWidth(element.getBoundingClientRect().width)
27
+
28
+ measure()
29
+ const observer = new ResizeObserver(measure)
30
+ observer.observe(element)
31
+ return () => observer.disconnect()
32
+ }, [ref])
33
+
34
+ return width
35
+ }
@@ -10,14 +10,16 @@ export function useConversationRealtime(
10
10
  conversationId: string | undefined,
11
11
  onEvent: ConversationRealtimeHandler,
12
12
  ): void {
13
- const context = useConversations()
13
+ const sse = useConversations()?.sse
14
14
  const onEventRef = useRef(onEvent)
15
15
  onEventRef.current = onEvent
16
16
 
17
+ // Depende da porta SSE, não do objeto de contexto inteiro: reabrir o stream é caro (um ticket por
18
+ // abertura) e o contexto é o que mais muda de identidade quando o host renderiza.
17
19
  useEffect(() => {
18
- if (!context || !conversationId) return
20
+ if (!sse || !conversationId) return
19
21
 
20
- const source = context.sse.connectConversationStream(conversationId)
22
+ const source = sse.connectConversationStream(conversationId)
21
23
  const handler = (event: MessageEvent) => onEventRef.current(event)
22
24
  source.addEventListener('message', handler)
23
25
 
@@ -25,20 +27,20 @@ export function useConversationRealtime(
25
27
  source.removeEventListener('message', handler)
26
28
  source.close()
27
29
  }
28
- }, [context, conversationId])
30
+ }, [sse, conversationId])
29
31
  }
30
32
 
31
33
  // Assina o stream global (ex: novas conversas entrando na fila, notificações cross-conversa)
32
34
  // — mesma porta SSEProvider, sem conversationId.
33
35
  export function useGlobalRealtime(onEvent: ConversationRealtimeHandler): void {
34
- const context = useConversations()
36
+ const sse = useConversations()?.sse
35
37
  const onEventRef = useRef(onEvent)
36
38
  onEventRef.current = onEvent
37
39
 
38
40
  useEffect(() => {
39
- if (!context) return
41
+ if (!sse) return
40
42
 
41
- const source = context.sse.connectGlobalStream()
43
+ const source = sse.connectGlobalStream()
42
44
  const handler = (event: MessageEvent) => onEventRef.current(event)
43
45
  source.addEventListener('message', handler)
44
46
 
@@ -46,5 +48,5 @@ export function useGlobalRealtime(onEvent: ConversationRealtimeHandler): void {
46
48
  source.removeEventListener('message', handler)
47
49
  source.close()
48
50
  }
49
- }, [context])
51
+ }, [sse])
50
52
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Tamanhos de ícone da UI de conversas, em px, na escala de 4 da grade.
3
+ *
4
+ * Ícone vem da biblioteca (lucide), nunca emoji. Emoji desenha diferente em cada sistema
5
+ * operacional, não herda `currentColor` — então não acompanha o estado do controle (ativo,
6
+ * desabilitado, variante destrutiva) — e não escala com o token de tipografia. Numa mesma faixa,
7
+ * metade emoji e metade SVG lê como duas famílias de botão.
8
+ */
9
+
10
+ export const ICON_SIZE_PILL = 12
11
+ export const ICON_SIZE_INLINE = 14
12
+ export const ICON_SIZE_ACTION = 16
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export { DateDivider } from './DateDivider'
28
28
  export { Avatar, DEFAULT_AVATAR_LABELS } from './Avatar'
29
29
  export { ConversationListItem, DEFAULT_CONVERSATION_LIST_ITEM_LABELS } from './ConversationListItem'
30
30
  export { ToastProvider, useToast, toast } from './Toast'
31
+ export { TooltipLayer, TOOLTIP_ATTRIBUTE } from './Tooltip'
31
32
 
32
33
  export { StatusTicks } from './StatusTicks'
33
34
  export { Lightbox, DEFAULT_LIGHTBOX_LABELS } from './Lightbox'
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import {
4
+ FORMATTING_ACTION,
5
+ FORMATTING_SEPARATOR,
6
+ activeFormattingIn,
7
+ canExecuteFormattingCommand,
8
+ isFormattingActive,
9
+ toggleFormattingCommand,
10
+ } from './composer-formatting'
11
+ import { ZERO_WIDTH_SPACE, htmlToWA, waToHTML } from './whatsapp-formatting'
12
+
13
+ describe('isFormattingActive', () => {
14
+ it('reconhece cada formatação dentro do conjunto ativo', () => {
15
+ const active = [FORMATTING_ACTION.BOLD, FORMATTING_ACTION.MONOSPACE].join(FORMATTING_SEPARATOR)
16
+
17
+ expect(isFormattingActive({ active, action: FORMATTING_ACTION.BOLD })).toBe(true)
18
+ expect(isFormattingActive({ active, action: FORMATTING_ACTION.MONOSPACE })).toBe(true)
19
+ expect(isFormattingActive({ active, action: FORMATTING_ACTION.ITALIC })).toBe(false)
20
+ })
21
+
22
+ it('não acende nada quando o conjunto está vazio', () => {
23
+ expect(isFormattingActive({ active: '', action: FORMATTING_ACTION.BOLD })).toBe(false)
24
+ })
25
+ })
26
+
27
+ describe('activeFormattingIn', () => {
28
+ it('não acende nada quando não há seleção no documento', () => {
29
+ const editor = { contains: () => false } as unknown as HTMLElement
30
+
31
+ expect(activeFormattingIn(editor)).toBe('')
32
+ })
33
+ })
34
+
35
+ describe('toggleFormattingCommand', () => {
36
+ it('avisa que não executou onde o comando não existe, para o campo cair no wrap manual', () => {
37
+ if (canExecuteFormattingCommand()) return
38
+ expect(toggleFormattingCommand(FORMATTING_ACTION.BOLD)).toBe(false)
39
+ })
40
+
41
+ it('não tem comando nativo para monoespaçado', () => {
42
+ expect(toggleFormattingCommand(FORMATTING_ACTION.MONOSPACE)).toBe(false)
43
+ })
44
+ })
45
+
46
+ describe('waToHTML dentro do campo editável', () => {
47
+ it('escreve o tachado como <s>, que é o que o navegador consegue desfazer', () => {
48
+ expect(waToHTML('~riscado~')).toBe('<s>riscado</s>')
49
+ })
50
+
51
+ it('mantém o ida e volta do tachado', () => {
52
+ expect(htmlToWA(waToHTML('~riscado~'))).toBe('~riscado~')
53
+ })
54
+ })
55
+
56
+ describe('htmlToWA e a âncora do cursor', () => {
57
+ it('descarta o código que só tem a âncora, para não sair um par de crases vazio', () => {
58
+ expect(htmlToWA(`oi <code>${ZERO_WIDTH_SPACE}</code>`)).toBe('oi')
59
+ })
60
+
61
+ it('tira a âncora de dentro do código que o operador chegou a preencher', () => {
62
+ expect(htmlToWA(`<code>${ZERO_WIDTH_SPACE}npm run dev</code>`)).toBe('`npm run dev`')
63
+ })
64
+
65
+ it('não deixa a âncora vazar para o texto enviado', () => {
66
+ expect(htmlToWA(`oi${ZERO_WIDTH_SPACE} tudo bem`)).toBe('oi tudo bem')
67
+ })
68
+ })
69
+
70
+ describe('htmlToWA com a marcação do navegador', () => {
71
+ it('converte o <strike> do execCommand para o tachado do WhatsApp', () => {
72
+ expect(htmlToWA('<strike>cancelado</strike>')).toBe('~cancelado~')
73
+ })
74
+
75
+ it('converte o <b> e o <i> do navegador junto com as nossas tags', () => {
76
+ expect(htmlToWA('<b>oi</b> <i>tudo</i> <strong>bem</strong>')).toBe('*oi* _tudo_ *bem*')
77
+ })
78
+ })
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Estado e alternância da formatação dentro do campo rico.
3
+ *
4
+ * O botão precisa responder duas perguntas que o wrap manual não respondia: o cursor está dentro de
5
+ * um negrito? e, se está, como saio dele? `execCommand` é depreciado mas continua sendo o único
6
+ * caminho que alterna a formatação do cursor vazio — sem ele, ligar o negrito antes de digitar não
7
+ * existe, e era por isso que os botões não pareciam acender nem apagar.
8
+ *
9
+ * `queryCommandState` responde pelo estilo computado, então acende igual para `<strong>` (nosso) e
10
+ * `<b>` (do navegador). Onde o ambiente não tem as duas APIs — jsdom, teste — as funções avisam, e
11
+ * quem chama volta ao wrap manual.
12
+ */
13
+
14
+ import { COMPOSER_MONOSPACE_CLASS } from '../composer.constant'
15
+ import { ZERO_WIDTH_SPACE } from './whatsapp-formatting'
16
+
17
+ export const FORMATTING_ACTION = {
18
+ BOLD: 'bold',
19
+ ITALIC: 'italic',
20
+ STRIKETHROUGH: 'strikethrough',
21
+ MONOSPACE: 'monospace',
22
+ } as const
23
+ export type FormattingAction = (typeof FORMATTING_ACTION)[keyof typeof FORMATTING_ACTION]
24
+
25
+ /** Monoespaçado fica de fora: não existe comando nativo, o campo trata à mão. */
26
+ const EXEC_COMMAND_BY_ACTION = {
27
+ [FORMATTING_ACTION.BOLD]: 'bold',
28
+ [FORMATTING_ACTION.ITALIC]: 'italic',
29
+ [FORMATTING_ACTION.STRIKETHROUGH]: 'strikeThrough',
30
+ } as const
31
+
32
+ /**
33
+ * O conjunto ativo viaja como string separada para o React comparar por valor: um `Set` novo a cada
34
+ * movimento do cursor rerenderizaria a barra inteira sem nada ter mudado.
35
+ */
36
+ export const FORMATTING_SEPARATOR = '|'
37
+
38
+ interface EditorNodeParams {
39
+ readonly node: Node
40
+ readonly editor: HTMLElement
41
+ }
42
+
43
+ /** O `<code>` que envolve o cursor, se houver — a marcação de monoespaçado que damos ao texto. */
44
+ export function codeAncestorOf({ node, editor }: EditorNodeParams): HTMLElement | undefined {
45
+ let current: Node | null = node
46
+
47
+ while (current && current !== editor) {
48
+ if (current instanceof HTMLElement && current.tagName === 'CODE') return current
49
+ current = current.parentNode
50
+ }
51
+
52
+ return undefined
53
+ }
54
+
55
+ function canQueryCommandState(): boolean {
56
+ return typeof document !== 'undefined' && typeof document.queryCommandState === 'function'
57
+ }
58
+
59
+ export function canExecuteFormattingCommand(): boolean {
60
+ return typeof document !== 'undefined' && typeof document.execCommand === 'function'
61
+ }
62
+
63
+ function isCommandActive(command: string): boolean {
64
+ if (!canQueryCommandState()) return false
65
+ try {
66
+ return document.queryCommandState(command)
67
+ } catch {
68
+ // Fora de um campo editável o navegador lança em vez de responder `false`.
69
+ return false
70
+ }
71
+ }
72
+
73
+ export function activeFormattingIn(editor: HTMLElement): string {
74
+ const selection = typeof window !== 'undefined' ? window.getSelection() : null
75
+ const anchor = selection?.anchorNode
76
+ // Cursor fora do campo: nenhum botão aceso, senão a barra descreveria a seleção de outro lugar.
77
+ if (!anchor || !editor.contains(anchor)) return ''
78
+
79
+ const active: FormattingAction[] = []
80
+ for (const [action, command] of Object.entries(EXEC_COMMAND_BY_ACTION)) {
81
+ if (isCommandActive(command)) active.push(action as FormattingAction)
82
+ }
83
+ if (codeAncestorOf({ node: anchor, editor })) active.push(FORMATTING_ACTION.MONOSPACE)
84
+
85
+ return active.join(FORMATTING_SEPARATOR)
86
+ }
87
+
88
+ export function isFormattingActive({ active, action }: { active: string; action: FormattingAction }): boolean {
89
+ return active.split(FORMATTING_SEPARATOR).includes(action)
90
+ }
91
+
92
+ /**
93
+ * Liga o monoespaçado no cursor vago: abre o `<code>` e põe o cursor lá dentro, ancorado no espaço
94
+ * de largura zero — sem a âncora não há onde o cursor pousar, e o próximo caractere sai de fora.
95
+ * A âncora some na conversão para a notação do WhatsApp.
96
+ */
97
+ export function startMonospaceAt(range: Range): void {
98
+ const code = document.createElement('code')
99
+ code.className = COMPOSER_MONOSPACE_CLASS
100
+ code.textContent = ZERO_WIDTH_SPACE
101
+ range.deleteContents()
102
+ range.insertNode(code)
103
+ placeCaretAfter(code.firstChild as Text, ZERO_WIDTH_SPACE.length)
104
+ }
105
+
106
+ /**
107
+ * Desliga o monoespaçado do cursor vago. Só mover o cursor para depois do `</code>` não basta — o
108
+ * navegador o traz de volta para dentro; é preciso uma âncora do lado de fora para ele pousar.
109
+ */
110
+ export function exitMonospaceAfter(code: HTMLElement): void {
111
+ const anchor = document.createTextNode(ZERO_WIDTH_SPACE)
112
+ code.after(anchor)
113
+ placeCaretAfter(anchor, ZERO_WIDTH_SPACE.length)
114
+ }
115
+
116
+ /** Tira a marcação e devolve o texto ao redor — o que o clique com seleção dentro do código faz. */
117
+ export function unwrapMonospace(code: HTMLElement): void {
118
+ code.replaceWith(document.createTextNode(code.textContent?.replace(ZERO_WIDTH_SPACE, '') ?? ''))
119
+ }
120
+
121
+ function placeCaretAfter(node: Text, offset: number): void {
122
+ const caret = document.createRange()
123
+ caret.setStart(node, offset)
124
+ caret.collapse(true)
125
+ const selection = window.getSelection()
126
+ selection?.removeAllRanges()
127
+ selection?.addRange(caret)
128
+ }
129
+
130
+ /**
131
+ * Alterna a formatação pelo navegador. Devolve `false` quando o comando não existe no ambiente,
132
+ * para o campo cair no wrap manual em vez de engolir o clique.
133
+ */
134
+ export function toggleFormattingCommand(action: FormattingAction): boolean {
135
+ const command = EXEC_COMMAND_BY_ACTION[action as keyof typeof EXEC_COMMAND_BY_ACTION]
136
+ if (!command || !canExecuteFormattingCommand()) return false
137
+
138
+ try {
139
+ // Sem isto o Chrome escreve `<span style="font-weight:bold">`, que não sobrevive ao htmlToWA.
140
+ document.execCommand('styleWithCSS', false, 'false')
141
+ return document.execCommand(command)
142
+ } catch {
143
+ return false
144
+ }
145
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { parseWhatsAppFormatting } from './whatsapp-formatting'
5
+
6
+ function render(text: string): string {
7
+ return renderToStaticMarkup(<>{parseWhatsAppFormatting(text)}</>)
8
+ }
9
+
10
+ describe('parseWhatsAppFormatting com marcador sem par', () => {
11
+ // Estes casos travavam a aba: o laço reprocessava a mesma string sem consumir o marcador.
12
+ it('trata o sublinhado solto de um nome de arquivo como texto', () => {
13
+ expect(render('IMG_2026.jpg')).toBe('<span>IMG</span><span>_</span><span>2026.jpg</span>')
14
+ })
15
+
16
+ it('não trava com asterisco, til ou crase sem fechamento', () => {
17
+ expect(render('3 * 4')).toContain('*')
18
+ expect(render('mais ou menos ~10')).toContain('~')
19
+ expect(render('use ` para código')).toContain('`')
20
+ })
21
+
22
+ it('sobrevive a uma linha só de marcadores', () => {
23
+ expect(render('*_~`')).toBe('<span>*</span><span>_</span><span>~</span><span>`</span>')
24
+ })
25
+ })
26
+
27
+ describe('parseWhatsAppFormatting com marcador fechado', () => {
28
+ it('continua formatando o par completo depois do marcador solto', () => {
29
+ expect(render('IMG_2026 *urgente*')).toContain('<strong>urgente</strong>')
30
+ })
31
+
32
+ it('mantém negrito, itálico e tachado', () => {
33
+ expect(render('*a* _b_ ~c~')).toBe(
34
+ '<strong>a</strong><span> </span><em>b</em><span> </span><del>c</del>',
35
+ )
36
+ })
37
+ })
@@ -90,9 +90,15 @@ function parseInlineTokens(text: string): FormatToken[] {
90
90
  if (remaining) result.push({ type: 'text', content: remaining })
91
91
  break
92
92
  }
93
- if (nextSpecial > 0) {
94
- result.push({ type: 'text', content: remaining.slice(0, nextSpecial) })
93
+ // Marcador sem par — o `_` de `IMG_2026.jpg` é o caso comum. Nenhuma das regras casou e o
94
+ // marcador está na posição zero: sem consumir esse caractere o laço reprocessa a mesma string
95
+ // para sempre e trava a aba inteira, porque isto roda no render de cada mensagem.
96
+ if (nextSpecial === 0) {
97
+ result.push({ type: 'text', content: remaining.slice(0, 1) })
98
+ remaining = remaining.slice(1)
99
+ continue
95
100
  }
101
+ result.push({ type: 'text', content: remaining.slice(0, nextSpecial) })
96
102
  remaining = remaining.slice(nextSpecial)
97
103
  }
98
104
 
@@ -137,6 +143,15 @@ function unescapeHtml(text: string): string {
137
143
  const CODE_TOKEN_MARK = String.fromCharCode(0xe000)
138
144
  const CODE_TOKEN_REGEX = new RegExp(`${CODE_TOKEN_MARK}(\\d+)${CODE_TOKEN_MARK}`, 'g')
139
145
 
146
+ /**
147
+ * Âncora invisível que o campo rico usa para pôr o cursor dentro (ou fora) de um `<code>` — não
148
+ * existe comando de navegador para monoespaçado, e o cursor sozinho não fica onde não há texto.
149
+ * Ela é do editor, nunca da mensagem: sai toda aqui, na conversão de volta.
150
+ */
151
+ export const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b)
152
+ const EMPTY_CODE_REGEX = new RegExp(`<code[^>]*>[${ZERO_WIDTH_SPACE}\\s]*</code>`, 'gi')
153
+ const ZERO_WIDTH_SPACE_REGEX = new RegExp(ZERO_WIDTH_SPACE, 'g')
154
+
140
155
  // waToHTML/htmlToWA formam um par round-trip: todo texto que sai de waToHTML deve
141
156
  // reconstruir exatamente o original ao passar por htmlToWA (inclusive blocos de
142
157
  // código multi-linha, que exigem distinguir ``` de ` via atributo data-wa).
@@ -156,7 +171,10 @@ export function waToHTML(text: string): string {
156
171
  let html = escapeHtml(working)
157
172
  html = html.replace(/\*([^*\n]+)\*/g, '<strong>$1</strong>')
158
173
  html = html.replace(/_([^_\n]+)_/g, '<em>$1</em>')
159
- html = html.replace(/~([^~\n]+)~/g, '<del>$1</del>')
174
+ // `<s>` e não `<del>`: o navegador se recusa a tirar o tachado de um `<del>` — o botão acendia,
175
+ // o clique não fazia nada e o texto continuava riscado. Com `<s>` a alternância funciona, e a
176
+ // linha na tela é a mesma. `<del>` continua certo na exibição da mensagem, que não é editável.
177
+ html = html.replace(/~([^~\n]+)~/g, '<s>$1</s>')
160
178
  html = html.replace(/\n/g, '<br>')
161
179
 
162
180
  html = html.replace(CODE_TOKEN_REGEX, (_match, indexStr: string) => {
@@ -175,6 +193,9 @@ export function htmlToWA(html: string): string {
175
193
  if (!html) return ''
176
194
 
177
195
  let text = html
196
+ // O `<code>` que o cursor abre nasce só com a âncora de largura zero. Sem nada digitado dentro,
197
+ // ele viraria um par de crases vazias no texto enviado.
198
+ text = text.replace(EMPTY_CODE_REGEX, '')
178
199
  text = text.replace(/<code data-wa="block"[^>]*>([\s\S]*?)<\/code>/gi, (_match, inner: string) => (
179
200
  `\`\`\`${unescapeHtml(inner.replace(/<br\s*\/?>/gi, '\n'))}\`\`\``
180
201
  ))
@@ -195,11 +216,15 @@ export function htmlToWA(html: string): string {
195
216
  text = text.replace(/<i>(.*?)<\/i>/gi, '_$1_')
196
217
  text = text.replace(/<del>(.*?)<\/del>/gi, '~$1~')
197
218
  text = text.replace(/<s>(.*?)<\/s>/gi, '~$1~')
219
+ // O `strikeThrough` do navegador escreve `<strike>`, que não é o que geramos mas chega no campo.
220
+ text = text.replace(/<strike>(.*?)<\/strike>/gi, '~$1~')
198
221
  // Compat: HTML sem os marcadores data-wa (ex: vindo de outro editor) ainda vira código inline.
199
222
  text = text.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`')
200
223
 
201
224
  text = text.replace(/<[^>]+>/g, '')
202
225
  text = unescapeHtml(text)
226
+ // A âncora do cursor sobrevive dentro do `<code>` que ganhou texto; ela é do editor, não da mensagem.
227
+ text = text.replace(ZERO_WIDTH_SPACE_REGEX, '')
203
228
  return text.trim()
204
229
  }
205
230
 
@@ -29,6 +29,7 @@ export function SortableHead({ label, field, activeField, direction, onSort, cla
29
29
  return (
30
30
  <th scope="col" className={cn('px-3 py-2 text-left text-xs font-medium', className)}>
31
31
  <button
32
+ data-cv-tooltip={label}
32
33
  type="button"
33
34
  onClick={() => onSort(field)}
34
35
  aria-label={label}
@@ -64,6 +65,7 @@ export function MultiSelectFilter({ label, options, selected, onChange, classNam
64
65
  return (
65
66
  <div className={cn('relative', className)}>
66
67
  <button
68
+ data-cv-tooltip={label} aria-label={label}
67
69
  type="button"
68
70
  onClick={() => setOpen((current) => !current)}
69
71
  className="cv-header-action inline-flex items-center gap-1"
@@ -112,7 +114,7 @@ export function BulkActionBar({ selectedCount, selectedLabel, clearLabel, onClea
112
114
  <div className="cv-bulk-bar" role="toolbar" aria-label={selectedLabel(selectedCount)}>
113
115
  <span className="text-xs font-medium">{selectedLabel(selectedCount)}</span>
114
116
  {children}
115
- <button type="button" onClick={onClear} className="cv-header-action ml-auto inline-flex items-center gap-1">
117
+ <button data-cv-tooltip={clearLabel} aria-label={clearLabel} type="button" onClick={onClear} className="cv-header-action ml-auto inline-flex items-center gap-1">
116
118
  <X size={12} aria-hidden="true" />
117
119
  {clearLabel}
118
120
  </button>
@@ -174,6 +176,7 @@ export function ListingPagination({
174
176
 
175
177
  <div className="flex items-center gap-2">
176
178
  <button
179
+ data-cv-tooltip={labels.previous}
177
180
  type="button"
178
181
  onClick={() => onPageChange(page - 1)}
179
182
  disabled={page <= 1}
@@ -184,6 +187,7 @@ export function ListingPagination({
184
187
  </button>
185
188
  <span className="text-xs text-gray-500">{labels.page(page, lastPage)}</span>
186
189
  <button
190
+ data-cv-tooltip={labels.next}
187
191
  type="button"
188
192
  onClick={() => onPageChange(page + 1)}
189
193
  disabled={page >= lastPage}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Rótulos dos controles de paginação. Os pagers da inbox, do painel de documentos e da biblioteca
3
+ * são o mesmo controle repetido: o texto mora aqui para os três dizerem a mesma coisa.
4
+ */
5
+ export const PAGINATION_LABELS = {
6
+ first: 'Primeira página',
7
+ previous: 'Página anterior',
8
+ next: 'Próxima página',
9
+ last: 'Última página',
10
+ } as const
@@ -72,7 +72,7 @@ export function ConversationSimulatorPanel({
72
72
  </div>
73
73
  <div className="cv-simulator-panel__actions">
74
74
  {headerActions}
75
- <button type="button" onClick={onClose} title={text.close} aria-label={text.close} className="cv-simulator-panel__close">
75
+ <button type="button" onClick={onClose} data-cv-tooltip={text.close} aria-label={text.close} className="cv-simulator-panel__close">
76
76
  {/* SVG inline em vez de lucide-react: o pacote não impõe biblioteca de ícone ao host. */}
77
77
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
78
78
  <path d="M18 6 6 18M6 6l12 12" />
@@ -1,4 +1,4 @@
1
- import { createContext, useContext, type ReactNode } from 'react'
1
+ import { createContext, useContext, useMemo, type ReactNode } from 'react'
2
2
  import type { ConversationsApi, SSEProvider } from './types'
3
3
 
4
4
  interface ConversationsContextValue {
@@ -17,11 +17,13 @@ export function ConversationsProvider({
17
17
  sse: SSEProvider
18
18
  children: ReactNode
19
19
  }) {
20
- return (
21
- <ConversationsContext.Provider value={{ api, sse }}>
22
- {children}
23
- </ConversationsContext.Provider>
24
- )
20
+ // Objeto novo a cada render fazia todo efeito que depende do contexto reexecutar junto — o do
21
+ // `useConversationRealtime` fecha e reabre o SSE da conversa, e cada reabertura pede um ticket
22
+ // novo. Uma tela que renderiza em rajada saturava as 6 conexões do navegador com requisições
23
+ // pendentes que nunca chegavam a servir para nada.
24
+ const value = useMemo(() => ({ api, sse }), [api, sse])
25
+
26
+ return <ConversationsContext.Provider value={value}>{children}</ConversationsContext.Provider>
25
27
  }
26
28
 
27
29
  export function useConversations(): ConversationsContextValue | null {
@@ -33,6 +33,7 @@ import {
33
33
  type TranscriptionSettingsFormLabels,
34
34
  } from './TranscriptionSettingsForm'
35
35
  import type { TranscriptionMode } from '../types'
36
+ import { TooltipLayer } from '../Tooltip'
36
37
 
37
38
  export interface BotMessages {
38
39
  welcomeMessage: string
@@ -345,6 +346,7 @@ export function MessagesWorkspace({
345
346
 
346
347
  return (
347
348
  <div className={`space-y-6 p-4 lg:p-0 ${className ?? ''}`}>
349
+ <TooltipLayer />
348
350
  <header>
349
351
  <h1 className="text-xl font-semibold text-gray-900 dark:text-gray-100">{labels.title}</h1>
350
352
  <p className="text-sm text-gray-500 dark:text-gray-400">{labels.subtitle}</p>
@@ -355,6 +357,7 @@ export function MessagesWorkspace({
355
357
  <nav className="flex gap-1 border-b border-gray-200 dark:border-gray-700" role="tablist">
356
358
  {tabs.map((item) => (
357
359
  <button
360
+ data-cv-tooltip={item.label} aria-label={item.label}
358
361
  key={item.id}
359
362
  type="button"
360
363
  role="tab"
@@ -66,6 +66,7 @@ export function TopicsForm({
66
66
  <p className="text-sm font-medium text-gray-900 dark:text-gray-100">{topic.label}</p>
67
67
  </div>
68
68
  <button
69
+ data-cv-tooltip={topic.label} aria-label={topic.label}
69
70
  type="button"
70
71
  onClick={() => onToggle(topic.key)}
71
72
  className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
@@ -94,6 +95,7 @@ export function TopicsForm({
94
95
 
95
96
  <div className="flex items-center gap-3">
96
97
  <button
98
+ data-cv-tooltip={labels.saveButton} aria-label={labels.saveButton}
97
99
  type="submit"
98
100
  disabled={saving}
99
101
  className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
@@ -76,6 +76,6 @@ describe('TranscriptionSettingsForm', () => {
76
76
  const markup = render({ saving: true })
77
77
 
78
78
  expect(markup).toContain('Salvando...')
79
- expect(findTag(markup, /<button type="submit"[^>]*>/g, 'type="submit"')).toContain('disabled')
79
+ expect(findTag(markup, /<button[^>]*>/g, 'type="submit"')).toContain('disabled')
80
80
  })
81
81
  })
@@ -132,6 +132,7 @@ export function TranscriptionSettingsForm({
132
132
 
133
133
  <div className="flex justify-end">
134
134
  <button
135
+ data-cv-tooltip={saving ? labels.saving : labels.save} aria-label={saving ? labels.saving : labels.save}
135
136
  type="submit"
136
137
  disabled={saving || !isAvailable}
137
138
  className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold px-5 py-2.5 rounded-xl transition-colors text-sm"
@@ -104,6 +104,7 @@ export function WelcomeFarewellForm({
104
104
 
105
105
  <div className="flex justify-end">
106
106
  <button
107
+ data-cv-tooltip={saving ? labels.saving : labels.save} aria-label={saving ? labels.saving : labels.save}
107
108
  type="submit"
108
109
  disabled={saving}
109
110
  className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold px-5 py-2.5 rounded-xl transition-colors text-sm"
@@ -297,6 +297,7 @@ export function WhatsAppCreateTemplateForm({
297
297
 
298
298
  <div className="flex justify-end">
299
299
  <button
300
+ data-cv-tooltip={submitting ? labels.sending : labels.sendForApproval} aria-label={submitting ? labels.sending : labels.sendForApproval}
300
301
  type="submit"
301
302
  disabled={submitting || !value.name || !value.bodyText}
302
303
  className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 disabled:bg-emerald-400 text-white font-semibold px-5 py-2.5 rounded-xl transition-colors text-sm"