@adatechnology/conversations-ui 0.1.0-rc.25 → 0.1.0-rc.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +3 -1
- package/dist/flows/index.js +61 -18
- 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 +1 -1
- 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/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 +16 -5
- package/src/flows/labels.ts +4 -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
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
type FlowNodeData,
|
|
41
41
|
type GraphIssue,
|
|
42
42
|
} from './flowGraph'
|
|
43
|
+
import { TooltipLayer } from '../Tooltip'
|
|
43
44
|
|
|
44
45
|
const RF_NODE_TYPES = {
|
|
45
46
|
...flowNodeTypes,
|
|
@@ -104,7 +105,9 @@ export interface FlowsWorkspaceProps {
|
|
|
104
105
|
/** Kinds de ação do produto oferecidos na paleta (`trigger_simulation`, `abrir_comanda`…). */
|
|
105
106
|
readonly actionOptions?: readonly FlowPaletteActionOption[]
|
|
106
107
|
/** Seletor de arquivos do nó `send_media` — a biblioteca é do host, então entra por slot. */
|
|
107
|
-
|
|
108
|
+
// Recebe o grafo junto do nó: com fluxos fundidos, o nó em edição pode pertencer a um fluxo que
|
|
109
|
+
// não é o raiz, e o seletor do host precisa da chave dele para saber onde gravar.
|
|
110
|
+
readonly renderMediaPicker?: (node: FlowNodeData, graph: FlowGraphData) => ReactNode
|
|
108
111
|
/** Intervalo do polling de posições vivas. Só tem efeito com `getLivePositions`. */
|
|
109
112
|
readonly livePollIntervalMs?: number
|
|
110
113
|
readonly className?: string
|
|
@@ -964,6 +967,7 @@ export function FlowsWorkspace({
|
|
|
964
967
|
|
|
965
968
|
return (
|
|
966
969
|
<div className={`space-y-4 h-full flex flex-col ${className ?? ''}`}>
|
|
970
|
+
<TooltipLayer />
|
|
967
971
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
968
972
|
<div>
|
|
969
973
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{labels.workspace.title}</h2>
|
|
@@ -975,6 +979,7 @@ export function FlowsWorkspace({
|
|
|
975
979
|
<span className="text-sm text-red-600">{saveErrorMessage ?? labels.workspace.saveError}</span>
|
|
976
980
|
)}
|
|
977
981
|
<button
|
|
982
|
+
data-cv-tooltip={viewMode === 'map' ? labels.flowMap.toggleToDetail : labels.flowMap.toggleToMap} aria-label={viewMode === 'map' ? labels.flowMap.toggleToDetail : labels.flowMap.toggleToMap}
|
|
978
983
|
type="button"
|
|
979
984
|
onClick={() => setViewMode((mode) => (mode === 'map' ? 'detail' : 'map'))}
|
|
980
985
|
className={OUTLINE_BUTTON}
|
|
@@ -988,7 +993,7 @@ export function FlowsWorkspace({
|
|
|
988
993
|
type="button"
|
|
989
994
|
onClick={handleOrganize}
|
|
990
995
|
className={OUTLINE_BUTTON}
|
|
991
|
-
|
|
996
|
+
data-cv-tooltip={labels.workspace.organizeTooltip} aria-label={labels.workspace.organizeTooltip}
|
|
992
997
|
disabled={!primaryGraph}
|
|
993
998
|
>
|
|
994
999
|
<LayoutGrid size={14} aria-hidden="true" /> {labels.workspace.organize}
|
|
@@ -997,12 +1002,13 @@ export function FlowsWorkspace({
|
|
|
997
1002
|
type="button"
|
|
998
1003
|
onClick={handleDiscardChanges}
|
|
999
1004
|
className={OUTLINE_BUTTON}
|
|
1000
|
-
|
|
1005
|
+
data-cv-tooltip={labels.workspace.discardTooltip} aria-label={labels.workspace.discardTooltip}
|
|
1001
1006
|
disabled={!isDirty || saveState === 'saving'}
|
|
1002
1007
|
>
|
|
1003
1008
|
<Undo2 size={14} aria-hidden="true" /> {labels.workspace.discardChanges}
|
|
1004
1009
|
</button>
|
|
1005
1010
|
<button
|
|
1011
|
+
data-cv-tooltip={labels.workspace.saveGraph} aria-label={labels.workspace.saveGraph}
|
|
1006
1012
|
type="button"
|
|
1007
1013
|
onClick={() => void handlePublish()}
|
|
1008
1014
|
className={PRIMARY_BUTTON}
|
|
@@ -1022,6 +1028,7 @@ export function FlowsWorkspace({
|
|
|
1022
1028
|
{graphs &&
|
|
1023
1029
|
Object.values(graphs).map((graph) => (
|
|
1024
1030
|
<button
|
|
1031
|
+
data-cv-tooltip={graph.label} aria-label={graph.label}
|
|
1025
1032
|
key={graph.key}
|
|
1026
1033
|
type="button"
|
|
1027
1034
|
onClick={() => focusFlow(graph.key)}
|
|
@@ -1036,6 +1043,7 @@ export function FlowsWorkspace({
|
|
|
1036
1043
|
))}
|
|
1037
1044
|
{canCreateFlow && (
|
|
1038
1045
|
<button
|
|
1046
|
+
data-cv-tooltip={labels.flowManager.newFlow} aria-label={labels.flowManager.newFlow}
|
|
1039
1047
|
type="button"
|
|
1040
1048
|
onClick={() => {
|
|
1041
1049
|
setFlowMutationState({ pending: false })
|
|
@@ -1057,6 +1065,7 @@ export function FlowsWorkspace({
|
|
|
1057
1065
|
)}
|
|
1058
1066
|
{primaryGraph && canDeleteFlow && (
|
|
1059
1067
|
<button
|
|
1068
|
+
data-cv-tooltip={labels.flowManager.deleteFlow} aria-label={labels.flowManager.deleteFlow}
|
|
1060
1069
|
type="button"
|
|
1061
1070
|
onClick={() => {
|
|
1062
1071
|
setFlowMutationState({ pending: false })
|
|
@@ -1184,10 +1193,11 @@ export function FlowsWorkspace({
|
|
|
1184
1193
|
{flowMutationState.error && <p className="text-xs text-red-600">{flowMutationState.error}</p>}
|
|
1185
1194
|
</div>
|
|
1186
1195
|
<div className="flex justify-end gap-2 mt-4">
|
|
1187
|
-
<button type="button" className={OUTLINE_BUTTON} onClick={() => setShowCreateDialog(false)}>
|
|
1196
|
+
<button data-cv-tooltip={labels.nodePanel.cancel} aria-label={labels.nodePanel.cancel} type="button" className={OUTLINE_BUTTON} onClick={() => setShowCreateDialog(false)}>
|
|
1188
1197
|
{labels.nodePanel.cancel}
|
|
1189
1198
|
</button>
|
|
1190
1199
|
<button
|
|
1200
|
+
data-cv-tooltip={labels.flowManager.create} aria-label={labels.flowManager.create}
|
|
1191
1201
|
type="button"
|
|
1192
1202
|
className={PRIMARY_BUTTON}
|
|
1193
1203
|
onClick={() => void handleCreateFlow()}
|
|
@@ -1206,10 +1216,11 @@ export function FlowsWorkspace({
|
|
|
1206
1216
|
</p>
|
|
1207
1217
|
{flowMutationState.error && <p className="text-xs text-red-600 mt-2">{flowMutationState.error}</p>}
|
|
1208
1218
|
<div className="flex justify-end gap-2 mt-4">
|
|
1209
|
-
<button type="button" className={OUTLINE_BUTTON} onClick={() => setShowDeleteDialog(false)}>
|
|
1219
|
+
<button data-cv-tooltip={labels.nodePanel.cancel} aria-label={labels.nodePanel.cancel} type="button" className={OUTLINE_BUTTON} onClick={() => setShowDeleteDialog(false)}>
|
|
1210
1220
|
{labels.nodePanel.cancel}
|
|
1211
1221
|
</button>
|
|
1212
1222
|
<button
|
|
1223
|
+
data-cv-tooltip={labels.flowManager.deleteFlow} aria-label={labels.flowManager.deleteFlow}
|
|
1213
1224
|
type="button"
|
|
1214
1225
|
className="inline-flex items-center gap-1.5 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700 disabled:opacity-40"
|
|
1215
1226
|
onClick={() => void handleDeleteFlow()}
|
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,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
|
+
}
|
|
@@ -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
|
|
package/src/listing/index.tsx
CHANGED
|
@@ -29,6 +29,7 @@ export function SortableHead({ label, field, activeField, direction, onSort, cla
|
|
|
29
29
|
return (
|
|
30
30
|
<th scope="col" className={cn('px-3 py-2 text-left text-xs font-medium', className)}>
|
|
31
31
|
<button
|
|
32
|
+
data-cv-tooltip={label}
|
|
32
33
|
type="button"
|
|
33
34
|
onClick={() => onSort(field)}
|
|
34
35
|
aria-label={label}
|
|
@@ -64,6 +65,7 @@ export function MultiSelectFilter({ label, options, selected, onChange, classNam
|
|
|
64
65
|
return (
|
|
65
66
|
<div className={cn('relative', className)}>
|
|
66
67
|
<button
|
|
68
|
+
data-cv-tooltip={label} aria-label={label}
|
|
67
69
|
type="button"
|
|
68
70
|
onClick={() => setOpen((current) => !current)}
|
|
69
71
|
className="cv-header-action inline-flex items-center gap-1"
|
|
@@ -112,7 +114,7 @@ export function BulkActionBar({ selectedCount, selectedLabel, clearLabel, onClea
|
|
|
112
114
|
<div className="cv-bulk-bar" role="toolbar" aria-label={selectedLabel(selectedCount)}>
|
|
113
115
|
<span className="text-xs font-medium">{selectedLabel(selectedCount)}</span>
|
|
114
116
|
{children}
|
|
115
|
-
<button type="button" onClick={onClear} className="cv-header-action ml-auto inline-flex items-center gap-1">
|
|
117
|
+
<button data-cv-tooltip={clearLabel} aria-label={clearLabel} type="button" onClick={onClear} className="cv-header-action ml-auto inline-flex items-center gap-1">
|
|
116
118
|
<X size={12} aria-hidden="true" />
|
|
117
119
|
{clearLabel}
|
|
118
120
|
</button>
|
|
@@ -174,6 +176,7 @@ export function ListingPagination({
|
|
|
174
176
|
|
|
175
177
|
<div className="flex items-center gap-2">
|
|
176
178
|
<button
|
|
179
|
+
data-cv-tooltip={labels.previous}
|
|
177
180
|
type="button"
|
|
178
181
|
onClick={() => onPageChange(page - 1)}
|
|
179
182
|
disabled={page <= 1}
|
|
@@ -184,6 +187,7 @@ export function ListingPagination({
|
|
|
184
187
|
</button>
|
|
185
188
|
<span className="text-xs text-gray-500">{labels.page(page, lastPage)}</span>
|
|
186
189
|
<button
|
|
190
|
+
data-cv-tooltip={labels.next}
|
|
187
191
|
type="button"
|
|
188
192
|
onClick={() => onPageChange(page + 1)}
|
|
189
193
|
disabled={page >= lastPage}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rótulos dos controles de paginação. Os pagers da inbox, do painel de documentos e da biblioteca
|
|
3
|
+
* são o mesmo controle repetido: o texto mora aqui para os três dizerem a mesma coisa.
|
|
4
|
+
*/
|
|
5
|
+
export const PAGINATION_LABELS = {
|
|
6
|
+
first: 'Primeira página',
|
|
7
|
+
previous: 'Página anterior',
|
|
8
|
+
next: 'Próxima página',
|
|
9
|
+
last: 'Última página',
|
|
10
|
+
} as const
|
|
@@ -72,7 +72,7 @@ export function ConversationSimulatorPanel({
|
|
|
72
72
|
</div>
|
|
73
73
|
<div className="cv-simulator-panel__actions">
|
|
74
74
|
{headerActions}
|
|
75
|
-
<button type="button" onClick={onClose}
|
|
75
|
+
<button type="button" onClick={onClose} data-cv-tooltip={text.close} aria-label={text.close} className="cv-simulator-panel__close">
|
|
76
76
|
{/* SVG inline em vez de lucide-react: o pacote não impõe biblioteca de ícone ao host. */}
|
|
77
77
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
|
78
78
|
<path d="M18 6 6 18M6 6l12 12" />
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContext, useContext, type ReactNode } from 'react'
|
|
1
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react'
|
|
2
2
|
import type { ConversationsApi, SSEProvider } from './types'
|
|
3
3
|
|
|
4
4
|
interface ConversationsContextValue {
|
|
@@ -17,11 +17,13 @@ export function ConversationsProvider({
|
|
|
17
17
|
sse: SSEProvider
|
|
18
18
|
children: ReactNode
|
|
19
19
|
}) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
)
|
|
20
|
+
// Objeto novo a cada render fazia todo efeito que depende do contexto reexecutar junto — o do
|
|
21
|
+
// `useConversationRealtime` fecha e reabre o SSE da conversa, e cada reabertura pede um ticket
|
|
22
|
+
// novo. Uma tela que renderiza em rajada saturava as 6 conexões do navegador com requisições
|
|
23
|
+
// pendentes que nunca chegavam a servir para nada.
|
|
24
|
+
const value = useMemo(() => ({ api, sse }), [api, sse])
|
|
25
|
+
|
|
26
|
+
return <ConversationsContext.Provider value={value}>{children}</ConversationsContext.Provider>
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export function useConversations(): ConversationsContextValue | null {
|