@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.
- package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/chunk-BJNRLLDO.js +2708 -0
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2389 -678
- package/dist/index.d.ts +1171 -42
- package/dist/index.js +3755 -843
- package/dist/preview/index.d.ts +157 -42
- package/dist/preview/index.js +772 -191
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +14 -3
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +347 -24
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +163 -45
- package/src/ConversationListItem.tsx +19 -2
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +31 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +382 -0
- package/src/EmojiPicker.tsx +70 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +146 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +92 -16
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +75 -6
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +155 -19
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +67 -7
- package/src/WhatsAppMessageEditor.tsx +34 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +106 -69
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1219 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +11 -7
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +129 -13
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +225 -17
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +175 -15
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +127 -4
- package/src/preview/index.ts +51 -3
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/preview.test.ts +5 -3
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +288 -1
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +185 -10
- package/src/quickReply.test.ts +58 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/types.ts +64 -1
- package/src/useWaitingNotifications.ts +74 -29
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/chunk-4R6Y43DQ.js +0 -726
- package/dist/chunk-NV2RZ5KT.js +0 -56
- 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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|