@adatechnology/conversations-ui 0.1.0-rc.26 → 0.1.0-rc.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-DXPSPUWF.js +110 -0
- package/dist/{chunk-TV4OQRGH.js → chunk-UZJBYD5O.js} +123 -45
- package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
- package/dist/flows/index.d.ts +75 -2
- package/dist/flows/index.js +176 -95
- package/dist/index.d.ts +37 -10
- package/dist/index.js +694 -297
- package/dist/preview/index.js +3 -3
- package/dist/styles.css +119 -6
- package/package.json +2 -2
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +3 -3
- package/src/AudioRecorderButton.tsx +3 -3
- package/src/AudioTranscription.tsx +4 -1
- package/src/Avatar.tsx +1 -1
- package/src/ConversationContextPanel.tsx +2 -2
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +3 -2
- package/src/ConversationRow.tsx +31 -7
- package/src/DocumentsLibrary.tsx +9 -4
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +2 -0
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +27 -8
- package/src/MessageComposer.tsx +11 -8
- package/src/RichMessageComposer.test.tsx +42 -12
- package/src/RichMessageComposer.tsx +216 -45
- 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 +164 -0
- package/src/WhatsAppMessageEditor.tsx +4 -4
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationWindow.ts +7 -5
- package/src/documents/DocumentsWorkspace.tsx +10 -6
- package/src/flows/FlowGroupHeader.tsx +2 -2
- package/src/flows/FlowMapNode.tsx +2 -1
- package/src/flows/FlowNodeCard.tsx +3 -3
- package/src/flows/FlowNodePanel.tsx +6 -5
- package/src/flows/FlowPalette.tsx +7 -1
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowsWorkspace.tsx +69 -102
- package/src/flows/flowEditorOps.test.ts +241 -0
- package/src/flows/flowEditorOps.ts +177 -0
- package/src/flows/flowGraph.ts +1 -1
- package/src/flows/index.ts +16 -0
- package/src/flows/labels.ts +4 -0
- package/src/flows/workspaceContract.test.ts +95 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +1 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +5 -1
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationSimulatorPanel.tsx +1 -1
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/settings/MessagesWorkspace.tsx +3 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +1 -1
- package/src/settings/TranscriptionSettingsForm.tsx +1 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.tsx +2 -0
- package/src/styles.css +100 -4
- package/src/workspace/BulkTemplateModal.tsx +4 -2
- package/src/workspace/ConversationPane.tsx +49 -19
- package/src/workspace/ConversationsInboxList.tsx +17 -8
- package/src/workspace/ConversationsWorkspace.tsx +24 -16
- package/src/workspace/useConversationsInbox.ts +5 -2
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* Operações puras de grafo que o editor de fluxo precisa, e que viviam soltas dentro da página de
|
|
5
|
+
* 973 linhas do financiamento.
|
|
6
|
+
*
|
|
7
|
+
* Puras e separadas do hook de propósito: são a parte que dá para testar sem navegador, sem React e
|
|
8
|
+
* sem estado — e três delas (`removeNodeAndCleanRefs`, `resolveConnection`, `mergedFlowKeysFrom`)
|
|
9
|
+
* decidem o que acontece com o fluxo que alguém desenhou. Errar ali não dá erro; dá aresta apontando
|
|
10
|
+
* para nó que não existe mais, ou fluxo que some do canvas.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Os TIPOS do grafo vêm do meta-whatsapp-contracts, fonte única da verdade do trio
|
|
14
|
+
// (`pluggable-module.md` §2) — mesmo motivo documentado em `flowGraph.ts`. `import type` puro: nada
|
|
15
|
+
// do contracts entra por aqui em tempo de execução.
|
|
16
|
+
import type { FlowGraphData, FlowNodeData, FlowNodeNext } from '@adatechnology/meta-whatsapp-contracts'
|
|
17
|
+
|
|
18
|
+
// A constante vem do `flowGraph`, que já a define, e não do contracts: duas fontes do mesmo prefixo
|
|
19
|
+
// divergem em silêncio, e o sintoma seria salto entre fluxos que o motor do bot não reconhece.
|
|
20
|
+
import { CROSS_FLOW_PREFIX, crossFlowTargetsOf } from './flowGraph'
|
|
21
|
+
|
|
22
|
+
const NAMESPACE_SEPARATOR = '::'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Id de nó no canvas mesclado: vários fluxos dividem o mesmo espaço, e `boas-vindas` pode existir em
|
|
26
|
+
* dois deles. Sem o prefixo, arrastar um card moveria o homônimo do outro fluxo.
|
|
27
|
+
*/
|
|
28
|
+
export function namespaceNodeId(flowKey: string, nodeId: string): string {
|
|
29
|
+
return `${flowKey}${NAMESPACE_SEPARATOR}${nodeId}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseNamespacedId(value: string): { flowKey: string; nodeId: string } {
|
|
33
|
+
const index = value.indexOf(NAMESPACE_SEPARATOR)
|
|
34
|
+
// Sem separador é id de fluxo único — trata como do fluxo vazio para o chamador decidir.
|
|
35
|
+
if (index === -1) return { flowKey: '', nodeId: value }
|
|
36
|
+
return { flowKey: value.slice(0, index), nodeId: value.slice(index + NAMESPACE_SEPARATOR.length) }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Apaga o nó E as referências a ele.
|
|
41
|
+
*
|
|
42
|
+
* A limpeza não é cortesia: uma aresta apontando para nó inexistente faz o motor do bot parar a
|
|
43
|
+
* conversa no meio, e o sintoma aparece para o cliente, não para quem editou.
|
|
44
|
+
*/
|
|
45
|
+
export function removeNodeAndCleanRefs(
|
|
46
|
+
nodes: Readonly<Record<string, FlowNodeData>>,
|
|
47
|
+
removedId: string,
|
|
48
|
+
): Record<string, FlowNodeData> {
|
|
49
|
+
const remaining: Record<string, FlowNodeData> = {}
|
|
50
|
+
|
|
51
|
+
for (const [id, node] of Object.entries(nodes)) {
|
|
52
|
+
if (id === removedId) continue
|
|
53
|
+
remaining[id] = { ...node, next: cleanNext(node.next, removedId) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return remaining
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cleanNext(next: FlowNodeNext | undefined, removedId: string): FlowNodeNext | undefined {
|
|
60
|
+
if (next === undefined) return undefined
|
|
61
|
+
if (typeof next === 'string') return next === removedId ? '' : next
|
|
62
|
+
|
|
63
|
+
const byAnswer: Record<string, string> = {}
|
|
64
|
+
for (const [answer, target] of Object.entries(next.byAnswer ?? {})) {
|
|
65
|
+
// Resposta que levava ao nó apagado fica com destino vazio, e não é removida: apagar a chave
|
|
66
|
+
// esconderia da tela que aquela opção existe e não vai a lugar nenhum.
|
|
67
|
+
byAnswer[answer] = target === removedId ? '' : target
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { byAnswer, default: next.default === removedId ? '' : (next.default ?? '') }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* O fecho transitivo dos fluxos alcançáveis a partir de um — é o conjunto que o canvas abre junto.
|
|
75
|
+
*
|
|
76
|
+
* BFS e não recursão: fluxo que referencia a si mesmo (menu que volta ao menu) é comum, e recursão
|
|
77
|
+
* ingênua estouraria a pilha no caso mais banal que existe.
|
|
78
|
+
*/
|
|
79
|
+
export function mergedFlowKeysFrom(
|
|
80
|
+
rootKey: string,
|
|
81
|
+
graphs: Readonly<Record<string, FlowGraphData>>,
|
|
82
|
+
): readonly string[] {
|
|
83
|
+
const visited = new Set<string>()
|
|
84
|
+
const queue = [rootKey]
|
|
85
|
+
|
|
86
|
+
while (queue.length > 0) {
|
|
87
|
+
const key = queue.shift()!
|
|
88
|
+
if (visited.has(key) || !graphs[key]) continue
|
|
89
|
+
visited.add(key)
|
|
90
|
+
for (const target of crossFlowTargetsOf(graphs[key]!)) {
|
|
91
|
+
if (!visited.has(target) && graphs[target]) queue.push(target)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return [...visited]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type ConnectionRequest = {
|
|
99
|
+
readonly source: string
|
|
100
|
+
readonly target: string
|
|
101
|
+
readonly sourceHandle?: string | null | undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type ResolvedConnection = {
|
|
105
|
+
readonly flowKey: string
|
|
106
|
+
readonly nodeId: string
|
|
107
|
+
readonly handle: string
|
|
108
|
+
/** O que gravar no `next`: id de nó local, ou `flow:<key>` quando atravessa fluxo. */
|
|
109
|
+
readonly targetValue: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Traduz um arraste de aresta no valor que vai para o `next` — e recusa o que o motor do bot não
|
|
114
|
+
* sabe executar.
|
|
115
|
+
*
|
|
116
|
+
* A regra que não é óbvia: conectar num nó de OUTRO fluxo só funciona se for o nó inicial dele,
|
|
117
|
+
* porque o motor só sabe pular para o começo de um fluxo, não para um nó do meio. Conectar no meio
|
|
118
|
+
* devolve `undefined` — recusa silenciosa é melhor que gravar um salto que o bot vai ignorar em
|
|
119
|
+
* produção, deixando a conversa parada sem ninguém entender por quê.
|
|
120
|
+
*/
|
|
121
|
+
export function resolveConnection(params: {
|
|
122
|
+
readonly connection: ConnectionRequest
|
|
123
|
+
readonly graphs: Readonly<Record<string, FlowGraphData>>
|
|
124
|
+
}): ResolvedConnection | undefined {
|
|
125
|
+
const { source, target, sourceHandle } = params.connection
|
|
126
|
+
if (!source || !target || source === target) return undefined
|
|
127
|
+
|
|
128
|
+
const sourceRef = parseNamespacedId(source)
|
|
129
|
+
const targetRef = parseNamespacedId(target)
|
|
130
|
+
|
|
131
|
+
let targetValue: string
|
|
132
|
+
if (targetRef.flowKey === sourceRef.flowKey) {
|
|
133
|
+
targetValue = targetRef.nodeId
|
|
134
|
+
} else {
|
|
135
|
+
const targetGraph = params.graphs[targetRef.flowKey]
|
|
136
|
+
if (!targetGraph || targetGraph.startNodeId !== targetRef.nodeId) return undefined
|
|
137
|
+
targetValue = `${CROSS_FLOW_PREFIX}${targetRef.flowKey}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
flowKey: sourceRef.flowKey,
|
|
142
|
+
nodeId: sourceRef.nodeId,
|
|
143
|
+
handle: sourceHandle ?? 'next',
|
|
144
|
+
targetValue,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Aplica a conexão resolvida no nó. `next` string para saída única, objeto para ramificação. */
|
|
149
|
+
export function applyConnection(node: FlowNodeData, resolved: ResolvedConnection): FlowNodeData {
|
|
150
|
+
const currentNext = typeof node.next === 'object' && node.next ? node.next : undefined
|
|
151
|
+
|
|
152
|
+
if (resolved.handle === 'next') return { ...node, next: resolved.targetValue }
|
|
153
|
+
|
|
154
|
+
if (resolved.handle === '__default') {
|
|
155
|
+
return { ...node, next: { byAnswer: currentNext?.byAnswer ?? {}, default: resolved.targetValue } }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
...node,
|
|
160
|
+
next: {
|
|
161
|
+
byAnswer: { ...(currentNext?.byAnswer ?? {}), [resolved.handle]: resolved.targetValue },
|
|
162
|
+
default: currentNext?.default ?? '',
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Um fluxo está sujo quando o rascunho difere do publicado.
|
|
169
|
+
*
|
|
170
|
+
* Comparação estrutural por JSON: é grosseira, e é suficiente porque o grafo é dado serializável sem
|
|
171
|
+
* ordem significativa de chave — o servidor devolve o que gravou. Comparar campo a campo daria a
|
|
172
|
+
* mesma resposta com mais código para errar.
|
|
173
|
+
*/
|
|
174
|
+
export function isGraphDirty(working: FlowGraphData | undefined, published: FlowGraphData | undefined): boolean {
|
|
175
|
+
if (!working || !published) return false
|
|
176
|
+
return JSON.stringify(working) !== JSON.stringify(published)
|
|
177
|
+
}
|
package/src/flows/flowGraph.ts
CHANGED
|
@@ -372,7 +372,7 @@ export function findCollectionChains(graph: FlowGraphData): CollectionChain[] {
|
|
|
372
372
|
}
|
|
373
373
|
|
|
374
374
|
// Gera um id de nó único e legível a partir do rótulo (ex.: "Qual sua renda?" → "qual_sua_renda").
|
|
375
|
-
export function slugifyNodeId(label: string, existing:
|
|
375
|
+
export function slugifyNodeId(label: string, existing: ReadonlySet<string>): string {
|
|
376
376
|
const base =
|
|
377
377
|
label
|
|
378
378
|
.toLowerCase()
|
package/src/flows/index.ts
CHANGED
|
@@ -55,3 +55,19 @@ export type { FlowPortalNodeData } from './FlowPortalNode'
|
|
|
55
55
|
export type { FlowPaletteProps, FlowPaletteActionOption, NewNodeSpec } from './FlowPalette'
|
|
56
56
|
export type { FlowNodePanelProps } from './FlowNodePanel'
|
|
57
57
|
export type { FlowWhatsAppPreviewProps } from './FlowWhatsAppPreview'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Operações do EDITOR de fluxo, puras. Vinham soltas dentro da página de 973 linhas do
|
|
61
|
+
* financiamento; são a base do `FlowsWorkspace` (ADR 0002) e a parte que carrega o risco de perder
|
|
62
|
+
* trabalho de quem edita.
|
|
63
|
+
*/
|
|
64
|
+
export {
|
|
65
|
+
applyConnection,
|
|
66
|
+
isGraphDirty,
|
|
67
|
+
mergedFlowKeysFrom,
|
|
68
|
+
namespaceNodeId,
|
|
69
|
+
parseNamespacedId,
|
|
70
|
+
removeNodeAndCleanRefs,
|
|
71
|
+
resolveConnection,
|
|
72
|
+
} from './flowEditorOps'
|
|
73
|
+
export type { ConnectionRequest, ResolvedConnection } from './flowEditorOps'
|
package/src/flows/labels.ts
CHANGED
|
@@ -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,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* O subpath `/flows` precisa exportar a TELA, não só as peças.
|
|
5
|
+
*
|
|
6
|
+
* Existe porque a ausência disso já custou: exportando apenas `FlowMapCanvas`, `FlowPalette` e
|
|
7
|
+
* `FlowNodePanel`, o financiamento montou a tela por conta — 973 linhas de página mais um fork local
|
|
8
|
+
* dos componentes, que ficou atrás do pacote. O quickcart, para ter a mesma tela, teria que copiar o
|
|
9
|
+
* arquivo. É exatamente a divergência que `pluggable-module.md` §4 proíbe.
|
|
10
|
+
*
|
|
11
|
+
* O teste é de superfície, não de comportamento: não prova que o canvas desenha certo, prova que
|
|
12
|
+
* existe UM lugar onde a tela mora, e que ela aceita customização por contrato em vez de por fork.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from 'bun:test'
|
|
16
|
+
|
|
17
|
+
import * as flows from './index'
|
|
18
|
+
import { DEFAULT_FLOW_EDITOR_LABELS, mergeFlowEditorLabels } from './labels'
|
|
19
|
+
|
|
20
|
+
const WORKSPACE_SOURCE = `${import.meta.dir}/FlowsWorkspace.tsx`
|
|
21
|
+
|
|
22
|
+
describe('superfície composta', () => {
|
|
23
|
+
it('exporta a tela inteira', () => {
|
|
24
|
+
expect(typeof flows.FlowsWorkspace).toBe('function')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('exporta as peças também — quem precisa de layout próprio não fica sem saída', () => {
|
|
28
|
+
// Workspace é o caminho recomendado, não uma prisão: um produto com layout radicalmente
|
|
29
|
+
// diferente compõe as peças, e isso é melhor que forkar o pacote.
|
|
30
|
+
for (const piece of ['FlowMapCanvas', 'FlowPalette', 'FlowNodePanel', 'FlowWhatsAppPreview']) {
|
|
31
|
+
expect(typeof (flows as Record<string, unknown>)[piece], piece).toBe('function')
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('as operações de grafo saem puras, sem passar pela tela', () => {
|
|
36
|
+
// São as que a tela consome de verdade (ver os imports em `FlowsWorkspace.tsx`). Exportá-las sem
|
|
37
|
+
// usá-las seria pior que não ter teste: teste verde sobre código que não roda em produção.
|
|
38
|
+
for (const operation of ['resolveConnection', 'applyConnection', 'removeNodeAndCleanRefs', 'mergedFlowKeysFrom']) {
|
|
39
|
+
expect(typeof (flows as Record<string, unknown>)[operation], operation).toBe('function')
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('contrato de customização', () => {
|
|
45
|
+
it('nenhuma capacidade em forma de flag booleana `hasX`', async () => {
|
|
46
|
+
/**
|
|
47
|
+
* Capacidade opcional é por AUSÊNCIA de prop. `hasDelete` seria um segundo jeito de dizer o que
|
|
48
|
+
* `deletableFlowKeys` já diz, e dois jeitos divergem — alguém liga a flag sem a lista e a tela
|
|
49
|
+
* desenha um botão que não exclui nada.
|
|
50
|
+
*/
|
|
51
|
+
const content = await Bun.file(WORKSPACE_SOURCE).text()
|
|
52
|
+
|
|
53
|
+
expect(content).not.toMatch(/readonly has[A-Z]/)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('aceita labels, className e os slots de render', async () => {
|
|
57
|
+
const content = await Bun.file(WORKSPACE_SOURCE).text()
|
|
58
|
+
|
|
59
|
+
expect(content).toContain('labels?: Partial<')
|
|
60
|
+
// `className` é o que deixa o produto posicionar a tela no layout dele sem tocar no pacote.
|
|
61
|
+
expect(content).toContain('className?: string')
|
|
62
|
+
expect(content).toContain('renderMediaPicker?:')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('nenhum texto visível escrito no componente — tudo passa por labels', async () => {
|
|
66
|
+
const content = await Bun.file(WORKSPACE_SOURCE).text()
|
|
67
|
+
/**
|
|
68
|
+
* Texto entre tags JSX que não seja `{...}`, que é o que `web.md` §6 proíbe.
|
|
69
|
+
*
|
|
70
|
+
* O `\s*` nas pontas não é detalhe: a primeira versão deste regex no notification-ui exigia o
|
|
71
|
+
* texto colado nas tags, e o Prettier põe o conteúdo em linha própria — o teste passava com
|
|
72
|
+
* `>\n Configurações\n<` no meio do componente, provando nada.
|
|
73
|
+
*/
|
|
74
|
+
const hardcoded = content.match(/>\s*[A-Za-zÀ-ÿ][A-Za-zÀ-ÿ ]{3,}\s*</g)
|
|
75
|
+
|
|
76
|
+
expect(hardcoded, `texto fixo: ${hardcoded?.join(' | ')}`).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('o produto sobrescreve UM texto sem perder os outros do mesmo grupo', () => {
|
|
80
|
+
const merged = mergeFlowEditorLabels({ workspace: { ...DEFAULT_FLOW_EDITOR_LABELS.workspace, title: 'Jornadas' } })
|
|
81
|
+
|
|
82
|
+
expect(merged.workspace.title).toBe('Jornadas')
|
|
83
|
+
expect(merged.workspace.saveGraph).toBe(DEFAULT_FLOW_EDITOR_LABELS.workspace.saveGraph)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('grupo novo de label entra no merge profundo', () => {
|
|
87
|
+
// Esquecer o grupo no `mergeFlowEditorLabels` deixa o override apagar os irmãos dele, e o
|
|
88
|
+
// sintoma é texto sumindo da tela — não erro.
|
|
89
|
+
for (const group of ['workspace', 'flowManager', 'validation', 'collectionChain'] as const) {
|
|
90
|
+
const merged = mergeFlowEditorLabels({ [group]: {} })
|
|
91
|
+
|
|
92
|
+
expect(Object.keys(merged[group]).length, group).toBe(Object.keys(DEFAULT_FLOW_EDITOR_LABELS[group]).length)
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -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
|
|
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 (!
|
|
20
|
+
if (!sse || !conversationId) return
|
|
19
21
|
|
|
20
|
-
const source =
|
|
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
|
-
}, [
|
|
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
|
|
36
|
+
const sse = useConversations()?.sse
|
|
35
37
|
const onEventRef = useRef(onEvent)
|
|
36
38
|
onEventRef.current = onEvent
|
|
37
39
|
|
|
38
40
|
useEffect(() => {
|
|
39
|
-
if (!
|
|
41
|
+
if (!sse) return
|
|
40
42
|
|
|
41
|
-
const source =
|
|
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
|
-
}, [
|
|
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
|
+
}
|