@adatechnology/conversations-ui 0.1.0-rc.4 → 0.1.0-rc.41

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 (149) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/chunk-BJNRLLDO.js +2708 -0
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2389 -678
  7. package/dist/index.d.ts +1171 -42
  8. package/dist/index.js +3755 -843
  9. package/dist/preview/index.d.ts +157 -42
  10. package/dist/preview/index.js +772 -191
  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 +14 -3
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +347 -24
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +163 -45
  23. package/src/ConversationListItem.tsx +19 -2
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +31 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +382 -0
  29. package/src/EmojiPicker.tsx +70 -55
  30. package/src/FileIcon.test.ts +83 -0
  31. package/src/FileIcon.tsx +88 -11
  32. package/src/InteractiveMessage.test.tsx +41 -0
  33. package/src/InteractiveMessage.tsx +146 -0
  34. package/src/Lightbox.tsx +18 -3
  35. package/src/MediaRenderer.tsx +92 -16
  36. package/src/MessageBubble.test.tsx +41 -0
  37. package/src/MessageBubble.tsx +75 -6
  38. package/src/MessageComposer.test.tsx +35 -0
  39. package/src/MessageComposer.tsx +155 -19
  40. package/src/RichMessageComposer.test.tsx +113 -0
  41. package/src/RichMessageComposer.tsx +551 -0
  42. package/src/SimpleEmojiPicker.tsx +5 -3
  43. package/src/StatusTicks.tsx +1 -1
  44. package/src/Toast.tsx +4 -0
  45. package/src/Tooltip.test.ts +42 -0
  46. package/src/Tooltip.tsx +167 -0
  47. package/src/Wallpaper.test.tsx +21 -0
  48. package/src/Wallpaper.tsx +67 -7
  49. package/src/WhatsAppMessageEditor.tsx +34 -7
  50. package/src/WindowExpiredNotice.tsx +12 -4
  51. package/src/audioRecorderFormat.test.ts +67 -0
  52. package/src/buildOutput.test.ts +79 -0
  53. package/src/composer.constant.ts +33 -0
  54. package/src/conversationTranscript.test.ts +57 -0
  55. package/src/conversationTranscript.ts +29 -4
  56. package/src/conversationWindow.ts +7 -5
  57. package/src/documentTypeLabel.test.ts +57 -0
  58. package/src/documents/DocumentsWorkspace.tsx +550 -0
  59. package/src/documents/index.ts +8 -0
  60. package/src/documents/labels.ts +92 -0
  61. package/src/emojiCatalog.test.ts +35 -0
  62. package/src/emojiCatalog.ts +189 -0
  63. package/src/flows/FlowConnectionEdge.tsx +104 -0
  64. package/src/flows/FlowGroupHeader.tsx +12 -2
  65. package/src/flows/FlowLegend.tsx +125 -0
  66. package/src/flows/FlowMapCanvas.tsx +15 -12
  67. package/src/flows/FlowMapNode.tsx +4 -1
  68. package/src/flows/FlowNodeCard.tsx +219 -34
  69. package/src/flows/FlowNodePanel.tsx +153 -39
  70. package/src/flows/FlowPalette.tsx +106 -69
  71. package/src/flows/FlowPortalNode.tsx +1 -1
  72. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  73. package/src/flows/FlowsWorkspace.tsx +1219 -0
  74. package/src/flows/flowCanvasModel.test.ts +456 -0
  75. package/src/flows/flowCanvasModel.ts +378 -0
  76. package/src/flows/flowEditorOps.test.ts +276 -0
  77. package/src/flows/flowEditorOps.ts +202 -0
  78. package/src/flows/flowGraph.ts +78 -53
  79. package/src/flows/index.ts +51 -2
  80. package/src/flows/labels.ts +180 -0
  81. package/src/flows/workspaceContract.test.ts +126 -0
  82. package/src/hooks/useContainerWidth.ts +35 -0
  83. package/src/hooks/useConversationActions.ts +56 -0
  84. package/src/hooks/useConversationDocuments.ts +11 -7
  85. package/src/hooks/useConversationList.ts +15 -9
  86. package/src/hooks/useConversationMessages.ts +2 -2
  87. package/src/hooks/useConversationRealtime.ts +10 -8
  88. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  89. package/src/hooks/useUrlFilterState.ts +107 -0
  90. package/src/icon.constant.ts +12 -0
  91. package/src/index.ts +129 -13
  92. package/src/lib/cn.test.ts +29 -0
  93. package/src/lib/composer-formatting.test.ts +78 -0
  94. package/src/lib/composer-formatting.ts +145 -0
  95. package/src/lib/createMediaUrlResolver.ts +33 -0
  96. package/src/lib/paginated.test.ts +33 -0
  97. package/src/lib/paginated.ts +26 -0
  98. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  99. package/src/lib/whatsapp-formatting.tsx +28 -3
  100. package/src/listing/index.tsx +202 -0
  101. package/src/pagination.constant.ts +10 -0
  102. package/src/preview/ConversationPreview.tsx +225 -17
  103. package/src/preview/ConversationSimulatorClient.ts +143 -0
  104. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  105. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  106. package/src/preview/MediaTypesPreview.tsx +87 -0
  107. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  108. package/src/preview/createMockConversationsApi.ts +175 -15
  109. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  110. package/src/preview/createPreviewBridgeClient.ts +124 -0
  111. package/src/preview/createPreviewMediaUploader.ts +82 -0
  112. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  113. package/src/preview/createPreviewWebhookClient.ts +127 -4
  114. package/src/preview/index.ts +51 -3
  115. package/src/preview/mediaTypeOf.test.ts +15 -0
  116. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  117. package/src/preview/preview.test.ts +5 -3
  118. package/src/preview/previewFileSamples.test.ts +151 -0
  119. package/src/preview/previewFileSamples.ts +74 -0
  120. package/src/preview/previewFixtures.ts +288 -1
  121. package/src/preview/previewMediaSource.test.ts +62 -0
  122. package/src/preview/previewMediaSource.ts +91 -0
  123. package/src/preview/previewMediaUploader.test.ts +61 -0
  124. package/src/providers/ConversationsProvider.tsx +8 -6
  125. package/src/providers/types.ts +185 -10
  126. package/src/quickReply.test.ts +58 -0
  127. package/src/settings/MessagesWorkspace.tsx +571 -0
  128. package/src/settings/TopicsForm.tsx +2 -0
  129. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  130. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  131. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  132. package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
  133. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  134. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  135. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  136. package/src/styles.css +858 -0
  137. package/src/types.ts +64 -1
  138. package/src/useWaitingNotifications.ts +74 -29
  139. package/src/workspace/BulkTemplateModal.tsx +132 -0
  140. package/src/workspace/ConversationPane.tsx +432 -0
  141. package/src/workspace/ConversationsInboxList.tsx +194 -0
  142. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  143. package/src/workspace/index.ts +17 -0
  144. package/src/workspace/labels.test.ts +17 -0
  145. package/src/workspace/labels.ts +85 -0
  146. package/src/workspace/useConversationsInbox.ts +332 -0
  147. package/dist/chunk-4R6Y43DQ.js +0 -726
  148. package/dist/chunk-NV2RZ5KT.js +0 -56
  149. package/dist/types-C0PtaO7S.d.ts +0 -207
@@ -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,33 @@
1
+ /**
2
+ * Resolve a mídia de uma mensagem numa URL exibível, usando só o `ConversationsApi`.
3
+ *
4
+ * Mora no pacote, e não em cada host, porque a regra não tem nada de específico de produto: é a
5
+ * tradução de `uploadId`/`mediaId` pelos dois métodos que o próprio contrato já declara. Deixá-la no
6
+ * host significava que todo projeto que adotasse o SDK reescreveria as mesmas oito linhas — e, na
7
+ * prática, ninguém escrevia: o `MediaRenderer` só busca mídia pela porta `onResolveMediaUrl`, então
8
+ * onde nada era injetado foto, vídeo e áudio ficavam no placeholder para sempre.
9
+ */
10
+
11
+ import type { MessagePayload } from '../types'
12
+ import type { ConversationsApi } from '../providers/types'
13
+ import type { ResolveMediaUrl } from '../MediaRenderer'
14
+
15
+ export function createMediaUrlResolver(
16
+ api: Pick<ConversationsApi, 'getDocumentUrl' | 'getMediaProxyUrl'>,
17
+ ): ResolveMediaUrl {
18
+ return async (message: MessagePayload): Promise<string | null> => {
19
+ // Mídia já copiada para o storage do host: sai por URL assinada e o binário não passa pela API.
20
+ // `inline` porque aqui o arquivo é para VER na tela — `attachment` faria o navegador baixar.
21
+ if (message.uploadId) return api.getDocumentUrl(message.uploadId, 'inline')
22
+
23
+ // Antes da ingestão só existe o id na Meta, cuja URL expira; o backend busca e devolve base64.
24
+ // Data URL serve de `src` para `<img>`/`<video>`/`<audio>`: o bloqueio do Chrome a `data:` vale
25
+ // para navegação de topo, não para carregar mídia dentro da página.
26
+ if (message.mediaId) {
27
+ const { mimeType, data } = await api.getMediaProxyUrl(message.mediaId)
28
+ return `data:${mimeType};base64,${data}`
29
+ }
30
+
31
+ return null
32
+ }
33
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { conversationsOf, documentsOf, totalOf } from './paginated'
3
+ import type { ConversationDocument, ConversationSummary } from '../providers/types'
4
+
5
+ const conversation = { id: '1', whatsappNumber: '5511900000000' } as ConversationSummary
6
+ const document = { id: 'd1', filename: 'contrato.pdf' } as ConversationDocument
7
+
8
+ describe('normalização de lista ou página', () => {
9
+ // As duas formas existem porque o contrato aceita as duas: array puro é o retorno original, e
10
+ // implementações antigas continuam válidas. Um consumidor que só tratasse uma delas quebraria
11
+ // em runtime, não no compilador.
12
+ it('aceita o array puro do contrato original', () => {
13
+ expect(conversationsOf([conversation])).toEqual([conversation])
14
+ expect(documentsOf([document])).toEqual([document])
15
+ })
16
+
17
+ it('desembrulha a forma paginada', () => {
18
+ expect(conversationsOf({ conversations: [conversation], total: 42 })).toEqual([conversation])
19
+ expect(documentsOf({ documents: [document], total: 7 })).toEqual([document])
20
+ })
21
+
22
+ it('usa o total do servidor quando ele vem, e o tamanho da página quando não vem', () => {
23
+ // A distinção importa: com array puro não dá para saber se a página é a última, então o
24
+ // melhor palpite honesto é o que se tem em mãos.
25
+ expect(totalOf({ conversations: [conversation], total: 42 })).toBe(42)
26
+ expect(totalOf([conversation])).toBe(1)
27
+ })
28
+
29
+ it('trata página vazia sem confundir com ausência de dados', () => {
30
+ expect(conversationsOf({ conversations: [], total: 0 })).toEqual([])
31
+ expect(totalOf({ conversations: [], total: 0 })).toBe(0)
32
+ })
33
+ })
@@ -0,0 +1,26 @@
1
+ import type {
2
+ ConversationDocument,
3
+ ConversationDocumentPage,
4
+ ConversationPage,
5
+ ConversationSummary,
6
+ } from '../providers/types'
7
+
8
+ /**
9
+ * `fetchConversations` e `getDocuments` devolvem o array puro (contrato original) ou uma página
10
+ * com total. Normalizar num lugar só evita que cada consumidor invente sua própria checagem —
11
+ * e um consumidor esquecido não falha no compilador, falha em runtime com `.map is not a
12
+ * function`.
13
+ */
14
+ export function conversationsOf(result: ConversationSummary[] | ConversationPage): ConversationSummary[] {
15
+ return Array.isArray(result) ? result : result.conversations
16
+ }
17
+
18
+ export function documentsOf(result: ConversationDocument[] | ConversationDocumentPage): ConversationDocument[] {
19
+ return Array.isArray(result) ? result : result.documents
20
+ }
21
+
22
+ export function totalOf(
23
+ result: ConversationSummary[] | ConversationPage | ConversationDocument[] | ConversationDocumentPage,
24
+ ): number {
25
+ return Array.isArray(result) ? result.length : result.total
26
+ }
@@ -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
 
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Peças de listagem que toda tela tabular do pacote precisa ter iguais: cabeçalho ordenável,
3
+ * filtro de seleção múltipla, barra de ação em lote e paginação (regra `web.md` §7).
4
+ *
5
+ * Vivem aqui, e não dentro de cada tela, porque a alternativa já se provou pior: cada workspace
6
+ * recriava o seu, e o mesmo "limpar filtros" ficava em três lugares com três comportamentos.
7
+ */
8
+
9
+ import { useState, type ReactNode } from 'react'
10
+ import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, X } from 'lucide-react'
11
+
12
+ import { cn } from '../lib/cn'
13
+
14
+ export type SortDirection = 'asc' | 'desc'
15
+
16
+ export interface SortableHeadProps {
17
+ readonly label: string
18
+ readonly field: string
19
+ readonly activeField: string
20
+ readonly direction: SortDirection
21
+ readonly onSort: (field: string) => void
22
+ readonly className?: string
23
+ }
24
+
25
+ export function SortableHead({ label, field, activeField, direction, onSort, className }: SortableHeadProps) {
26
+ const isActive = activeField === field
27
+ const Icon = !isActive ? ArrowUpDown : direction === 'asc' ? ArrowUp : ArrowDown
28
+
29
+ return (
30
+ <th scope="col" className={cn('px-3 py-2 text-left text-xs font-medium', className)}>
31
+ <button
32
+ data-cv-tooltip={label}
33
+ type="button"
34
+ onClick={() => onSort(field)}
35
+ aria-label={label}
36
+ className="inline-flex items-center gap-1 hover:text-gray-900 dark:hover:text-gray-100"
37
+ >
38
+ {label}
39
+ <Icon size={12} className={isActive ? '' : 'opacity-40'} aria-hidden="true" />
40
+ </button>
41
+ </th>
42
+ )
43
+ }
44
+
45
+ export interface FilterOption {
46
+ readonly value: string
47
+ readonly label: string
48
+ }
49
+
50
+ export interface MultiSelectFilterProps {
51
+ readonly label: string
52
+ readonly options: readonly FilterOption[]
53
+ readonly selected: readonly string[]
54
+ readonly onChange: (selected: readonly string[]) => void
55
+ readonly className?: string
56
+ }
57
+
58
+ export function MultiSelectFilter({ label, options, selected, onChange, className }: MultiSelectFilterProps) {
59
+ const [open, setOpen] = useState(false)
60
+
61
+ function toggle(value: string): void {
62
+ onChange(selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value])
63
+ }
64
+
65
+ return (
66
+ <div className={cn('relative', className)}>
67
+ <button
68
+ data-cv-tooltip={label} aria-label={label}
69
+ type="button"
70
+ onClick={() => setOpen((current) => !current)}
71
+ className="cv-header-action inline-flex items-center gap-1"
72
+ >
73
+ {label}
74
+ {selected.length > 0 ? <span className="cv-filter-count">{selected.length}</span> : null}
75
+ <ChevronDown size={12} className={open ? 'rotate-180 transition-transform' : 'transition-transform'} aria-hidden="true" />
76
+ </button>
77
+
78
+ {open ? (
79
+ <>
80
+ {/* Sem a camada de fundo o dropdown só fechava clicando de novo no botão, e ficava aberto
81
+ por cima da tabela enquanto o usuário tentava clicar numa linha. */}
82
+ <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
83
+ <div className="cv-filter-menu">
84
+ {options.map((option) => (
85
+ <label key={option.value} className="cv-filter-option">
86
+ <input
87
+ type="checkbox"
88
+ checked={selected.includes(option.value)}
89
+ onChange={() => toggle(option.value)}
90
+ className="cursor-pointer rounded border-gray-300 text-blue-600 dark:border-gray-600"
91
+ />
92
+ {option.label}
93
+ </label>
94
+ ))}
95
+ </div>
96
+ </>
97
+ ) : null}
98
+ </div>
99
+ )
100
+ }
101
+
102
+ export interface BulkActionBarProps {
103
+ readonly selectedCount: number
104
+ readonly selectedLabel: (count: number) => string
105
+ readonly clearLabel: string
106
+ readonly onClear: () => void
107
+ readonly children?: ReactNode
108
+ }
109
+
110
+ export function BulkActionBar({ selectedCount, selectedLabel, clearLabel, onClear, children }: BulkActionBarProps) {
111
+ if (selectedCount === 0) return null
112
+
113
+ return (
114
+ <div className="cv-bulk-bar" role="toolbar" aria-label={selectedLabel(selectedCount)}>
115
+ <span className="text-xs font-medium">{selectedLabel(selectedCount)}</span>
116
+ {children}
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">
118
+ <X size={12} aria-hidden="true" />
119
+ {clearLabel}
120
+ </button>
121
+ </div>
122
+ )
123
+ }
124
+
125
+ export interface ListingPaginationProps {
126
+ readonly page: number
127
+ readonly total: number
128
+ readonly perPage: number
129
+ readonly perPageOptions?: readonly number[]
130
+ readonly onPageChange: (page: number) => void
131
+ readonly onPerPageChange?: (perPage: number) => void
132
+ readonly labels: {
133
+ readonly show: string
134
+ readonly perPage: string
135
+ readonly total: (count: number) => string
136
+ readonly page: (current: number, last: number) => string
137
+ readonly previous: string
138
+ readonly next: string
139
+ }
140
+ }
141
+
142
+ export function ListingPagination({
143
+ page,
144
+ total,
145
+ perPage,
146
+ perPageOptions,
147
+ onPageChange,
148
+ onPerPageChange,
149
+ labels,
150
+ }: ListingPaginationProps) {
151
+ const lastPage = Math.max(1, Math.ceil(total / perPage))
152
+
153
+ return (
154
+ <div className="cv-listing-pagination">
155
+ <div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
156
+ {onPerPageChange && perPageOptions ? (
157
+ <>
158
+ <span>{labels.show}</span>
159
+ <select
160
+ value={perPage}
161
+ onChange={(event) => onPerPageChange(Number(event.target.value))}
162
+ aria-label={labels.perPage}
163
+ className="cv-listing-perpage"
164
+ >
165
+ {perPageOptions.map((option) => (
166
+ <option key={option} value={option}>
167
+ {option}
168
+ </option>
169
+ ))}
170
+ </select>
171
+ <span>{labels.perPage}</span>
172
+ </>
173
+ ) : null}
174
+ <span className="ml-1">{labels.total(total)}</span>
175
+ </div>
176
+
177
+ <div className="flex items-center gap-2">
178
+ <button
179
+ data-cv-tooltip={labels.previous}
180
+ type="button"
181
+ onClick={() => onPageChange(page - 1)}
182
+ disabled={page <= 1}
183
+ aria-label={labels.previous}
184
+ className="cv-header-icon disabled:opacity-40"
185
+ >
186
+ ‹
187
+ </button>
188
+ <span className="text-xs text-gray-500">{labels.page(page, lastPage)}</span>
189
+ <button
190
+ data-cv-tooltip={labels.next}
191
+ type="button"
192
+ onClick={() => onPageChange(page + 1)}
193
+ disabled={page >= lastPage}
194
+ aria-label={labels.next}
195
+ className="cv-header-icon disabled:opacity-40"
196
+ >
197
+ ›
198
+ </button>
199
+ </div>
200
+ </div>
201
+ )
202
+ }
@@ -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