@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
@@ -5,7 +5,7 @@
5
5
  * última mensagem, outro engolia falha de anexo, outro não abria a biblioteca de arquivos.
6
6
  */
7
7
 
8
- import { useEffect, useRef, useState, type ReactNode } from 'react'
8
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
9
9
 
10
10
  import { AudioRecorderButton } from '../AudioRecorderButton'
11
11
  import { ConversationContextPanel, type ConversationContextEntry } from '../ConversationContextPanel'
@@ -18,11 +18,7 @@ import { MessageComposer, applyQuickReplyVariables, type QuickReply } from '../M
18
18
  import { RichMessageComposer, type RichComposerVariable } from '../RichMessageComposer'
19
19
  import { WindowExpiredNotice, isWindowBlocking } from '../WindowExpiredNotice'
20
20
  import { windowOf } from '../conversationWindow'
21
- import {
22
- buildTranscriptFilename,
23
- buildTranscriptText,
24
- downloadTextFile,
25
- } from '../conversationTranscript'
21
+ import { buildTranscriptFilename, buildTranscriptText, downloadTextFile } from '../conversationTranscript'
26
22
  import { useConversationContext } from '../hooks/useConversationContext'
27
23
  import { useConversationMessages } from '../hooks/useConversationMessages'
28
24
  import { useConversationRealtime } from '../hooks/useConversationRealtime'
@@ -30,6 +26,15 @@ import { useScrollToLatestMessage } from '../hooks/useScrollToLatestMessage'
30
26
  import { useConversations } from '../providers/ConversationsProvider'
31
27
  import type { ConversationSummary } from '../providers/types'
32
28
  import type { ConversationsWorkspaceLabels } from './labels'
29
+ import type {
30
+ ConversationVariable,
31
+ QueuedAttachment,
32
+ QuickReply as SavedQuickReply,
33
+ } from '../quickReplies/quickReply.types'
34
+ import { queuedAttachmentsFromQuickReply } from '../quickReplies/quickReplyAttachments'
35
+ import { resolveConversationVariables } from '../quickReplies/resolveConversationVariables'
36
+ import { QueuedAttachmentsList } from './QueuedAttachmentsList'
37
+ import { useComposerQueue } from './useComposerQueue'
33
38
 
34
39
  export interface ConversationPaneProps {
35
40
  readonly conversation: ConversationSummary
@@ -49,6 +54,7 @@ export interface ConversationPaneProps {
49
54
  * Recebe o contexto junto porque o dado que interessa à variável (o nome que o bot perguntou,
50
55
  * por exemplo) vive no contexto do fluxo, não no resumo da listagem.
51
56
  */
57
+ /** @deprecated Use `conversationVariablesFor`, que alimenta os dois composers com uma lista só. */
52
58
  readonly quickReplyVariablesFor?: (
53
59
  conversation: ConversationSummary,
54
60
  context: Record<string, unknown> | undefined,
@@ -87,11 +93,22 @@ export interface ConversationPaneProps {
87
93
  * é menos coisa na tela.
88
94
  */
89
95
  readonly composer?: 'simple' | 'rich'
90
- /** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
96
+ /**
97
+ * Valores que o operador insere sem digitar. Só o composer `rich` os oferece.
98
+ * @deprecated Use `conversationVariablesFor`.
99
+ */
91
100
  readonly composerVariablesFor?: (
92
101
  conversation: ConversationSummary,
93
102
  context: Record<string, unknown> | undefined,
94
103
  ) => readonly RichComposerVariable[]
104
+ /**
105
+ * Dados da conversa que o texto pode citar, numa lista só. Presente, manda sobre
106
+ * `quickReplyVariablesFor` e `composerVariablesFor`.
107
+ */
108
+ readonly conversationVariablesFor?: (
109
+ conversation: ConversationSummary,
110
+ context: Record<string, unknown> | undefined,
111
+ ) => readonly ConversationVariable[]
95
112
  /**
96
113
  * Fila de anexos com legenda, como no WhatsApp: os arquivos escolhidos ficam visíveis acima da
97
114
  * barra e saem junto com o texto escrito. Ausente, o clipe manda cada arquivo na hora — o que
@@ -124,6 +141,7 @@ export function ConversationPane({
124
141
  onAttach,
125
142
  composer = 'simple',
126
143
  composerVariablesFor,
144
+ conversationVariablesFor,
127
145
  onSendAttachments,
128
146
  onRecordAudio,
129
147
  }: ConversationPaneProps) {
@@ -139,17 +157,51 @@ export function ConversationPane({
139
157
  const [sendFailure, setSendFailure] = useState<string | undefined>(undefined)
140
158
  const [selectedMessageIds, setSelectedMessageIds] = useState<ReadonlySet<string>>(new Set())
141
159
  const [draft, setDraft] = useState(initialComposerText ?? '')
142
- const [queuedFiles, setQueuedFiles] = useState<readonly File[]>([])
143
- const [isSendingDraft, setIsSendingDraft] = useState(false)
144
- /** Ref, não estado: entre dois cliques seguidos o React ainda não teria repintado a trava. */
145
- const sendInFlightRef = useRef(false)
146
160
 
147
- // Trocar de conversa zera as duas coisas: seleção de mensagem e rascunho pertencem à thread, e
148
- // levá-los adiante faria copiar o trecho errado ou responder ao cliente errado.
161
+ const {
162
+ queue,
163
+ enqueueAttachments,
164
+ attachmentStatus,
165
+ isSendingDraft,
166
+ handleRichSend,
167
+ removeQueuedAttachment,
168
+ retryQueuedAttachment,
169
+ retryingKeys,
170
+ } = useComposerQueue({
171
+ conversationId: conversation.id,
172
+ draft,
173
+ setDraft,
174
+ api,
175
+ labels: { sendFailure: labels.sendFailure, attachFailure: labels.attachFailure },
176
+ ...(onSendAttachments ? { onSendAttachments } : {}),
177
+ refetch,
178
+ setSendFailure,
179
+ })
180
+
181
+ /**
182
+ * Uma promessa por `uploadId`, nunca duas: sem o cache, cada render da lista de anexos disparava
183
+ * de novo a URL assinada do mesmo arquivo (M4) — a função abaixo é estável, mas o item pode
184
+ * remontar por causa do estado de envio.
185
+ */
186
+ const thumbnailUrlCacheRef = useRef(new Map<string, Promise<string>>())
187
+ const getQueuedAttachmentThumbnailUrl = useCallback(
188
+ (uploadId: string): Promise<string> => {
189
+ const cached = thumbnailUrlCacheRef.current.get(uploadId)
190
+ if (cached) return cached
191
+ const pending = api.getDocumentUrl(uploadId, 'inline')
192
+ thumbnailUrlCacheRef.current.set(uploadId, pending)
193
+ pending.catch(() => thumbnailUrlCacheRef.current.delete(uploadId))
194
+ return pending
195
+ },
196
+ [api],
197
+ )
198
+
199
+ // Trocar de conversa zera seleção de mensagem e rascunho — pertencem à thread, e levá-los adiante
200
+ // faria copiar o trecho errado ou responder ao cliente errado. A fila de anexos se reseta sozinha
201
+ // dentro de `useComposerQueue`, keyed pelo mesmo `conversation.id`.
149
202
  useEffect(() => {
150
203
  setSelectedMessageIds(new Set())
151
204
  setDraft(initialComposerText ?? '')
152
- setQueuedFiles([])
153
205
  }, [conversation.id, initialComposerText])
154
206
 
155
207
  function toggleMessageSelected(messageId: string): void {
@@ -218,35 +270,6 @@ export function ConversationPane({
218
270
  await runSend(() => api.sendTemplate(conversation.id, {}), labels.sendFailure)
219
271
  }
220
272
 
221
- /**
222
- * O texto escrito é a legenda do anexo, não uma segunda mensagem: no WhatsApp a foto chega com a
223
- * frase embaixo, e mandar as duas separadas invertia a ordem quando a mídia demorava a subir.
224
- */
225
- async function handleRichSend(): Promise<void> {
226
- // O upload da mídia demora e não dá retorno na tela; sem esta trava o segundo clique — ou o
227
- // Enter impaciente — mandava o mesmo arquivo outra vez.
228
- if (sendInFlightRef.current) return
229
- sendInFlightRef.current = true
230
- setIsSendingDraft(true)
231
- try {
232
- if (onSendAttachments && queuedFiles.length > 0) {
233
- const files = queuedFiles
234
- const caption = draft
235
- const didSend = await runSend(() => onSendAttachments(files, caption), labels.attachFailure)
236
- if (didSend) {
237
- setQueuedFiles([])
238
- setDraft('')
239
- }
240
- return
241
- }
242
- if (!draft.trim()) return
243
- if (await handleSend(draft)) setDraft('')
244
- } finally {
245
- sendInFlightRef.current = false
246
- setIsSendingDraft(false)
247
- }
248
- }
249
-
250
273
  async function handleAttach(file: File): Promise<void> {
251
274
  if (!onAttach) return
252
275
  await runSend(() => onAttach(file), labels.attachFailure)
@@ -264,7 +287,11 @@ export function ConversationPane({
264
287
  }
265
288
 
266
289
  const contextEntries = contextEntriesOf?.(conversationContext)
267
- const composerVariables = composerVariablesFor?.(conversation, conversationContext)
290
+ const { quickReplyVariables, composerVariables } = resolveConversationVariables({
291
+ conversationVariables: conversationVariablesFor?.(conversation, conversationContext),
292
+ quickReplyVariables: quickReplyVariablesFor?.(conversation, conversationContext),
293
+ composerVariables: composerVariablesFor?.(conversation, conversationContext),
294
+ })
268
295
  /**
269
296
  * As mesmas `quickReplies` do composer simples, com as variáveis já resolvidas — o campo rico
270
297
  * recebe texto pronto. Uma segunda lista, só de formato diferente, é como as telas divergiam.
@@ -274,9 +301,29 @@ export function ConversationPane({
274
301
  label: reply.label,
275
302
  text:
276
303
  typeof reply.text === 'string'
277
- ? applyQuickReplyVariables(reply.text, quickReplyVariablesFor?.(conversation, conversationContext) ?? {})
278
- : reply.text(quickReplyVariablesFor?.(conversation, conversationContext) ?? {}),
304
+ ? applyQuickReplyVariables(reply.text, quickReplyVariables ?? {})
305
+ : reply.text(quickReplyVariables ?? {}),
279
306
  }))
307
+ /**
308
+ * Botão de raio e atalho `/` dos dois composers. `listQuickReplies` é a capacidade — sem ela na
309
+ * porta do host, nenhum dos dois aparece, em vez de um botão que abre uma lista sempre vazia.
310
+ */
311
+ const savedQuickReplies = api.listQuickReplies
312
+ ? {
313
+ // Arrow em vez de repassar o método direto: `api.listQuickReplies` solto perde o `this` do
314
+ // objeto que o implementa, e um cliente HTTP real costuma depender dele internamente.
315
+ listQuickReplies: (params?: { search?: string }) => api.listQuickReplies!(params),
316
+ conversationId: conversation.id,
317
+ variables: quickReplyVariables,
318
+ hasAttachmentsCapability: Boolean(api.sendStoredAttachments),
319
+ // Empurra os anexos da mensagem escolhida como itens guardados (QR-32) — sem a porta, a
320
+ // linha do picker já avisou e o texto entra sozinho, sem silenciosamente perder o anexo.
321
+ onSelect: (quickReply: SavedQuickReply) => {
322
+ const attachments = queuedAttachmentsFromQuickReply(quickReply, Boolean(api.sendStoredAttachments))
323
+ enqueueAttachments(attachments)
324
+ },
325
+ }
326
+ : undefined
280
327
  const botOwnsConversation = Boolean(requireTakeoverToReply) && conversation.mode !== 'human'
281
328
 
282
329
  return (
@@ -337,10 +384,20 @@ export function ConversationPane({
337
384
  <div className="cv-workspace-selection">
338
385
  <span>{labels.messagesSelected(selectedMessageIds.size)}</span>
339
386
  <div className="cv-workspace-selection__actions">
340
- <button data-cv-tooltip={labels.bulkClear} aria-label={labels.bulkClear} type="button" onClick={() => setSelectedMessageIds(new Set())}>
387
+ <button
388
+ data-cv-tooltip={labels.bulkClear}
389
+ aria-label={labels.bulkClear}
390
+ type="button"
391
+ onClick={() => setSelectedMessageIds(new Set())}
392
+ >
341
393
  {labels.bulkClear}
342
394
  </button>
343
- <button data-cv-tooltip={labels.copySelected} aria-label={labels.copySelected} type="button" onClick={copySelectedMessages}>
395
+ <button
396
+ data-cv-tooltip={labels.copySelected}
397
+ aria-label={labels.copySelected}
398
+ type="button"
399
+ onClick={copySelectedMessages}
400
+ >
344
401
  {labels.copySelected}
345
402
  </button>
346
403
  </div>
@@ -354,10 +411,7 @@ export function ConversationPane({
354
411
  ) : null}
355
412
 
356
413
  {blocked ? (
357
- <WindowExpiredNotice
358
- disabled={busy}
359
- onSendTemplate={() => void handleSendTemplate()}
360
- />
414
+ <WindowExpiredNotice disabled={busy} onSendTemplate={() => void handleSendTemplate()} />
361
415
  ) : botOwnsConversation ? (
362
416
  // Responder com a conversa no bot atropelaria o fluxo automático no meio de uma pergunta.
363
417
  <p className="cv-workspace-notice">{labels.takeoverToReply}</p>
@@ -367,7 +421,14 @@ export function ConversationPane({
367
421
  onChange={setDraft}
368
422
  onSend={() => void handleRichSend()}
369
423
  {...(onSendAttachments
370
- ? { onAttachFiles: (files: FileList) => setQueuedFiles((current) => [...current, ...Array.from(files)]) }
424
+ ? {
425
+ onAttachFiles: (files: FileList) =>
426
+ enqueueAttachments(
427
+ Array.from(files).map(
428
+ (file): QueuedAttachment => ({ kind: 'local', localId: crypto.randomUUID(), file }),
429
+ ),
430
+ ),
431
+ }
371
432
  : onAttach
372
433
  ? {
373
434
  onAttachFiles: (files: FileList) => {
@@ -377,7 +438,7 @@ export function ConversationPane({
377
438
  : {})}
378
439
  placeholder={labels.composerPlaceholder}
379
440
  isSending={busy || isSendingDraft}
380
- hasQueuedAttachments={queuedFiles.length > 0}
441
+ hasQueuedAttachments={queue.length > 0}
381
442
  {...(onRecordAudio
382
443
  ? {
383
444
  idleAction: (
@@ -388,31 +449,33 @@ export function ConversationPane({
388
449
  ),
389
450
  }
390
451
  : {})}
391
- {...(queuedFiles.length > 0
452
+ {...(queue.length > 0
392
453
  ? {
393
454
  attachmentsPreview: (
394
- <ul className="cv-workspace-attachments">
395
- {queuedFiles.map((file, index) => (
396
- <li key={`${file.name}-${index}`}>
397
- <span>{file.name}</span>
398
- <button
399
- data-cv-tooltip={labels.attachmentRemove}
400
- type="button"
401
- aria-label={labels.attachmentRemove}
402
- onClick={() =>
403
- setQueuedFiles((current) => current.filter((_, position) => position !== index))
404
- }
405
- >
406
-
407
- </button>
408
- </li>
409
- ))}
410
- </ul>
455
+ <QueuedAttachmentsList
456
+ items={queue}
457
+ statusOf={(key) => attachmentStatus[key] ?? 'waiting'}
458
+ onRemove={removeQueuedAttachment}
459
+ onRetry={retryQueuedAttachment}
460
+ retryingKeys={retryingKeys}
461
+ getThumbnailUrl={getQueuedAttachmentThumbnailUrl}
462
+ busy={isSendingDraft}
463
+ labels={{
464
+ remove: labels.attachmentRemove,
465
+ waiting: labels.attachmentWaiting,
466
+ sending: labels.attachmentSending,
467
+ sent: labels.attachmentSent,
468
+ failed: labels.attachmentFailed,
469
+ skipped: labels.attachmentSkipped,
470
+ retry: labels.attachmentRetry,
471
+ }}
472
+ />
411
473
  ),
412
474
  }
413
475
  : {})}
414
476
  {...(richQuickReplies ? { quickReplies: [...richQuickReplies] } : {})}
415
477
  {...(composerVariables ? { variables: [...composerVariables] } : {})}
478
+ {...(savedQuickReplies ? { savedQuickReplies } : {})}
416
479
  />
417
480
  ) : (
418
481
  <MessageComposer
@@ -424,7 +487,8 @@ export function ConversationPane({
424
487
  {...(onAttach ? { onAttach: (file: File) => void handleAttach(file) } : {})}
425
488
  placeholder={labels.composerPlaceholder}
426
489
  {...(quickReplies ? { quickReplies } : {})}
427
- {...(quickReplyVariablesFor ? { quickReplyVariables: quickReplyVariablesFor(conversation, conversationContext) } : {})}
490
+ {...(quickReplyVariables ? { quickReplyVariables } : {})}
491
+ {...(savedQuickReplies ? { savedQuickReplies } : {})}
428
492
  />
429
493
  )}
430
494
  </div>
@@ -39,6 +39,7 @@ import { ConversationsInboxList } from './ConversationsInboxList'
39
39
  import { DEFAULT_CONVERSATIONS_WORKSPACE_LABELS, type ConversationsWorkspaceLabels } from './labels'
40
40
  import { useConversationsInbox, type UseConversationsInboxResult } from './useConversationsInbox'
41
41
  import { TooltipLayer } from '../Tooltip'
42
+ import type { ConversationVariable } from '../quickReplies/quickReply.types'
42
43
 
43
44
  export type SimulatorTransportParams = {
44
45
  readonly conversationId: string
@@ -98,6 +99,7 @@ export interface ConversationsWorkspaceProps {
98
99
  readonly initialWhatsappNumber?: string | undefined
99
100
  readonly simulator?: ConversationsWorkspaceSimulator
100
101
  readonly quickReplies?: readonly QuickReply[]
102
+ /** @deprecated Use `conversationVariablesFor`, que alimenta os dois composers com uma lista só. */
101
103
  readonly quickReplyVariablesFor?: (
102
104
  conversation: ConversationSummary,
103
105
  context: Record<string, unknown> | undefined,
@@ -114,11 +116,22 @@ export interface ConversationsWorkspaceProps {
114
116
  readonly initialComposerText?: string | undefined
115
117
  /** `rich` troca o campo simples pelo texto com a formatação do WhatsApp desenhada ao escrever. */
116
118
  readonly composer?: 'simple' | 'rich'
117
- /** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
119
+ /**
120
+ * Valores que o operador insere sem digitar. Só o composer `rich` os oferece.
121
+ * @deprecated Use `conversationVariablesFor`.
122
+ */
118
123
  readonly composerVariablesFor?: (
119
124
  conversation: ConversationSummary,
120
125
  context: Record<string, unknown> | undefined,
121
126
  ) => readonly RichComposerVariable[]
127
+ /**
128
+ * Dados da conversa que o texto pode citar, numa lista só. Presente, manda sobre
129
+ * `quickReplyVariablesFor` e `composerVariablesFor`.
130
+ */
131
+ readonly conversationVariablesFor?: (
132
+ conversation: ConversationSummary,
133
+ context: Record<string, unknown> | undefined,
134
+ ) => readonly ConversationVariable[]
122
135
  /** Fila de anexos com legenda, como no WhatsApp. Ausente, o clipe manda cada arquivo na hora. */
123
136
  readonly onSendAttachments?: (
124
137
  conversation: ConversationSummary,
@@ -162,6 +175,7 @@ export function ConversationsWorkspace({
162
175
  initialComposerText,
163
176
  composer,
164
177
  composerVariablesFor,
178
+ conversationVariablesFor,
165
179
  onSendAttachments,
166
180
  onRecordAudio,
167
181
  contextEntriesOf,
@@ -360,6 +374,7 @@ export function ConversationsWorkspace({
360
374
  {...(initialComposerText ? { initialComposerText } : {})}
361
375
  {...(composer ? { composer } : {})}
362
376
  {...(composerVariablesFor ? { composerVariablesFor } : {})}
377
+ {...(conversationVariablesFor ? { conversationVariablesFor } : {})}
363
378
  {...(onSendAttachments
364
379
  ? {
365
380
  onSendAttachments: (files: readonly File[], caption: string) =>
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { departedItemsOf } from './QueuedAttachmentsList'
4
+ import type { QueuedAttachment } from '../quickReplies/quickReply.types'
5
+
6
+ function storedItem(uploadId: string): QueuedAttachment {
7
+ return { kind: 'stored', uploadId, filename: `${uploadId}.pdf`, mimeType: 'application/pdf', sizeBytes: 100 }
8
+ }
9
+
10
+ describe('departedItemsOf', () => {
11
+ it('não aponta saída quando a fila não muda', () => {
12
+ const items = [storedItem('a'), storedItem('b')]
13
+ expect(departedItemsOf(items, items)).toEqual([])
14
+ })
15
+
16
+ it('aponta só o item que saiu da fila', () => {
17
+ const previouslyRendered = [storedItem('a'), storedItem('b')]
18
+ const items = [storedItem('a')]
19
+ expect(departedItemsOf(previouslyRendered, items)).toEqual([storedItem('b')])
20
+ })
21
+
22
+ it('não aponta saída quando um item entra na fila', () => {
23
+ const previouslyRendered = [storedItem('a')]
24
+ const items = [storedItem('a'), storedItem('b')]
25
+ expect(departedItemsOf(previouslyRendered, items)).toEqual([])
26
+ })
27
+ })
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Fila de anexos do composer — itens `local` (ainda não subiram) e `stored` (mensagem pronta,
3
+ * enviados por referência). Mostra nome, tipo, tamanho, miniatura de imagem sob demanda e o estado
4
+ * de envio de cada item (QR-32, QR-33, QR-47).
5
+ */
6
+
7
+ import { useEffect, useRef, useState } from 'react'
8
+
9
+ import { formatFileSize } from '../lib/format'
10
+ import { attachmentKey, type AttachmentSendStatus } from '../quickReplies/quickReplyAttachments'
11
+ import type { QueuedAttachment } from '../quickReplies/quickReply.types'
12
+
13
+ export type QueuedAttachmentsListLabels = {
14
+ readonly remove: string
15
+ readonly waiting: string
16
+ readonly sending: string
17
+ readonly sent: string
18
+ readonly failed: string
19
+ readonly skipped: string
20
+ readonly retry: string
21
+ }
22
+
23
+ export type QueuedAttachmentsListProps = {
24
+ readonly items: readonly QueuedAttachment[]
25
+ readonly statusOf: (key: string) => AttachmentSendStatus
26
+ readonly onRemove: (item: QueuedAttachment) => void
27
+ readonly onRetry?: (item: QueuedAttachment) => void
28
+ /** Chaves com retry avulso em voo — desabilita o botão "Tentar de novo" desse item específico. */
29
+ readonly retryingKeys?: ReadonlySet<string>
30
+ readonly getThumbnailUrl?: (uploadId: string) => Promise<string>
31
+ readonly labels: QueuedAttachmentsListLabels
32
+ readonly busy: boolean
33
+ }
34
+
35
+ function nameOf(item: QueuedAttachment): string {
36
+ return item.kind === 'local' ? item.file.name : item.filename
37
+ }
38
+
39
+ function mimeTypeOf(item: QueuedAttachment): string {
40
+ return item.kind === 'local' ? item.file.type : item.mimeType
41
+ }
42
+
43
+ function sizeOf(item: QueuedAttachment): number {
44
+ return item.kind === 'local' ? item.file.size : item.sizeBytes
45
+ }
46
+
47
+ /** Espaço reservado do tamanho final enquanto a miniatura carrega — sem pulo de layout (QR-47). */
48
+ function AttachmentThumbnail({
49
+ item,
50
+ getThumbnailUrl,
51
+ }: {
52
+ readonly item: QueuedAttachment
53
+ readonly getThumbnailUrl?: (uploadId: string) => Promise<string>
54
+ }) {
55
+ const isImage = mimeTypeOf(item).startsWith('image/')
56
+ const [url, setUrl] = useState<string | undefined>(item.kind === 'local' ? undefined : item.previewUrl)
57
+ const localFile = item.kind === 'local' ? item.file : undefined
58
+ const uploadId = item.kind === 'stored' ? item.uploadId : undefined
59
+
60
+ // Só o `File` decide a URL de objeto local: incluir `item` inteiro (H4) recriava e revogava a URL
61
+ // a cada render em que a identidade do objeto da fila mudasse por outro motivo (status, por ex.).
62
+ useEffect(() => {
63
+ if (!isImage || !localFile) return
64
+ const objectUrl = URL.createObjectURL(localFile)
65
+ setUrl(objectUrl)
66
+ return () => URL.revokeObjectURL(objectUrl)
67
+ }, [isImage, localFile])
68
+
69
+ // `url` fica de fora do array por propósito: é este efeito que o define via `setUrl`, incluí-lo
70
+ // reexecutaria a busca a cada resolução. Só `uploadId` reinicia a busca da miniatura remota.
71
+ useEffect(() => {
72
+ if (!isImage || !uploadId || !getThumbnailUrl) return
73
+ let cancelled = false
74
+ getThumbnailUrl(uploadId)
75
+ .then((resolved) => {
76
+ if (!cancelled) setUrl(resolved)
77
+ })
78
+ .catch(() => undefined)
79
+ return () => {
80
+ cancelled = true
81
+ }
82
+ }, [isImage, uploadId, getThumbnailUrl])
83
+
84
+ if (!isImage) return null
85
+ return (
86
+ <span className="cv-attachment-item__thumbnail" aria-hidden="true">
87
+ {url ? <img src={url} alt="" /> : null}
88
+ </span>
89
+ )
90
+ }
91
+
92
+ /**
93
+ * Parte pura da transição: quais itens saíram da fila entre a última lista renderizada e a nova.
94
+ * Separada de `useDepartingItems` para ser testável sem montar um componente.
95
+ */
96
+ export function departedItemsOf(
97
+ previouslyRendered: readonly QueuedAttachment[],
98
+ items: readonly QueuedAttachment[],
99
+ ): readonly QueuedAttachment[] {
100
+ const currentKeys = new Set(items.map(attachmentKey))
101
+ return previouslyRendered.filter((item) => !currentKeys.has(attachmentKey(item)))
102
+ }
103
+
104
+ /** Mantém o item visível por uma transição curta depois de sair da fila (QR-47), sem travar props. */
105
+ function useDepartingItems(items: readonly QueuedAttachment[]) {
106
+ const [rendered, setRendered] = useState(items)
107
+ const [departingKeys, setDepartingKeys] = useState<ReadonlySet<string>>(new Set())
108
+ // Espelha `rendered` num ref lido dentro do efeito: assim o array de dependências fica completo
109
+ // (só `items`, que é o que deve reiniciar a transição) sem o efeito reagir à própria escrita em
110
+ // `rendered` via `setRendered`, o que recriaria o timer em loop.
111
+ const renderedRef = useRef(rendered)
112
+ renderedRef.current = rendered
113
+
114
+ useEffect(() => {
115
+ const removedItems = departedItemsOf(renderedRef.current, items)
116
+ if (removedItems.length === 0) {
117
+ setRendered(items)
118
+ return
119
+ }
120
+ setDepartingKeys(new Set(removedItems.map(attachmentKey)))
121
+ const reduceMotion =
122
+ typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
123
+ const delay = reduceMotion ? 0 : 220
124
+ const timer = setTimeout(() => {
125
+ setRendered(items)
126
+ setDepartingKeys(new Set())
127
+ }, delay)
128
+ return () => clearTimeout(timer)
129
+ }, [items])
130
+
131
+ return { rendered: departingKeys.size > 0 ? [...rendered] : items, departingKeys }
132
+ }
133
+
134
+ export function QueuedAttachmentsList({
135
+ items,
136
+ statusOf,
137
+ onRemove,
138
+ onRetry,
139
+ retryingKeys,
140
+ getThumbnailUrl,
141
+ labels,
142
+ busy,
143
+ }: QueuedAttachmentsListProps) {
144
+ const { rendered, departingKeys } = useDepartingItems(items)
145
+ if (rendered.length === 0) return null
146
+
147
+ const statusLabelOf = (status: AttachmentSendStatus): string => {
148
+ if (status === 'sending') return labels.sending
149
+ if (status === 'sent') return labels.sent
150
+ if (status === 'failed') return labels.failed
151
+ if (status === 'skipped') return labels.skipped
152
+ return labels.waiting
153
+ }
154
+
155
+ return (
156
+ <ul className="cv-workspace-attachments" aria-busy={busy} aria-live="polite">
157
+ {rendered.map((item) => {
158
+ const key = attachmentKey(item)
159
+ const status = statusOf(key)
160
+ const isDeparting = departingKeys.has(key)
161
+ return (
162
+ <li
163
+ key={key}
164
+ className={`cv-attachment-item cv-attachment-item--${status}${isDeparting ? ' cv-attachment-item--departing' : ''}`}
165
+ >
166
+ <AttachmentThumbnail item={item} getThumbnailUrl={getThumbnailUrl} />
167
+ <span className="cv-attachment-item__info">
168
+ <span className="cv-attachment-item__name">{nameOf(item)}</span>
169
+ <span className="cv-attachment-item__meta">
170
+ {formatFileSize(sizeOf(item))} · {statusLabelOf(status)}
171
+ </span>
172
+ </span>
173
+ {(status === 'failed' || status === 'skipped') && onRetry ? (
174
+ <button
175
+ type="button"
176
+ className="cv-attachment-item__retry"
177
+ disabled={busy || (retryingKeys?.has(key) ?? false)}
178
+ aria-busy={retryingKeys?.has(key) ?? false}
179
+ onClick={() => onRetry(item)}
180
+ >
181
+ {labels.retry}
182
+ </button>
183
+ ) : null}
184
+ <button
185
+ data-cv-tooltip={labels.remove}
186
+ type="button"
187
+ aria-label={labels.remove}
188
+ disabled={status === 'sending'}
189
+ onClick={() => onRemove(item)}
190
+ >
191
+
192
+ </button>
193
+ </li>
194
+ )
195
+ })}
196
+ </ul>
197
+ )
198
+ }
@@ -11,6 +11,7 @@ export { BulkTemplateModal } from './BulkTemplateModal'
11
11
  export type { BulkTemplateModalProps } from './BulkTemplateModal'
12
12
  export { ConversationsInboxList } from './ConversationsInboxList'
13
13
  export type { ConversationsInboxListProps } from './ConversationsInboxList'
14
+ export { QueuedAttachmentsList } from './QueuedAttachmentsList'
14
15
  export { useConversationsInbox, CONVERSATIONS_PER_PAGE } from './useConversationsInbox'
15
16
  export type { UseConversationsInboxParams, UseConversationsInboxResult } from './useConversationsInbox'
16
17
  export { DEFAULT_CONVERSATIONS_WORKSPACE_LABELS } from './labels'
@@ -36,6 +36,14 @@ export interface ConversationsWorkspaceLabels {
36
36
  readonly attachFailure: string
37
37
  /** Tira um arquivo da fila antes de enviar. */
38
38
  readonly attachmentRemove: string
39
+ /** Estado de cada item da fila durante o envio (QR-47). */
40
+ readonly attachmentWaiting: string
41
+ readonly attachmentSending: string
42
+ readonly attachmentSent: string
43
+ readonly attachmentFailed: string
44
+ /** Servidor parou antes de tentar este arquivo, por causa de uma falha anterior no mesmo lote. */
45
+ readonly attachmentSkipped: string
46
+ readonly attachmentRetry: string
39
47
  readonly recordFailure: string
40
48
  readonly sendFailure: string
41
49
  readonly takeoverToReply: string
@@ -76,6 +84,12 @@ export const DEFAULT_CONVERSATIONS_WORKSPACE_LABELS: ConversationsWorkspaceLabel
76
84
  composerPlaceholder: 'Responder como atendente…',
77
85
  attachFailure: 'Falha ao enviar o arquivo.',
78
86
  attachmentRemove: 'Remover anexo',
87
+ attachmentWaiting: 'Aguardando',
88
+ attachmentSending: 'Enviando…',
89
+ attachmentSent: 'Enviado ✓',
90
+ attachmentFailed: 'Falhou',
91
+ attachmentSkipped: 'Não enviado — aguardando o anterior',
92
+ attachmentRetry: 'Tentar de novo',
79
93
  recordFailure: 'Falha ao gravar o áudio.',
80
94
  sendFailure: 'Falha ao enviar a mensagem.',
81
95
  takeoverToReply: 'Assuma o atendimento para responder diretamente ao cliente.',