@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.
@@ -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
- // Decide "tudo saiu" sobre a fila CORRENTE dentro do updater, não sobre `result.remainingQueue`
166
- // (snapshot de antes do `await` ficaria obsoleto se algo mudou a fila durante o envio).
167
- let queueEmptyAfterSend = false
168
- setQueue((current) => {
169
- const next = current.filter((item) => !sentKeys.has(attachmentKey(item)))
170
- queueEmptyAfterSend = next.length === 0
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
- if (queueEmptyAfterSend) {
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()