@adatechnology/conversations-ui 0.2.0 → 0.3.0
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-DKPXKQGC.js → chunk-HMCOTZAX.js} +18 -1
- package/dist/{chunk-NHQDQJL2.js → chunk-L4OE4ORQ.js} +18 -19
- package/dist/{chunk-WCBDXZ3X.js → chunk-PJNR6UMN.js} +73 -8
- package/dist/flows/index.js +32 -28
- package/dist/index.d.ts +42 -3
- package/dist/index.js +586 -295
- package/dist/preview/index.js +2 -2
- package/package.json +1 -1
- package/src/InteractiveMessage.tsx +13 -13
- package/src/MessageText.tsx +1 -3
- package/src/blockFormattingNesting.test.tsx +47 -0
- package/src/flows/FlowWhatsAppPreview.tsx +40 -42
- package/src/index.ts +3 -0
- package/src/lib/WhatsAppMessagePreview.tsx +32 -0
- package/src/lib/composer-formatting.ts +8 -0
- package/src/lib/whatsapp-formatting.test.tsx +65 -2
- package/src/lib/whatsapp-formatting.tsx +110 -13
- package/src/quickReplies/QuickRepliesWorkspace.tsx +249 -164
- package/src/quickReplies/QuickReplyFormattingToolbar.tsx +62 -0
- package/src/quickReplies/QuickReplyWhatsAppPreview.tsx +57 -0
- package/src/quickReplies/labels.ts +27 -1
- package/src/quickReplies/quickReplyAttachments.test.ts +71 -0
- package/src/quickReplies/quickReplyAttachments.ts +39 -0
- package/src/quickReplies/quickReplyFormatting.test.tsx +336 -0
- package/src/quickReplies/quickReplyFormatting.ts +232 -0
- package/src/quickReplies/useQuickRepliesWorkspace.ts +1 -1
- package/src/workspace/useComposerAttachmentRetry.ts +14 -0
- package/src/workspace/useComposerQueue.ts +17 -8
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* Edição pura do texto da mensagem pronta pela barra de formatação — seguindo as regras do WhatsApp:
|
|
5
|
+
* o marcador não pode encostar em espaço, cada linha é formatada sozinha, e clicar de novo desliga.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { FORMATTING_ACTION, WHATSAPP_MARKER_BY_ACTION, type FormattingAction } from '../lib/composer-formatting'
|
|
9
|
+
|
|
10
|
+
export type WrapSelectionParams = {
|
|
11
|
+
readonly text: string
|
|
12
|
+
readonly start: number
|
|
13
|
+
readonly end: number
|
|
14
|
+
readonly marker: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type TextSelectionEdit = {
|
|
18
|
+
readonly text: string
|
|
19
|
+
readonly selectionStart: number
|
|
20
|
+
readonly selectionEnd: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const LEADING_WHITESPACE = /^\s*/
|
|
24
|
+
const TRAILING_WHITESPACE = /\s*$/
|
|
25
|
+
|
|
26
|
+
function isWrappedBy(core: string, marker: string): boolean {
|
|
27
|
+
return core.length >= marker.length * 2 && core.startsWith(marker) && core.endsWith(marker)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type ToggleLineParams = { readonly line: string; readonly marker: string; readonly shouldUnwrap: boolean }
|
|
31
|
+
|
|
32
|
+
function toggleLine({ line, marker, shouldUnwrap }: ToggleLineParams): string {
|
|
33
|
+
if (!line.trim()) return line
|
|
34
|
+
const leading = LEADING_WHITESPACE.exec(line)?.[0] ?? ''
|
|
35
|
+
const trailing = TRAILING_WHITESPACE.exec(line)?.[0] ?? ''
|
|
36
|
+
const core = line.slice(leading.length, line.length - trailing.length)
|
|
37
|
+
const toggled = shouldUnwrap ? core.slice(marker.length, core.length - marker.length) : marker + core + marker
|
|
38
|
+
return leading + toggled + trailing
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function wrapLines({ text, start, end, marker }: WrapSelectionParams): TextSelectionEdit {
|
|
42
|
+
const lines = text.slice(start, end).split('\n')
|
|
43
|
+
const shouldUnwrap = lines.filter((line) => line.trim()).every((line) => isWrappedBy(line.trim(), marker))
|
|
44
|
+
const replaced = lines.map((line) => toggleLine({ line, marker, shouldUnwrap })).join('\n')
|
|
45
|
+
return {
|
|
46
|
+
text: text.slice(0, start) + replaced + text.slice(end),
|
|
47
|
+
selectionStart: start,
|
|
48
|
+
selectionEnd: start + replaced.length,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Envolve (ou desliga) a formatação da seleção. Sem seleção, abre o par e deixa o cursor no meio —
|
|
54
|
+
* é o que permite clicar em "Negrito" antes de digitar.
|
|
55
|
+
*/
|
|
56
|
+
export function wrapSelection({ text, start, end, marker }: WrapSelectionParams): TextSelectionEdit {
|
|
57
|
+
const selected = text.slice(start, end)
|
|
58
|
+
if (!selected.trim()) {
|
|
59
|
+
return {
|
|
60
|
+
text: text.slice(0, start) + marker + selected + marker + text.slice(end),
|
|
61
|
+
selectionStart: start + marker.length,
|
|
62
|
+
selectionEnd: end + marker.length,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (selected.includes('\n')) return wrapLines({ text, start, end, marker })
|
|
66
|
+
|
|
67
|
+
const coreStart = start + (LEADING_WHITESPACE.exec(selected)?.[0].length ?? 0)
|
|
68
|
+
const coreEnd = end - (TRAILING_WHITESPACE.exec(selected)?.[0].length ?? 0)
|
|
69
|
+
const core = text.slice(coreStart, coreEnd)
|
|
70
|
+
const before = text.slice(0, coreStart)
|
|
71
|
+
const after = text.slice(coreEnd)
|
|
72
|
+
const size = marker.length
|
|
73
|
+
|
|
74
|
+
if (isWrappedBy(core, marker)) {
|
|
75
|
+
const unwrapped = core.slice(size, core.length - size)
|
|
76
|
+
return { text: before + unwrapped + after, selectionStart: coreStart, selectionEnd: coreStart + unwrapped.length }
|
|
77
|
+
}
|
|
78
|
+
if (before.endsWith(marker) && after.startsWith(marker)) {
|
|
79
|
+
return {
|
|
80
|
+
text: before.slice(0, before.length - size) + core + after.slice(size),
|
|
81
|
+
selectionStart: coreStart - size,
|
|
82
|
+
selectionEnd: coreEnd - size,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
text: before + marker + core + marker + after,
|
|
87
|
+
selectionStart: coreStart + size,
|
|
88
|
+
selectionEnd: coreEnd + size,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** As ações do campo rico mais as que só o texto puro da mensagem pronta oferece. */
|
|
93
|
+
export const QUICK_REPLY_FORMATTING_ACTION = {
|
|
94
|
+
...FORMATTING_ACTION,
|
|
95
|
+
INLINE_CODE: 'inlineCode',
|
|
96
|
+
BULLETED_LIST: 'bulletedList',
|
|
97
|
+
NUMBERED_LIST: 'numberedList',
|
|
98
|
+
QUOTE: 'quote',
|
|
99
|
+
} as const
|
|
100
|
+
export type QuickReplyFormattingAction =
|
|
101
|
+
(typeof QUICK_REPLY_FORMATTING_ACTION)[keyof typeof QUICK_REPLY_FORMATTING_ACTION]
|
|
102
|
+
|
|
103
|
+
type LinePrefixAction =
|
|
104
|
+
| typeof QUICK_REPLY_FORMATTING_ACTION.BULLETED_LIST
|
|
105
|
+
| typeof QUICK_REPLY_FORMATTING_ACTION.NUMBERED_LIST
|
|
106
|
+
| typeof QUICK_REPLY_FORMATTING_ACTION.QUOTE
|
|
107
|
+
|
|
108
|
+
const LINE_PREFIX_PATTERN: Readonly<Record<LinePrefixAction, RegExp>> = {
|
|
109
|
+
[QUICK_REPLY_FORMATTING_ACTION.BULLETED_LIST]: /^[-*] /,
|
|
110
|
+
[QUICK_REPLY_FORMATTING_ACTION.NUMBERED_LIST]: /^\d+\. /,
|
|
111
|
+
[QUICK_REPLY_FORMATTING_ACTION.QUOTE]: /^> /,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const INLINE_MARKER_BY_ACTION: Readonly<Partial<Record<QuickReplyFormattingAction, string>>> = {
|
|
115
|
+
...WHATSAPP_MARKER_BY_ACTION,
|
|
116
|
+
[QUICK_REPLY_FORMATTING_ACTION.INLINE_CODE]: '`',
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isLinePrefixAction(action: QuickReplyFormattingAction): action is LinePrefixAction {
|
|
120
|
+
return action in LINE_PREFIX_PATTERN
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function prefixFor(action: LinePrefixAction, position: number): string {
|
|
124
|
+
if (action === QUICK_REPLY_FORMATTING_ACTION.NUMBERED_LIST) return `${position}. `
|
|
125
|
+
return action === QUICK_REPLY_FORMATTING_ACTION.QUOTE ? '> ' : '- '
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type ToggleLinePrefixParams = {
|
|
129
|
+
readonly text: string
|
|
130
|
+
readonly start: number
|
|
131
|
+
readonly end: number
|
|
132
|
+
readonly action: LinePrefixAction
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Lista, lista numerada e citação valem por linha: o prefixo vai em cada linha tocada pela seleção
|
|
137
|
+
* (numerando em sequência) e sai de todas quando todas já o têm. Linha em branco no meio fica como está.
|
|
138
|
+
*/
|
|
139
|
+
export function toggleLinePrefix({ text, start, end, action }: ToggleLinePrefixParams): TextSelectionEdit {
|
|
140
|
+
const lineStart = start === 0 ? 0 : text.lastIndexOf('\n', start - 1) + 1
|
|
141
|
+
const searchFrom = end > start && text[end - 1] === '\n' ? end - 1 : end
|
|
142
|
+
const nextBreak = text.indexOf('\n', searchFrom)
|
|
143
|
+
const lineEnd = nextBreak === -1 ? text.length : nextBreak
|
|
144
|
+
const original = text.slice(lineStart, lineEnd)
|
|
145
|
+
const lines = original.split('\n')
|
|
146
|
+
const pattern = LINE_PREFIX_PATTERN[action]
|
|
147
|
+
const filledLines = lines.filter((line) => line.trim())
|
|
148
|
+
const shouldRemove = filledLines.length > 0 && filledLines.every((line) => pattern.test(line))
|
|
149
|
+
|
|
150
|
+
let position = 0
|
|
151
|
+
const replaced = lines
|
|
152
|
+
.map((line) => {
|
|
153
|
+
if (shouldRemove) return line.replace(pattern, '')
|
|
154
|
+
if (!line.trim() && lines.length > 1) return line
|
|
155
|
+
position += 1
|
|
156
|
+
return prefixFor(action, position) + line.replace(pattern, '')
|
|
157
|
+
})
|
|
158
|
+
.join('\n')
|
|
159
|
+
|
|
160
|
+
const nextText = text.slice(0, lineStart) + replaced + text.slice(lineEnd)
|
|
161
|
+
if (start === end) {
|
|
162
|
+
const caret = Math.max(lineStart, start + replaced.length - original.length)
|
|
163
|
+
return { text: nextText, selectionStart: caret, selectionEnd: caret }
|
|
164
|
+
}
|
|
165
|
+
return { text: nextText, selectionStart: lineStart, selectionEnd: lineStart + replaced.length }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type ComputeFormattingEditParams = {
|
|
169
|
+
readonly text: string
|
|
170
|
+
readonly selectionStart: number
|
|
171
|
+
readonly selectionEnd: number
|
|
172
|
+
readonly action: QuickReplyFormattingAction
|
|
173
|
+
readonly maximumLength: number
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `undefined` quando o resultado passaria do limite do campo — o `maxLength` só barra a digitação. */
|
|
177
|
+
export function computeFormattingEdit({
|
|
178
|
+
text,
|
|
179
|
+
selectionStart,
|
|
180
|
+
selectionEnd,
|
|
181
|
+
action,
|
|
182
|
+
maximumLength,
|
|
183
|
+
}: ComputeFormattingEditParams): TextSelectionEdit | undefined {
|
|
184
|
+
const edit = isLinePrefixAction(action)
|
|
185
|
+
? toggleLinePrefix({ text, start: selectionStart, end: selectionEnd, action })
|
|
186
|
+
: wrapSelection({ text, start: selectionStart, end: selectionEnd, marker: INLINE_MARKER_BY_ACTION[action] ?? '' })
|
|
187
|
+
return edit.text.length > maximumLength ? undefined : edit
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type FormattingShortcutEvent = {
|
|
191
|
+
readonly key: string
|
|
192
|
+
readonly ctrlKey: boolean
|
|
193
|
+
readonly metaKey: boolean
|
|
194
|
+
readonly shiftKey: boolean
|
|
195
|
+
readonly altKey: boolean
|
|
196
|
+
readonly isComposing: boolean
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const SHORTCUT_ACTION_BY_KEY: Readonly<Record<string, FormattingAction>> = {
|
|
200
|
+
b: FORMATTING_ACTION.BOLD,
|
|
201
|
+
i: FORMATTING_ACTION.ITALIC,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const FORMATTING_SHORTCUT_HINT: Readonly<Partial<Record<QuickReplyFormattingAction, string>>> = {
|
|
205
|
+
[FORMATTING_ACTION.BOLD]: 'Ctrl/⌘+B',
|
|
206
|
+
[FORMATTING_ACTION.ITALIC]: 'Ctrl/⌘+I',
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function formattingActionForShortcut(event: FormattingShortcutEvent): FormattingAction | undefined {
|
|
210
|
+
if (event.isComposing || event.shiftKey || event.altKey) return undefined
|
|
211
|
+
if (!(event.ctrlKey || event.metaKey)) return undefined
|
|
212
|
+
return SHORTCUT_ACTION_BY_KEY[event.key.toLowerCase()]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export type ChangedRange = {
|
|
216
|
+
readonly start: number
|
|
217
|
+
/** Fim no texto anterior. */
|
|
218
|
+
readonly end: number
|
|
219
|
+
readonly insertedText: string
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Menor trecho que muda — substituir só ele mantém o desfazer nativo do campo enxuto. */
|
|
223
|
+
export function changedRange(previous: string, next: string): ChangedRange {
|
|
224
|
+
let prefix = 0
|
|
225
|
+
const shortest = Math.min(previous.length, next.length)
|
|
226
|
+
while (prefix < shortest && previous[prefix] === next[prefix]) prefix += 1
|
|
227
|
+
let suffix = 0
|
|
228
|
+
while (suffix < shortest - prefix && previous[previous.length - 1 - suffix] === next[next.length - 1 - suffix]) {
|
|
229
|
+
suffix += 1
|
|
230
|
+
}
|
|
231
|
+
return { start: prefix, end: previous.length - suffix, insertedText: next.slice(prefix, next.length - suffix) }
|
|
232
|
+
}
|
|
@@ -40,7 +40,7 @@ export type QuickRepliesWorkspaceEditing = {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const TITLE_MAX_LENGTH = 40
|
|
43
|
-
const BODY_MAX_LENGTH = 1000
|
|
43
|
+
export const BODY_MAX_LENGTH = 1000
|
|
44
44
|
const SHORTCUT_PATTERN = /^[a-z0-9-]{1,20}$/
|
|
45
45
|
|
|
46
46
|
/** Casca genérica de erro de API com `code` e `details[]` (`apis.md`) — sem acoplar a um cliente HTTP específico. */
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
resolveIdempotencyKey,
|
|
19
19
|
resolveRetryOutcome,
|
|
20
20
|
retryStoredAttachments,
|
|
21
|
+
shouldResetRetryKey,
|
|
21
22
|
type AttachmentSendStatus,
|
|
22
23
|
type IdempotencyKeyState,
|
|
23
24
|
} from '../quickReplies/quickReplyAttachments'
|
|
@@ -116,6 +117,19 @@ export function useComposerAttachmentRetry(params: UseComposerAttachmentRetryPar
|
|
|
116
117
|
if (isSameConversation()) setAttachmentStatus((current) => ({ ...current, [statusKey]: status }))
|
|
117
118
|
},
|
|
118
119
|
})
|
|
120
|
+
// Descarta a chave só se este retry a enviou E nenhum retry mais novo do mesmo `uploadId`
|
|
121
|
+
// já trocou o ref por identidade (ver `shouldResetRetryKey`) — senão um segundo retry rápido
|
|
122
|
+
// do mesmo anexo (mensagens prontas reusam `uploadId`) reenviaria com chave já consumida.
|
|
123
|
+
if (
|
|
124
|
+
shouldResetRetryKey({
|
|
125
|
+
current: retryIdempotencyKeyRef.current,
|
|
126
|
+
attempted: idempotencyState,
|
|
127
|
+
sentAttachmentKeys: result.sentAttachmentKeys,
|
|
128
|
+
uploadId: item.uploadId,
|
|
129
|
+
})
|
|
130
|
+
) {
|
|
131
|
+
retryIdempotencyKeyRef.current = undefined
|
|
132
|
+
}
|
|
119
133
|
setQueue(
|
|
120
134
|
(current) =>
|
|
121
135
|
resolveRetryOutcome({
|
|
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
7
7
|
import {
|
|
8
8
|
attachmentKey,
|
|
9
9
|
excludeRetryingItems,
|
|
10
|
+
hasSentEveryStoredUpload,
|
|
10
11
|
resolveIdempotencyKey,
|
|
11
12
|
sendQueuedMessage,
|
|
12
13
|
type AttachmentSendStatus,
|
|
@@ -162,17 +163,25 @@ export function useComposerQueue(params: UseComposerQueueParams): UseComposerQue
|
|
|
162
163
|
// Filtra por chave sobre a fila CORRENTE, não sobrescreve com `result.remainingQueue` (que foi
|
|
163
164
|
// calculado sobre a fila capturada antes do `await` e perderia item adicionado durante o envio).
|
|
164
165
|
const sentKeys = new Set(result.sentAttachmentKeys)
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
166
|
+
setQueue((current) => current.filter((item) => !sentKeys.has(attachmentKey(item))))
|
|
167
|
+
// Decide sobre os `uploadId` desta TENTATIVA (`idempotencyState.uploadIds`), nunca sobre o
|
|
168
|
+
// `setQueue` acima: o updater de `setQueue` não roda de forma síncrona (só no próximo render),
|
|
169
|
+
// então ler uma variável escrita por ele aqui sempre pegaria o valor antigo (bug real em
|
|
170
|
+
// produção — a chave nunca era descartada e o segundo envio do mesmo anexo era recusado pelo
|
|
171
|
+
// servidor como replay).
|
|
172
|
+
setAttachmentStatus((current) => {
|
|
173
|
+
const next = { ...current }
|
|
174
|
+
for (const key of sentKeys) delete next[key]
|
|
171
175
|
return next
|
|
172
176
|
})
|
|
173
|
-
|
|
177
|
+
// Descarta a chave só se ninguém a trocou por identidade desde o `await` (mesmo cuidado do
|
|
178
|
+
// retry avulso em `shouldResetRetryKey`) — senão um envio concorrente que já trocou o ref
|
|
179
|
+
// teria sua chave nova apagada por esta tentativa mais antiga.
|
|
180
|
+
if (
|
|
181
|
+
idempotencyKeyRef.current === idempotencyState &&
|
|
182
|
+
hasSentEveryStoredUpload(idempotencyState.uploadIds, result.sentAttachmentKeys)
|
|
183
|
+
) {
|
|
174
184
|
idempotencyKeyRef.current = undefined
|
|
175
|
-
setAttachmentStatus({})
|
|
176
185
|
}
|
|
177
186
|
if (draft.trim()) setDraft('')
|
|
178
187
|
await refetch()
|