@adatechnology/conversations-ui 0.1.0 → 0.2.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.
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 +2166 -509
  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 +2 -2
  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 +396 -0
  29. package/src/quickReplies/quickReplyAttachments.ts +289 -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 +155 -0
  49. package/src/workspace/useComposerQueue.ts +220 -0
@@ -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.',
@@ -0,0 +1,155 @@
1
+ /**
2
+ * "Tentar de novo" de um item avulso da fila do composer, extraído de `useComposerQueue`: local
3
+ * volta por `onSendAttachments` sem legenda (o texto, se havia, já saiu); guardado vai sozinho por
4
+ * `retryStoredAttachments`, com sua própria chave de idempotência (M3) — nunca a mesma do envio do
5
+ * rascunho, porque o conjunto de `uploadId` de um retry solo é sempre outro.
6
+ *
7
+ * Guarda de troca de conversa (H2): `ConversationPane` fica montado ao trocar de conversa, então um
8
+ * retry que resolve depois da troca não pode gravar na fila/estado da conversa nova com dado da
9
+ * antiga. O id da conversa é capturado antes do primeiro `await` e comparado com
10
+ * `currentConversationIdRef` (espelhado por `useComposerQueue`) depois; troca no meio, o resultado é
11
+ * descartado. Trava por chave (não a trava global do rascunho) impede duplo clique de reenviar o
12
+ * mesmo item duas vezes.
13
+ */
14
+
15
+ import { useCallback, useRef, useState } from 'react'
16
+ import {
17
+ attachmentKey,
18
+ resolveIdempotencyKey,
19
+ resolveRetryOutcome,
20
+ retryStoredAttachments,
21
+ type AttachmentSendStatus,
22
+ type IdempotencyKeyState,
23
+ } from '../quickReplies/quickReplyAttachments'
24
+ import type { QueuedAttachment } from '../quickReplies/quickReply.types'
25
+ import type { ConversationsApi } from '../providers/types'
26
+
27
+ export type UseComposerAttachmentRetryParams = {
28
+ readonly conversationId: string
29
+ readonly currentConversationIdRef: { readonly current: string }
30
+ readonly queue: readonly QueuedAttachment[]
31
+ readonly setQueue: (updater: (current: readonly QueuedAttachment[]) => readonly QueuedAttachment[]) => void
32
+ readonly setAttachmentStatus: (
33
+ updater: (current: Record<string, AttachmentSendStatus>) => Record<string, AttachmentSendStatus>,
34
+ ) => void
35
+ readonly setSendFailure: (message: string | undefined) => void
36
+ readonly api: Pick<ConversationsApi, 'sendStoredAttachments'>
37
+ readonly onSendAttachments?: (files: readonly File[], caption: string) => Promise<void>
38
+ readonly labels: { readonly attachFailure: string }
39
+ /** Espelha o envio completo do rascunho (`useComposerQueue`): um retry avulso não pode disparar
40
+ * enquanto o composer inteiro está em voo — as duas chaves de idempotência colidiriam no mesmo item. */
41
+ readonly sendInFlightRef: { readonly current: boolean }
42
+ }
43
+
44
+ export type UseComposerAttachmentRetryResult = {
45
+ readonly retryQueuedAttachment: (item: QueuedAttachment) => void
46
+ /** Chamado por `useComposerQueue` ao trocar de conversa: descarta chave de idempotência e travas. */
47
+ readonly resetRetryState: () => void
48
+ /** Chaves com retry avulso em voo — `useComposerQueue` exclui estas de um envio completo (evita
49
+ * duplo envio do mesmo item), e a UI desabilita o botão "Tentar de novo" enquanto durar. */
50
+ readonly retryingKeys: ReadonlySet<string>
51
+ }
52
+
53
+ export function useComposerAttachmentRetry(params: UseComposerAttachmentRetryParams): UseComposerAttachmentRetryResult {
54
+ const {
55
+ conversationId,
56
+ currentConversationIdRef,
57
+ queue,
58
+ setQueue,
59
+ setAttachmentStatus,
60
+ setSendFailure,
61
+ api,
62
+ onSendAttachments,
63
+ labels,
64
+ sendInFlightRef,
65
+ } = params
66
+ const retryIdempotencyKeyRef = useRef<IdempotencyKeyState | undefined>(undefined)
67
+ /** Chaves com retry em voo — impede duplo clique de reenviar o mesmo item duas vezes. */
68
+ const retryingKeysRef = useRef<Set<string>>(new Set())
69
+ /** Espelho reativo de `retryingKeysRef` — o ref sozinho não repinta o botão desabilitado. */
70
+ const [retryingKeys, setRetryingKeys] = useState<ReadonlySet<string>>(new Set())
71
+
72
+ function markRetrying(key: string): void {
73
+ retryingKeysRef.current.add(key)
74
+ setRetryingKeys(new Set(retryingKeysRef.current))
75
+ }
76
+
77
+ function unmarkRetrying(key: string): void {
78
+ retryingKeysRef.current.delete(key)
79
+ setRetryingKeys(new Set(retryingKeysRef.current))
80
+ }
81
+
82
+ async function retryLocalAttachment(item: Extract<QueuedAttachment, { kind: 'local' }>): Promise<void> {
83
+ if (!onSendAttachments) return
84
+ const key = attachmentKey(item)
85
+ const conversationIdAtRetry = conversationId
86
+ const isSameConversation = (): boolean => currentConversationIdRef.current === conversationIdAtRetry
87
+ setAttachmentStatus((current) => ({ ...current, [key]: 'sending' }))
88
+ try {
89
+ await onSendAttachments([item.file], '')
90
+ if (!isSameConversation()) return
91
+ setAttachmentStatus((current) => ({ ...current, [key]: 'sent' }))
92
+ setQueue((current) => current.filter((queued) => attachmentKey(queued) !== key))
93
+ } catch (error: unknown) {
94
+ if (!isSameConversation()) return
95
+ setAttachmentStatus((current) => ({ ...current, [key]: 'failed' }))
96
+ setSendFailure(error instanceof Error ? error.message : labels.attachFailure)
97
+ }
98
+ }
99
+
100
+ async function retryStoredAttachment(item: Extract<QueuedAttachment, { kind: 'stored' }>): Promise<void> {
101
+ const sendStoredAttachmentsApi = api.sendStoredAttachments
102
+ if (!sendStoredAttachmentsApi) return
103
+ const conversationIdAtRetry = conversationId
104
+ const isSameConversation = (): boolean => currentConversationIdRef.current === conversationIdAtRetry
105
+ const uploadIds = [item.uploadId]
106
+ const idempotencyState = resolveIdempotencyKey(retryIdempotencyKeyRef.current, uploadIds)
107
+ retryIdempotencyKeyRef.current = idempotencyState
108
+ try {
109
+ const result = await retryStoredAttachments({
110
+ queue,
111
+ uploadIds,
112
+ idempotencyKey: idempotencyState.key,
113
+ sendStoredAttachments: (uploadParams) =>
114
+ sendStoredAttachmentsApi({ conversationId: conversationIdAtRetry, ...uploadParams }),
115
+ onAttachmentStatus: (statusKey, status) => {
116
+ if (isSameConversation()) setAttachmentStatus((current) => ({ ...current, [statusKey]: status }))
117
+ },
118
+ })
119
+ setQueue(
120
+ (current) =>
121
+ resolveRetryOutcome({
122
+ conversationIdAtRetry,
123
+ currentConversationId: currentConversationIdRef.current,
124
+ sentAttachmentKeys: result.sentAttachmentKeys,
125
+ queue: current,
126
+ }) ?? current,
127
+ )
128
+ } catch (error: unknown) {
129
+ if (isSameConversation()) setSendFailure(error instanceof Error ? error.message : labels.attachFailure)
130
+ }
131
+ }
132
+
133
+ function retryQueuedAttachment(item: QueuedAttachment): void {
134
+ // Um envio completo já está em voo — deixá-lo terminar antes de aceitar retry avulso, senão as
135
+ // duas chaves de idempotência disputam o mesmo item.
136
+ if (sendInFlightRef.current) return
137
+ const key = attachmentKey(item)
138
+ if (retryingKeysRef.current.has(key)) return
139
+ markRetrying(key)
140
+ const release = (): void => unmarkRetrying(key)
141
+ if (item.kind === 'local') {
142
+ void retryLocalAttachment(item).finally(release)
143
+ return
144
+ }
145
+ void retryStoredAttachment(item).finally(release)
146
+ }
147
+
148
+ const resetRetryState = useCallback((): void => {
149
+ retryIdempotencyKeyRef.current = undefined
150
+ retryingKeysRef.current.clear()
151
+ setRetryingKeys(new Set())
152
+ }, [])
153
+
154
+ return { retryQueuedAttachment, resetRetryState, retryingKeys }
155
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Fila de anexos do composer rico, extraída de `ConversationPane`: texto -> guardados -> locais
3
+ * (QR-34/QR-43). Reseta tudo ao trocar de conversa (H2).
4
+ */
5
+
6
+ import { useCallback, useEffect, useRef, useState } from 'react'
7
+ import {
8
+ attachmentKey,
9
+ excludeRetryingItems,
10
+ resolveIdempotencyKey,
11
+ sendQueuedMessage,
12
+ type AttachmentSendStatus,
13
+ type IdempotencyKeyState,
14
+ } from '../quickReplies/quickReplyAttachments'
15
+ import { useComposerAttachmentRetry } from './useComposerAttachmentRetry'
16
+ import type { QueuedAttachment } from '../quickReplies/quickReply.types'
17
+ import type { ConversationsApi } from '../providers/types'
18
+
19
+ export type UseComposerQueueParams = {
20
+ readonly conversationId: string
21
+ readonly draft: string
22
+ readonly setDraft: (value: string) => void
23
+ readonly api: Pick<ConversationsApi, 'sendMessage' | 'sendStoredAttachments'>
24
+ readonly labels: { readonly sendFailure: string; readonly attachFailure: string }
25
+ readonly onSendAttachments?: (files: readonly File[], caption: string) => Promise<void>
26
+ readonly refetch: () => Promise<void>
27
+ readonly setSendFailure: (message: string | undefined) => void
28
+ }
29
+
30
+ export type UseComposerQueueResult = {
31
+ readonly queue: readonly QueuedAttachment[]
32
+ readonly enqueueAttachments: (items: readonly QueuedAttachment[]) => void
33
+ readonly attachmentStatus: Record<string, AttachmentSendStatus>
34
+ readonly isSendingDraft: boolean
35
+ readonly handleRichSend: () => Promise<void>
36
+ readonly removeQueuedAttachment: (item: QueuedAttachment) => void
37
+ readonly retryQueuedAttachment: (item: QueuedAttachment) => void
38
+ /** Chaves com retry avulso em voo — a UI desabilita o botão "Tentar de novo" desses itens. */
39
+ readonly retryingKeys: ReadonlySet<string>
40
+ }
41
+
42
+ export function useComposerQueue(params: UseComposerQueueParams): UseComposerQueueResult {
43
+ const { conversationId, draft, setDraft, api, labels, onSendAttachments, refetch, setSendFailure } = params
44
+
45
+ const [queue, setQueue] = useState<readonly QueuedAttachment[]>([])
46
+ const [attachmentStatus, setAttachmentStatus] = useState<Record<string, AttachmentSendStatus>>({})
47
+ const [isSendingDraft, setIsSendingDraft] = useState(false)
48
+ /** Ref, não estado: entre dois cliques seguidos o React ainda não teria repintado a trava. */
49
+ const sendInFlightRef = useRef(false)
50
+ /** Uma por conjunto de `uploadId` guardado em voo (QR-38, M3); muda, `resolveIdempotencyKey` troca. */
51
+ const idempotencyKeyRef = useRef<IdempotencyKeyState | undefined>(undefined)
52
+ /** Espelha `conversationId` sem esperar o repaint (H2) — lido depois do `await` de um envio. */
53
+ const currentConversationIdRef = useRef(conversationId)
54
+
55
+ const { retryQueuedAttachment, resetRetryState, retryingKeys } = useComposerAttachmentRetry({
56
+ conversationId,
57
+ currentConversationIdRef,
58
+ queue,
59
+ setQueue,
60
+ setAttachmentStatus,
61
+ setSendFailure,
62
+ api,
63
+ labels,
64
+ sendInFlightRef,
65
+ ...(onSendAttachments ? { onSendAttachments } : {}),
66
+ })
67
+
68
+ // Trocar de conversa abandona o envio em andamento — senão a resposta tardia reabilitaria o
69
+ // composer errado ou reusaria a chave de idempotência de outra thread.
70
+ useEffect(() => {
71
+ currentConversationIdRef.current = conversationId
72
+ setQueue([])
73
+ setAttachmentStatus({})
74
+ idempotencyKeyRef.current = undefined
75
+ sendInFlightRef.current = false
76
+ setIsSendingDraft(false)
77
+ resetRetryState()
78
+ }, [conversationId, resetRetryState])
79
+
80
+ const enqueueAttachments = useCallback((items: readonly QueuedAttachment[]) => {
81
+ if (items.length === 0) return
82
+ setQueue((current) => [...current, ...items])
83
+ }, [])
84
+
85
+ const removeQueuedAttachment = useCallback((item: QueuedAttachment) => {
86
+ setQueue((current) => current.filter((queued) => attachmentKey(queued) !== attachmentKey(item)))
87
+ }, [])
88
+
89
+ /** Devolve se o envio passou: limpar o rascunho depois de uma falha apagaria o texto do operador. */
90
+ async function runSend(action: () => Promise<unknown>, fallback: string): Promise<boolean> {
91
+ setSendFailure(undefined)
92
+ try {
93
+ await action()
94
+ await refetch()
95
+ return true
96
+ } catch (error: unknown) {
97
+ setSendFailure(error instanceof Error ? error.message : fallback)
98
+ return false
99
+ }
100
+ }
101
+
102
+ /** Fila só com `local`, como antes da feature de guardados: uma chamada só, rascunho como legenda. */
103
+ async function sendLocalOnlyAttachments(isSameConversation: () => boolean): Promise<void> {
104
+ if (!onSendAttachments) return
105
+ const files = excludeRetryingItems(queue, retryingKeys)
106
+ .filter((item): item is Extract<QueuedAttachment, { kind: 'local' }> => item.kind === 'local')
107
+ .map((item) => item.file)
108
+ try {
109
+ const didSend = await runSend(() => onSendAttachments(files, draft), labels.attachFailure)
110
+ if (!isSameConversation()) return
111
+ if (didSend) {
112
+ // Preserva item com retry avulso em voo (excluído deste envio) — ele ainda não foi resolvido.
113
+ setQueue((current) => current.filter((item) => retryingKeys.has(attachmentKey(item))))
114
+ setAttachmentStatus((current) =>
115
+ Object.fromEntries(Object.entries(current).filter(([key]) => retryingKeys.has(key))),
116
+ )
117
+ idempotencyKeyRef.current = undefined
118
+ setDraft('')
119
+ }
120
+ } finally {
121
+ if (isSameConversation()) {
122
+ sendInFlightRef.current = false
123
+ setIsSendingDraft(false)
124
+ }
125
+ }
126
+ }
127
+
128
+ /** Texto -> guardados (lote, um resultado por arquivo) -> locais. Só o que não saiu continua na fila. */
129
+ async function sendQueuedDraft(isSameConversation: () => boolean): Promise<void> {
130
+ const sendStoredAttachmentsApi = api.sendStoredAttachments
131
+ // Item com retry avulso em voo fica de fora deste envio (chave de idempotência diferente) —
132
+ // senão o servidor recebe o mesmo anexo em dois lotes sem jeito de deduplicar.
133
+ const sendableQueue = excludeRetryingItems(queue, retryingKeys)
134
+ const storedUploadIds = sendableQueue
135
+ .filter((item): item is Extract<QueuedAttachment, { kind: 'stored' }> => item.kind === 'stored')
136
+ .map((item) => item.uploadId)
137
+ const idempotencyState = resolveIdempotencyKey(idempotencyKeyRef.current, storedUploadIds)
138
+ idempotencyKeyRef.current = idempotencyState
139
+ try {
140
+ const result = await sendQueuedMessage({
141
+ text: draft,
142
+ queue: sendableQueue,
143
+ idempotencyKey: idempotencyState.key,
144
+ sendText: (text) => runSend(() => api.sendMessage(conversationId, text), labels.sendFailure),
145
+ ...(sendStoredAttachmentsApi
146
+ ? {
147
+ sendStoredAttachments: (uploadParams: { uploadIds: readonly string[]; idempotencyKey: string }) =>
148
+ sendStoredAttachmentsApi({ conversationId, ...uploadParams }),
149
+ }
150
+ : {}),
151
+ ...(onSendAttachments
152
+ ? { sendLocalAttachments: (files: readonly File[]) => onSendAttachments(files, '') }
153
+ : {}),
154
+ onAttachmentStatus: (key, status) => {
155
+ if (isSameConversation()) setAttachmentStatus((current) => ({ ...current, [key]: status }))
156
+ },
157
+ })
158
+ if (!isSameConversation()) return
159
+ // `runSend` já gravou o erro específico do texto (`setSendFailure`) — não sobrescrever com o
160
+ // rótulo genérico.
161
+ if (!result.textSent) return
162
+ // Filtra por chave sobre a fila CORRENTE, não sobrescreve com `result.remainingQueue` (que foi
163
+ // calculado sobre a fila capturada antes do `await` e perderia item adicionado durante o envio).
164
+ 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
171
+ return next
172
+ })
173
+ if (queueEmptyAfterSend) {
174
+ idempotencyKeyRef.current = undefined
175
+ setAttachmentStatus({})
176
+ }
177
+ if (draft.trim()) setDraft('')
178
+ await refetch()
179
+ } catch (error: unknown) {
180
+ if (isSameConversation()) setSendFailure(error instanceof Error ? error.message : labels.attachFailure)
181
+ } finally {
182
+ if (isSameConversation()) {
183
+ sendInFlightRef.current = false
184
+ setIsSendingDraft(false)
185
+ }
186
+ }
187
+ }
188
+
189
+ /** Texto primeiro; se falhar, nenhum anexo sai (QR-34, QR-43). Só o que não saiu continua na fila. */
190
+ async function handleRichSend(): Promise<void> {
191
+ // Trava contra clique duplo / Enter impaciente enquanto o upload está em voo.
192
+ if (sendInFlightRef.current) return
193
+ if (!draft.trim() && queue.length === 0) return
194
+ sendInFlightRef.current = true
195
+ setIsSendingDraft(true)
196
+ setSendFailure(undefined)
197
+ // Capturado antes do primeiro `await` (H2) — ver `currentConversationIdRef`.
198
+ const conversationIdAtSend = conversationId
199
+ const isSameConversation = (): boolean => currentConversationIdRef.current === conversationIdAtSend
200
+
201
+ // QR-32/QR-33: só `stored` (mensagem pronta) vai pelo pipeline novo; só `local` fica no antigo.
202
+ const hasStoredItems = queue.some((item) => item.kind === 'stored')
203
+ if (onSendAttachments && queue.length > 0 && !hasStoredItems) {
204
+ await sendLocalOnlyAttachments(isSameConversation)
205
+ return
206
+ }
207
+ await sendQueuedDraft(isSameConversation)
208
+ }
209
+
210
+ return {
211
+ queue,
212
+ enqueueAttachments,
213
+ attachmentStatus,
214
+ isSendingDraft,
215
+ handleRichSend,
216
+ removeQueuedAttachment,
217
+ retryQueuedAttachment,
218
+ retryingKeys,
219
+ }
220
+ }