@adatechnology/conversations-ui 0.1.1 → 0.2.1

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.
Files changed (49) hide show
  1. package/dist/{ConversationSimulatorPanel--5fIzXWY.d.ts → ConversationSimulatorPanel-ikgmlciE.d.ts} +103 -1
  2. package/dist/{chunk-BJNRLLDO.js → chunk-NHQDQJL2.js} +769 -244
  3. package/dist/index.d.ts +304 -9
  4. package/dist/index.js +2181 -507
  5. package/dist/preview/index.d.ts +2 -2
  6. package/dist/preview/index.js +147 -1
  7. package/dist/styles.css +135 -0
  8. package/package.json +1 -1
  9. package/src/MessageComposer.test.tsx +14 -0
  10. package/src/MessageComposer.tsx +327 -73
  11. package/src/RichMessageComposer.test.tsx +22 -4
  12. package/src/RichMessageComposer.tsx +674 -370
  13. package/src/index.ts +28 -1
  14. package/src/preview/createMockConversationsApi.ts +184 -0
  15. package/src/providers/types.ts +34 -0
  16. package/src/quickReplies/AttachmentsFormSection.tsx +160 -0
  17. package/src/quickReplies/QuickRepliesPicker.test.tsx +114 -0
  18. package/src/quickReplies/QuickRepliesPicker.tsx +188 -0
  19. package/src/quickReplies/QuickRepliesWorkspace.test.tsx +32 -0
  20. package/src/quickReplies/QuickRepliesWorkspace.tsx +395 -0
  21. package/src/quickReplies/createUploadQueue.test.ts +106 -0
  22. package/src/quickReplies/createUploadQueue.ts +69 -0
  23. package/src/quickReplies/index.ts +5 -0
  24. package/src/quickReplies/labels.ts +124 -0
  25. package/src/quickReplies/quickReply.types.ts +74 -0
  26. package/src/quickReplies/quickReplyAttachmentUpload.test.ts +76 -0
  27. package/src/quickReplies/quickReplyAttachmentUpload.ts +81 -0
  28. package/src/quickReplies/quickReplyAttachments.test.ts +467 -0
  29. package/src/quickReplies/quickReplyAttachments.ts +328 -0
  30. package/src/quickReplies/quickReplySearch.test.ts +88 -0
  31. package/src/quickReplies/quickReplySearch.ts +104 -0
  32. package/src/quickReplies/quickReplyShortcut.test.ts +38 -0
  33. package/src/quickReplies/quickReplyShortcut.ts +46 -0
  34. package/src/quickReplies/resolveConversationVariables.test.ts +63 -0
  35. package/src/quickReplies/resolveConversationVariables.ts +41 -0
  36. package/src/quickReplies/useQuickRepliesPicker.test.ts +20 -0
  37. package/src/quickReplies/useQuickRepliesPicker.ts +121 -0
  38. package/src/quickReplies/useQuickRepliesWorkspace.test.ts +222 -0
  39. package/src/quickReplies/useQuickRepliesWorkspace.ts +426 -0
  40. package/src/quickReplies/useQuickReplyAttachmentUploads.ts +159 -0
  41. package/src/styles.css +87 -0
  42. package/src/workspace/ConversationPane.tsx +137 -73
  43. package/src/workspace/ConversationsWorkspace.tsx +16 -1
  44. package/src/workspace/QueuedAttachmentsList.test.ts +27 -0
  45. package/src/workspace/QueuedAttachmentsList.tsx +198 -0
  46. package/src/workspace/index.ts +1 -0
  47. package/src/workspace/labels.ts +14 -0
  48. package/src/workspace/useComposerAttachmentRetry.ts +169 -0
  49. package/src/workspace/useComposerQueue.ts +229 -0
@@ -0,0 +1,328 @@
1
+ import { QUICK_REPLY_ATTACHMENT_LIMIT } from './quickReply.types'
2
+ import type { QueuedAttachment, QuickReply, StoredAttachmentSendResult } from './quickReply.types'
3
+
4
+ /**
5
+ * Anexos de uma mensagem pronta escolhida no picker, prontos para entrar na fila como `stored`
6
+ * (QR-32). Vazio sem `hasAttachmentsCapability` — sem `sendStoredAttachments` no host, empurrar o
7
+ * item só encalharia na fila sem jeito de sair; a linha do picker já avisou disso antes do clique.
8
+ */
9
+ export function queuedAttachmentsFromQuickReply(
10
+ quickReply: Pick<QuickReply, 'attachments'>,
11
+ hasAttachmentsCapability: boolean,
12
+ ): readonly QueuedAttachment[] {
13
+ if (!hasAttachmentsCapability || !quickReply.attachments?.length) return []
14
+ return quickReply.attachments.map(
15
+ (attachment): QueuedAttachment => ({
16
+ kind: 'stored',
17
+ uploadId: attachment.uploadId,
18
+ filename: attachment.filename,
19
+ mimeType: attachment.mimeType,
20
+ sizeBytes: attachment.sizeBytes,
21
+ }),
22
+ )
23
+ }
24
+
25
+ /**
26
+ * Chave estável do item na fila — `uploadId` para guardado, `localId` gerado para local. A
27
+ * identidade do `File` (nome+tamanho+data) não bastava: duas cópias do mesmo arquivo colidiam na
28
+ * mesma chave e removê-la de uma removia as duas.
29
+ */
30
+ export function attachmentKey(item: QueuedAttachment): string {
31
+ return item.kind === 'stored' ? item.uploadId : item.localId
32
+ }
33
+
34
+ export type AttachmentSendStatus = 'waiting' | 'sending' | 'sent' | 'failed' | 'skipped'
35
+
36
+ /** Espelha os tetos da API; o host sobrescreve quando o backend dele aceita outro tamanho. */
37
+ export const DEFAULT_MAX_ATTACHMENT_SIZE_BYTES = {
38
+ document: 100 * 1024 * 1024,
39
+ image: 5 * 1024 * 1024,
40
+ audio: 16 * 1024 * 1024,
41
+ video: 16 * 1024 * 1024,
42
+ } as const
43
+
44
+ export type MaxAttachmentSizeBytes = {
45
+ readonly document: number
46
+ readonly image: number
47
+ readonly audio: number
48
+ readonly video: number
49
+ }
50
+
51
+ export type OutgoingItems = {
52
+ readonly text: string
53
+ readonly attachments: readonly QueuedAttachment[]
54
+ }
55
+
56
+ /**
57
+ * Texto primeiro, depois os guardados na ordem do cadastro, por último os locais: o cliente lê a
58
+ * explicação antes do arquivo, e o que o atendente anexou na hora é complemento do roteiro.
59
+ */
60
+ export function orderOutgoingItems(text: string, queue: readonly QueuedAttachment[]): OutgoingItems {
61
+ const stored = queue.filter((item) => item.kind === 'stored')
62
+ const local = queue.filter((item) => item.kind === 'local')
63
+ return { text, attachments: [...stored, ...local] }
64
+ }
65
+
66
+ /** Tira da fila só o que foi enviado; falha e pulado ficam para o atendente tentar de novo. */
67
+ export function applySendResults(
68
+ queue: readonly QueuedAttachment[],
69
+ results: readonly StoredAttachmentSendResult[],
70
+ ): readonly QueuedAttachment[] {
71
+ const sentUploadIds = new Set(results.filter((result) => result.status === 'sent').map((result) => result.uploadId))
72
+ return queue.filter((item) => item.kind === 'local' || !sentUploadIds.has(item.uploadId))
73
+ }
74
+
75
+ export function canAddAttachments(current: number, adding: number): boolean {
76
+ return current + adding <= QUICK_REPLY_ATTACHMENT_LIMIT
77
+ }
78
+
79
+ export function resolveMaxAttachmentSizeBytes(
80
+ mimeType: string,
81
+ limits: MaxAttachmentSizeBytes = DEFAULT_MAX_ATTACHMENT_SIZE_BYTES,
82
+ ): number {
83
+ if (mimeType.startsWith('image/')) return limits.image
84
+ if (mimeType.startsWith('audio/')) return limits.audio
85
+ if (mimeType.startsWith('video/')) return limits.video
86
+ return limits.document
87
+ }
88
+
89
+ export type IdempotencyKeyState = {
90
+ readonly key: string
91
+ /** Conjunto ORDENADO de `uploadId` desta tentativa — a chave só sobrevive enquanto for igual. */
92
+ readonly uploadIds: readonly string[]
93
+ }
94
+
95
+ /**
96
+ * Decide se a chave de idempotência de `previous` ainda serve (M3): serve quando o conjunto
97
+ * ORDENADO de `uploadIds` não mudou desde a tentativa anterior. Mudou — um anexo saiu, entrou, ou
98
+ * trocou de posição — vira uma tentativa diferente perante o backend, e precisa de chave nova; a
99
+ * mesma chave reenviaria o lote antigo como se fosse o novo (ou o servidor recusaria por conflito).
100
+ */
101
+ export function resolveIdempotencyKey(
102
+ previous: IdempotencyKeyState | undefined,
103
+ uploadIds: readonly string[],
104
+ generateKey: () => string = () => crypto.randomUUID(),
105
+ ): IdempotencyKeyState {
106
+ if (previous && sameUploadIds(previous.uploadIds, uploadIds)) return previous
107
+ return { key: generateKey(), uploadIds }
108
+ }
109
+
110
+ function sameUploadIds(a: readonly string[], b: readonly string[]): boolean {
111
+ if (a.length !== b.length) return false
112
+ return a.every((id, index) => id === b[index])
113
+ }
114
+
115
+ /**
116
+ * Decide se a chave de idempotência pode ser descartada depois de um envio (M3-bug): todo
117
+ * `uploadId` da tentativa saiu com sucesso. `setState` com updater NÃO roda de forma síncrona
118
+ * dentro do handler — uma variável `let` atualizada por ele e lida logo em seguida sempre lê o
119
+ * valor antigo. Esta decisão usa só os parâmetros da própria tentativa (nunca o estado da fila),
120
+ * então não depende de nenhum `setState` ter aplicado.
121
+ */
122
+ export function hasSentEveryStoredUpload(
123
+ storedUploadIds: readonly string[],
124
+ sentAttachmentKeys: readonly string[],
125
+ ): boolean {
126
+ if (storedUploadIds.length === 0) return true
127
+ const sentKeys = new Set(sentAttachmentKeys)
128
+ return storedUploadIds.every((uploadId) => sentKeys.has(uploadId))
129
+ }
130
+
131
+ export type ShouldResetRetryKeyParams = {
132
+ /** Valor corrente do ref no momento da checagem — lido depois do `await`. */
133
+ readonly current: IdempotencyKeyState | undefined
134
+ /** Estado usado NESTA tentativa, capturado antes do `await`. */
135
+ readonly attempted: IdempotencyKeyState
136
+ readonly sentAttachmentKeys: readonly string[]
137
+ readonly uploadId: string
138
+ }
139
+
140
+ /**
141
+ * Decide se a chave de idempotência do retry avulso pode ser descartada (M3-retry-bug): o item
142
+ * saiu com sucesso E ninguém trocou o ref por identidade desde então. A checagem de identidade
143
+ * (`current === attempted`) importa porque um retry avulso reusa `uploadId` de itens guardados
144
+ * (mensagens prontas) — um segundo retry do MESMO `uploadId` pode começar e sobrescrever o ref
145
+ * antes desta tentativa terminar; sem a checagem, o `undefined` desta tentativa apagaria a chave
146
+ * da tentativa mais nova, e o próximo envio dela reusaria uma chave já consumida pelo servidor.
147
+ */
148
+ export function shouldResetRetryKey(params: ShouldResetRetryKeyParams): boolean {
149
+ const { current, attempted, sentAttachmentKeys, uploadId } = params
150
+ if (!sentAttachmentKeys.includes(uploadId)) return false
151
+ return current === attempted
152
+ }
153
+
154
+ export type SendQueuedMessageParams = {
155
+ readonly text: string
156
+ readonly queue: readonly QueuedAttachment[]
157
+ /** Uma por clique, reusada em cada tentativa de reenvio do que sobrou (QR-38). */
158
+ readonly idempotencyKey: string
159
+ /** Devolve se o texto saiu — falso interrompe o pipeline antes de qualquer anexo (QR-43). */
160
+ readonly sendText: (text: string) => Promise<boolean>
161
+ /** Ausente, itens `stored` continuam na fila — o produto não sabe mandar por referência. */
162
+ readonly sendStoredAttachments?: (params: {
163
+ uploadIds: readonly string[]
164
+ idempotencyKey: string
165
+ }) => Promise<{ results: readonly StoredAttachmentSendResult[] }>
166
+ /** Ausente, itens `local` continuam na fila — o mesmo comportamento de hoje sem a porta. */
167
+ readonly sendLocalAttachments?: (files: readonly File[]) => Promise<void>
168
+ readonly onAttachmentStatus?: (key: string, status: AttachmentSendStatus) => void
169
+ }
170
+
171
+ export type SendQueuedMessageResult = {
172
+ readonly textSent: boolean
173
+ /** O que não foi enviado — falha, pulado ou sem porta — para o atendente tentar de novo. */
174
+ readonly remainingQueue: readonly QueuedAttachment[]
175
+ /** Chaves (`attachmentKey`) dos itens que saíram — para o chamador remover por chave de um estado
176
+ * corrente, em vez de sobrescrever a fila com este `remainingQueue` (calculado sobre uma fila
177
+ * capturada antes do `await`, que já pode estar desatualizada). */
178
+ readonly sentAttachmentKeys: readonly string[]
179
+ }
180
+
181
+ type SendStoredAttachmentsPort = NonNullable<SendQueuedMessageParams['sendStoredAttachments']>
182
+ type StoredQueuedAttachment = Extract<QueuedAttachment, { kind: 'stored' }>
183
+
184
+ /** Traduz o status do resultado do servidor para o status visual do item na fila. */
185
+ function attachmentSendStatusOf(status: StoredAttachmentSendResult['status']): AttachmentSendStatus {
186
+ if (status === 'sent') return 'sent'
187
+ if (status === 'skipped') return 'skipped'
188
+ return 'failed'
189
+ }
190
+
191
+ /**
192
+ * Manda um lote de itens `stored` e traduz a resposta em status por item — usado tanto pelo envio
193
+ * normal quanto pelo retry avulso (M5): guardado sem resultado no lote é tratado como falha, e o
194
+ * lote inteiro falhando (rede, 500) marca cada item como falha em vez de sumir da tela sem explicação.
195
+ * `skipped` (o servidor parou antes de tentar este arquivo, por causa de uma falha anterior no
196
+ * mesmo lote) fica distinto de `failed` — o item não falhou, só não chegou a ser tentado.
197
+ */
198
+ async function sendStoredBatch(
199
+ stored: readonly StoredQueuedAttachment[],
200
+ idempotencyKey: string,
201
+ sendStoredAttachments: SendStoredAttachmentsPort,
202
+ onAttachmentStatus?: (key: string, status: AttachmentSendStatus) => void,
203
+ ): Promise<readonly StoredAttachmentSendResult[]> {
204
+ for (const item of stored) onAttachmentStatus?.(attachmentKey(item), 'sending')
205
+ try {
206
+ const response = await sendStoredAttachments({ uploadIds: stored.map((item) => item.uploadId), idempotencyKey })
207
+ let results = response.results
208
+ const resultedUploadIds = new Set(results.map((result) => result.uploadId))
209
+ for (const item of stored) {
210
+ if (!resultedUploadIds.has(item.uploadId)) results = [...results, { uploadId: item.uploadId, status: 'failed' }]
211
+ }
212
+ for (const result of results) {
213
+ onAttachmentStatus?.(result.uploadId, attachmentSendStatusOf(result.status))
214
+ }
215
+ return results
216
+ } catch {
217
+ for (const item of stored) onAttachmentStatus?.(attachmentKey(item), 'failed')
218
+ return stored.map((item) => ({ uploadId: item.uploadId, status: 'failed' as const }))
219
+ }
220
+ }
221
+
222
+ export type RetryStoredAttachmentsParams = {
223
+ readonly queue: readonly QueuedAttachment[]
224
+ /** Só este subconjunto é reenviado — o resto da fila (texto já mandado) fica intocado. */
225
+ readonly uploadIds: readonly string[]
226
+ readonly idempotencyKey: string
227
+ readonly sendStoredAttachments: SendStoredAttachmentsPort
228
+ readonly onAttachmentStatus?: (key: string, status: AttachmentSendStatus) => void
229
+ }
230
+
231
+ export type RetryStoredAttachmentsResult = {
232
+ readonly remainingQueue: readonly QueuedAttachment[]
233
+ /** Chaves (`uploadId`) dos itens que saíram — ver `SendQueuedMessageResult.sentAttachmentKeys`. */
234
+ readonly sentAttachmentKeys: readonly string[]
235
+ }
236
+
237
+ /**
238
+ * Reenvia só os `stored` de `uploadIds` — nunca o texto do rascunho (M3): o botão "Tentar de novo"
239
+ * de um item é sobre aquele anexo, não sobre a mensagem inteira que já foi lida ou já saiu.
240
+ */
241
+ export async function retryStoredAttachments(
242
+ params: RetryStoredAttachmentsParams,
243
+ ): Promise<RetryStoredAttachmentsResult> {
244
+ const { queue, uploadIds, idempotencyKey, sendStoredAttachments, onAttachmentStatus } = params
245
+ const targetIds = new Set(uploadIds)
246
+ const targets = queue.filter(
247
+ (item): item is StoredQueuedAttachment => item.kind === 'stored' && targetIds.has(item.uploadId),
248
+ )
249
+ if (targets.length === 0) return { remainingQueue: queue, sentAttachmentKeys: [] }
250
+ const results = await sendStoredBatch(targets, idempotencyKey, sendStoredAttachments, onAttachmentStatus)
251
+ const sentAttachmentKeys = results.filter((result) => result.status === 'sent').map((result) => result.uploadId)
252
+ return { remainingQueue: applySendResults(queue, results), sentAttachmentKeys }
253
+ }
254
+
255
+ export type ResolveRetryOutcomeParams = {
256
+ readonly conversationIdAtRetry: string
257
+ readonly currentConversationId: string
258
+ readonly sentAttachmentKeys: readonly string[]
259
+ readonly queue: readonly QueuedAttachment[]
260
+ }
261
+
262
+ /**
263
+ * Decide o que gravar na fila depois de um retry avulso resolver (H2/M5): `undefined` quando a
264
+ * conversa trocou no meio do retry — o resultado é de outra thread e o chamador deve ignorá-lo,
265
+ * mantendo a fila corrente intocada. Na mesma conversa, tira só as chaves enviadas — nunca
266
+ * sobrescreve com uma fila capturada antes do `await`, que perderia item adicionado durante o envio.
267
+ */
268
+ export function resolveRetryOutcome(params: ResolveRetryOutcomeParams): readonly QueuedAttachment[] | undefined {
269
+ const { conversationIdAtRetry, currentConversationId, sentAttachmentKeys, queue } = params
270
+ if (conversationIdAtRetry !== currentConversationId) return undefined
271
+ const sentKeys = new Set(sentAttachmentKeys)
272
+ return queue.filter((item) => !sentKeys.has(attachmentKey(item)))
273
+ }
274
+
275
+ /** Tira da fila de um envio completo os itens com retry avulso em voo (M-retry-race): a chave de
276
+ * idempotência do retry é outra, e mandar o mesmo item nos dois pipelines ao mesmo tempo deixa o
277
+ * servidor sem jeito de deduplicar. */
278
+ export function excludeRetryingItems(
279
+ queue: readonly QueuedAttachment[],
280
+ retryingKeys: ReadonlySet<string>,
281
+ ): readonly QueuedAttachment[] {
282
+ if (retryingKeys.size === 0) return queue
283
+ return queue.filter((item) => !retryingKeys.has(attachmentKey(item)))
284
+ }
285
+
286
+ /**
287
+ * Orquestra QR-34/QR-37/QR-43: texto primeiro; se falhar, nada de anexo sai. Depois os `stored` (em
288
+ * lote, um resultado por arquivo) e por último os `local` (sem legenda — o texto já foi mandado).
289
+ * Pura para ser testável sem montar componente: os efeitos colaterais são só as três portas recebidas.
290
+ */
291
+ export async function sendQueuedMessage(params: SendQueuedMessageParams): Promise<SendQueuedMessageResult> {
292
+ const { text, queue, idempotencyKey, sendText, sendStoredAttachments, sendLocalAttachments, onAttachmentStatus } =
293
+ params
294
+
295
+ if (text.trim()) {
296
+ const textSent = await sendText(text)
297
+ if (!textSent) return { textSent: false, remainingQueue: queue, sentAttachmentKeys: [] }
298
+ }
299
+
300
+ const { attachments } = orderOutgoingItems(text, queue)
301
+ const stored = attachments.filter((item): item is StoredQueuedAttachment => item.kind === 'stored')
302
+ const local = attachments.filter(
303
+ (item): item is Extract<QueuedAttachment, { kind: 'local' }> => item.kind === 'local',
304
+ )
305
+
306
+ const results =
307
+ stored.length > 0 && sendStoredAttachments
308
+ ? await sendStoredBatch(stored, idempotencyKey, sendStoredAttachments, onAttachmentStatus)
309
+ : []
310
+
311
+ let remainingQueue = applySendResults(queue, results)
312
+ const sentAttachmentKeys = results.filter((result) => result.status === 'sent').map((result) => result.uploadId)
313
+
314
+ if (local.length > 0 && sendLocalAttachments) {
315
+ for (const item of local) onAttachmentStatus?.(attachmentKey(item), 'sending')
316
+ try {
317
+ await sendLocalAttachments(local.map((item) => item.file))
318
+ for (const item of local) onAttachmentStatus?.(attachmentKey(item), 'sent')
319
+ const sentKeys = new Set(local.map((item) => attachmentKey(item)))
320
+ remainingQueue = remainingQueue.filter((item) => item.kind !== 'local' || !sentKeys.has(attachmentKey(item)))
321
+ sentAttachmentKeys.push(...sentKeys)
322
+ } catch {
323
+ for (const item of local) onAttachmentStatus?.(attachmentKey(item), 'failed')
324
+ }
325
+ }
326
+
327
+ return { textSent: true, remainingQueue, sentAttachmentKeys }
328
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { filterQuickReplies, highlightMatch, normalizeForSearch } from './quickReplySearch'
4
+ import type { QuickReply } from './quickReply.types'
5
+
6
+ const GREETING: QuickReply = { id: '1', title: 'Saudação', shortcut: 'ola', body: 'Olá {{nome}}, tudo bem?' }
7
+ const DOCS: QuickReply = { id: '2', title: 'Pedido de documento', shortcut: 'doc', body: 'Envie seu RG e CPF.' }
8
+
9
+ describe('normalizeForSearch', () => {
10
+ it('remove acento e ignora caixa', () => {
11
+ expect(normalizeForSearch('Saudação')).toBe('saudacao')
12
+ expect(normalizeForSearch('VOCÊ')).toBe('voce')
13
+ })
14
+ })
15
+
16
+ describe('filterQuickReplies', () => {
17
+ it('busca sem acento no título', () => {
18
+ const result = filterQuickReplies({ quickReplies: [GREETING, DOCS], search: 'saudacao' })
19
+ expect(result).toEqual([GREETING])
20
+ })
21
+
22
+ it('busca no atalho', () => {
23
+ const result = filterQuickReplies({ quickReplies: [GREETING, DOCS], search: 'doc' })
24
+ expect(result).toEqual([DOCS])
25
+ })
26
+
27
+ it('busca no corpo já com a variável trocada', () => {
28
+ const result = filterQuickReplies({ quickReplies: [GREETING], search: 'marina', variables: { nome: 'Marina' } })
29
+ expect(result).toEqual([GREETING])
30
+ })
31
+
32
+ it('termo vazio devolve tudo', () => {
33
+ expect(filterQuickReplies({ quickReplies: [GREETING, DOCS], search: ' ' })).toEqual([GREETING, DOCS])
34
+ })
35
+
36
+ it('sem correspondência devolve lista vazia', () => {
37
+ expect(filterQuickReplies({ quickReplies: [GREETING, DOCS], search: 'boleto' })).toEqual([])
38
+ })
39
+
40
+ it('termo só de marca combinante normaliza para vazio e não filtra nada', () => {
41
+ // "́" sozinho (acento agudo combinante, sem base) normaliza para string vazia.
42
+ expect(filterQuickReplies({ quickReplies: [GREETING, DOCS], search: '́' })).toEqual([GREETING, DOCS])
43
+ })
44
+ })
45
+
46
+ describe('highlightMatch', () => {
47
+ it('destaca o trecho encontrado preservando o texto original', () => {
48
+ const segments = highlightMatch('Pedido de documento', 'documento')
49
+ expect(segments).toEqual([
50
+ { text: 'Pedido de ', isMatch: false },
51
+ { text: 'documento', isMatch: true },
52
+ ])
53
+ })
54
+
55
+ it('casa mesmo com acento diferente entre busca e texto', () => {
56
+ const segments = highlightMatch('Saudação', 'saudacao')
57
+ expect(segments.some((segment) => segment.isMatch)).toBe(true)
58
+ expect(segments.map((segment) => segment.text).join('')).toBe('Saudação')
59
+ })
60
+
61
+ it('sem termo, devolve o texto inteiro sem destaque', () => {
62
+ expect(highlightMatch('Saudação', '')).toEqual([{ text: 'Saudação', isMatch: false }])
63
+ })
64
+
65
+ it('sem correspondência, devolve um único segmento sem destaque', () => {
66
+ expect(highlightMatch('Saudação', 'boleto')).toEqual([{ text: 'Saudação', isMatch: false }])
67
+ })
68
+
69
+ it('casa e recorta certo quando o texto vem em NFD (acento como combinante separado)', () => {
70
+ const nfdText = 'José'.normalize('NFD') // "José" com o acento como caractere combinante
71
+ const segments = highlightMatch(nfdText, 'jose')
72
+ expect(segments.map((segment) => segment.text).join('')).toBe(nfdText)
73
+ expect(segments.some((segment) => segment.isMatch)).toBe(true)
74
+ })
75
+
76
+ it('casa e recorta certo quando minúscula tem mais caracteres que a maiúscula (İ)', () => {
77
+ const text = 'İstanbul'
78
+ const segments = highlightMatch(text, 'istanbul')
79
+ expect(segments.map((segment) => segment.text).join('')).toBe(text)
80
+ expect(segments.some((segment) => segment.isMatch)).toBe(true)
81
+ })
82
+
83
+ it('termo só de marca combinante normaliza para vazio — não trava em loop e devolve o texto inteiro', () => {
84
+ // Sem o guard, `indexOf('')` sempre acha posição 0 e o cursor nunca avança: loop infinito.
85
+ const segments = highlightMatch('Saudação', '́')
86
+ expect(segments).toEqual([{ text: 'Saudação', isMatch: false }])
87
+ })
88
+ })
@@ -0,0 +1,104 @@
1
+ import { applyQuickReplyVariables } from '../MessageComposer'
2
+ import type { QuickReply } from './quickReply.types'
3
+
4
+ const DIACRITIC_PATTERN = /[\u0300-\u036f]/g
5
+
6
+ export type NormalizedIndexMap = {
7
+ readonly normalized: string
8
+ /** `normalized[i]` veio do caractere original que começa em `originalIndexOf[i]` (UTF-16). */
9
+ readonly originalIndexOf: readonly number[]
10
+ }
11
+
12
+ /**
13
+ * Normaliza caractere a caractere (não a string inteira) e guarda, para cada posição do resultado,
14
+ * de qual índice do texto original ela veio. Um caractere de entrada não produz sempre um único
15
+ * caractere de saída: `'İ'.toLowerCase()` vira dois (`'i̇'`), e um acento em NFD normaliza para uma
16
+ * base sem o combinante. Sem o mapa, `highlightMatch` recorta o texto original no índice errado
17
+ * assim que a entrada tem um desses casos — foi o que quebrava com "José" em NFD e com maiúscula
18
+ * que cresce ao virar minúscula.
19
+ */
20
+ export function normalizeForSearchWithMap(value: string): NormalizedIndexMap {
21
+ let normalized = ''
22
+ const originalIndexOf: number[] = []
23
+ let originalCursor = 0
24
+ for (const char of value) {
25
+ const piece = char.normalize('NFD').replace(DIACRITIC_PATTERN, '').toLowerCase()
26
+ for (let i = 0; i < piece.length; i++) originalIndexOf.push(originalCursor)
27
+ normalized += piece
28
+ originalCursor += char.length
29
+ }
30
+ return { normalized, originalIndexOf }
31
+ }
32
+
33
+ export function normalizeForSearch(value: string): string {
34
+ return normalizeForSearchWithMap(value).normalized
35
+ }
36
+
37
+ export type FilterQuickRepliesParams = {
38
+ readonly quickReplies: readonly QuickReply[]
39
+ readonly search: string
40
+ /** Resolve `{{marcador}}` antes de comparar — buscar "joão" deve achar quem cita o nome no corpo. */
41
+ readonly variables?: Readonly<Record<string, string>>
42
+ }
43
+
44
+ /** Busca por título, atalho e corpo (com as variáveis já aplicadas). Termo vazio devolve tudo. */
45
+ export function filterQuickReplies({
46
+ quickReplies,
47
+ search,
48
+ variables,
49
+ }: FilterQuickRepliesParams): readonly QuickReply[] {
50
+ // Termo composto só de marcas combinantes (ex.: "́" sozinho) normaliza para string vazia —
51
+ // tratar como termo vazio, senão `indexOf('')` em `highlightMatch` casaria em todo índice.
52
+ const term = normalizeForSearch(search.trim())
53
+ if (!term) return quickReplies
54
+ return quickReplies.filter((quickReply) => {
55
+ const body = applyQuickReplyVariables(quickReply.body, variables)
56
+ return (
57
+ normalizeForSearch(quickReply.title).includes(term) ||
58
+ normalizeForSearch(quickReply.shortcut).includes(term) ||
59
+ normalizeForSearch(body).includes(term)
60
+ )
61
+ })
62
+ }
63
+
64
+ export type MatchSegment = {
65
+ readonly text: string
66
+ readonly isMatch: boolean
67
+ }
68
+
69
+ /**
70
+ * Segmenta o texto ao redor do termo buscado para o chamador destacar sem HTML — quem cadastrou a
71
+ * mensagem não deve conseguir injetar marcação pelo próprio corpo ou título.
72
+ */
73
+ export function highlightMatch(text: string, search: string): readonly MatchSegment[] {
74
+ const term = search.trim()
75
+ if (!term) return [{ text, isMatch: false }]
76
+ const { normalized: normalizedText, originalIndexOf } = normalizeForSearchWithMap(text)
77
+ const normalizedTerm = normalizeForSearch(term)
78
+ // Termo só de marcas combinantes (ex.: "́") normaliza para "" — `indexOf('')` sempre acha
79
+ // posição 0 e o laço abaixo nunca avança o cursor, travando a aba num loop infinito.
80
+ if (!normalizedTerm) return [{ text, isMatch: false }]
81
+ // Índice original correspondente a uma posição do texto normalizado — o comprimento do texto
82
+ // original fecha o mapa para quando a posição cai depois do último caractere normalizado.
83
+ const originalIndexAt = (normalizedIndex: number): number =>
84
+ normalizedIndex < originalIndexOf.length ? originalIndexOf[normalizedIndex]! : text.length
85
+
86
+ const segments: MatchSegment[] = []
87
+ let normalizedCursor = 0
88
+ let originalCursor = 0
89
+ while (normalizedCursor < normalizedText.length) {
90
+ const matchIndex = normalizedText.indexOf(normalizedTerm, normalizedCursor)
91
+ if (matchIndex === -1) break
92
+ const matchOriginalStart = originalIndexAt(matchIndex)
93
+ if (matchOriginalStart > originalCursor) {
94
+ segments.push({ text: text.slice(originalCursor, matchOriginalStart), isMatch: false })
95
+ }
96
+ const matchNormalizedEnd = matchIndex + normalizedTerm.length
97
+ const matchOriginalEnd = originalIndexAt(matchNormalizedEnd)
98
+ segments.push({ text: text.slice(matchOriginalStart, matchOriginalEnd), isMatch: true })
99
+ normalizedCursor = matchNormalizedEnd
100
+ originalCursor = matchOriginalEnd
101
+ }
102
+ if (originalCursor < text.length) segments.push({ text: text.slice(originalCursor), isMatch: false })
103
+ return segments
104
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { detectQuickReplyShortcut, replaceQuickReplyShortcut } from './quickReplyShortcut'
4
+
5
+ describe('detectQuickReplyShortcut', () => {
6
+ it('acha o atalho no início do texto', () => {
7
+ expect(detectQuickReplyShortcut('/doc')).toEqual({ start: 0, term: 'doc' })
8
+ })
9
+
10
+ it('acha o atalho depois de espaço', () => {
11
+ expect(detectQuickReplyShortcut('Olá, /doc')).toEqual({ start: 5, term: 'doc' })
12
+ })
13
+
14
+ it('acha o atalho vazio (só a barra, ainda sem termo)', () => {
15
+ expect(detectQuickReplyShortcut('/')).toEqual({ start: 0, term: '' })
16
+ })
17
+
18
+ it('apagar a barra some com o atalho', () => {
19
+ expect(detectQuickReplyShortcut('Olá, doc')).toBeUndefined()
20
+ })
21
+
22
+ it('barra no meio da palavra não é atalho', () => {
23
+ expect(detectQuickReplyShortcut('km/h')).toBeUndefined()
24
+ })
25
+ })
26
+
27
+ describe('replaceQuickReplyShortcut', () => {
28
+ it('troca o atalho pelo corpo resolvido e devolve onde o cursor cai', () => {
29
+ const result = replaceQuickReplyShortcut({
30
+ text: 'Olá, /doc',
31
+ start: 5,
32
+ caret: 9,
33
+ replacement: 'Envie seu RG, Marina.',
34
+ })
35
+ expect(result.text).toBe('Olá, Envie seu RG, Marina.')
36
+ expect(result.caret).toBe(5 + 'Envie seu RG, Marina.'.length)
37
+ })
38
+ })
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Detecção e inserção do atalho `/termo` — compartilhado entre `MessageComposer` e
3
+ * `RichMessageComposer` para os dois campos abrirem e fecharem o picker do mesmo jeito.
4
+ */
5
+
6
+ /** Casa `/termo` no início do texto ou depois de espaço, sempre no fim da string. */
7
+ const QUICK_REPLY_SHORTCUT_PATTERN = /(?:^|\s)\/([a-z0-9-]*)$/i
8
+
9
+ export type QuickReplyShortcutMatch = {
10
+ /** Índice do primeiro caractere da "/", para a inserção saber onde trocar. */
11
+ readonly start: number
12
+ readonly term: string
13
+ }
14
+
15
+ /** Sem correspondência (ou depois de apagar a "/"), devolve `undefined` — é o que fecha o picker. */
16
+ export function detectQuickReplyShortcut(text: string): QuickReplyShortcutMatch | undefined {
17
+ const match = QUICK_REPLY_SHORTCUT_PATTERN.exec(text)
18
+ if (!match) return undefined
19
+ return { start: match.index + (match[0].startsWith('/') ? 0 : 1), term: match[1] ?? '' }
20
+ }
21
+
22
+ export type ReplaceQuickReplyShortcutParams = {
23
+ readonly text: string
24
+ readonly start: number
25
+ readonly caret: number
26
+ readonly replacement: string
27
+ }
28
+
29
+ export type ReplaceQuickReplyShortcutResult = {
30
+ readonly text: string
31
+ /** Onde o cursor deve ficar depois — logo após o texto inserido. */
32
+ readonly caret: number
33
+ }
34
+
35
+ /** Troca o `/termo` (de `start` até `caret`) pelo corpo já resolvido. */
36
+ export function replaceQuickReplyShortcut({
37
+ text,
38
+ start,
39
+ caret,
40
+ replacement,
41
+ }: ReplaceQuickReplyShortcutParams): ReplaceQuickReplyShortcutResult {
42
+ return {
43
+ text: text.slice(0, start) + replacement + text.slice(caret),
44
+ caret: start + replacement.length,
45
+ }
46
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Guarda o adaptador entre a lista nova de variáveis e as duas props antigas. O caso que decide o
3
+ * desenho: com a lista nova presente, as antigas são ignoradas — duas fontes dariam valores
4
+ * diferentes no chip e no botão de variáveis.
5
+ */
6
+
7
+ import { describe, expect, it } from 'bun:test'
8
+
9
+ import { applyQuickReplyVariables } from '../MessageComposer'
10
+ import { resolveConversationVariables } from './resolveConversationVariables'
11
+
12
+ const CUSTOMER_NAME = { id: 'customerName', label: 'Nome', marker: '{{nome}}', value: 'Marina' }
13
+ const EMPTY_EMAIL = { id: 'email', label: 'E-mail', marker: '{{email}}', value: '' }
14
+
15
+ describe('resolveConversationVariables', () => {
16
+ it('deriva o mapa e a lista do composer a partir da lista nova', () => {
17
+ const result = resolveConversationVariables({ conversationVariables: [CUSTOMER_NAME] })
18
+ expect(result.quickReplyVariables).toEqual({ nome: 'Marina' })
19
+ expect(result.composerVariables).toEqual([{ id: 'customerName', label: 'Nome', value: 'Marina' }])
20
+ })
21
+
22
+ it('não oferece variável sem valor', () => {
23
+ const result = resolveConversationVariables({ conversationVariables: [CUSTOMER_NAME, EMPTY_EMAIL] })
24
+ expect(result.quickReplyVariables).toEqual({ nome: 'Marina' })
25
+ expect(result.composerVariables).toHaveLength(1)
26
+ })
27
+
28
+ it('a lista nova manda sobre as props antigas', () => {
29
+ const result = resolveConversationVariables({
30
+ conversationVariables: [CUSTOMER_NAME],
31
+ quickReplyVariables: { nome: 'Outro' },
32
+ composerVariables: [{ id: 'x', label: 'X', value: 'y' }],
33
+ })
34
+ expect(result.quickReplyVariables).toEqual({ nome: 'Marina' })
35
+ expect(result.composerVariables).toEqual([{ id: 'customerName', label: 'Nome', value: 'Marina' }])
36
+ })
37
+
38
+ it('sem a lista nova, devolve as props antigas intactas', () => {
39
+ const quickReplyVariables = { nome: 'Marina' }
40
+ const composerVariables = [{ id: 'x', label: 'X', value: 'y' }]
41
+ const result = resolveConversationVariables({ quickReplyVariables, composerVariables })
42
+ expect(result.quickReplyVariables).toBe(quickReplyVariables)
43
+ expect(result.composerVariables).toBe(composerVariables)
44
+ })
45
+
46
+ it('sem nada, devolve ausência', () => {
47
+ expect(resolveConversationVariables({})).toEqual({ quickReplyVariables: undefined, composerVariables: undefined })
48
+ })
49
+
50
+ it('marcador fora do formato cai no id', () => {
51
+ const result = resolveConversationVariables({
52
+ conversationVariables: [{ ...CUSTOMER_NAME, marker: 'nome' }],
53
+ })
54
+ expect(result.quickReplyVariables).toEqual({ customerName: 'Marina' })
55
+ })
56
+
57
+ it('o mapa derivado alimenta applyQuickReplyVariables sem mudança', () => {
58
+ const { quickReplyVariables } = resolveConversationVariables({
59
+ conversationVariables: [CUSTOMER_NAME, EMPTY_EMAIL],
60
+ })
61
+ expect(applyQuickReplyVariables('Olá {{nome}}, {{email}}', quickReplyVariables)).toBe('Olá Marina, ')
62
+ })
63
+ })