@adatechnology/conversations-ui 0.1.1 → 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.
- package/dist/{ConversationSimulatorPanel--5fIzXWY.d.ts → ConversationSimulatorPanel-ikgmlciE.d.ts} +103 -1
- package/dist/{chunk-BJNRLLDO.js → chunk-NHQDQJL2.js} +769 -244
- package/dist/index.d.ts +304 -9
- package/dist/index.js +2166 -509
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +147 -1
- package/dist/styles.css +135 -0
- package/package.json +1 -1
- package/src/MessageComposer.test.tsx +14 -0
- package/src/MessageComposer.tsx +327 -73
- package/src/RichMessageComposer.test.tsx +22 -4
- package/src/RichMessageComposer.tsx +674 -370
- package/src/index.ts +28 -1
- package/src/preview/createMockConversationsApi.ts +184 -0
- package/src/providers/types.ts +34 -0
- package/src/quickReplies/AttachmentsFormSection.tsx +160 -0
- package/src/quickReplies/QuickRepliesPicker.test.tsx +114 -0
- package/src/quickReplies/QuickRepliesPicker.tsx +188 -0
- package/src/quickReplies/QuickRepliesWorkspace.test.tsx +32 -0
- package/src/quickReplies/QuickRepliesWorkspace.tsx +395 -0
- package/src/quickReplies/createUploadQueue.test.ts +106 -0
- package/src/quickReplies/createUploadQueue.ts +69 -0
- package/src/quickReplies/index.ts +5 -0
- package/src/quickReplies/labels.ts +124 -0
- package/src/quickReplies/quickReply.types.ts +74 -0
- package/src/quickReplies/quickReplyAttachmentUpload.test.ts +76 -0
- package/src/quickReplies/quickReplyAttachmentUpload.ts +81 -0
- package/src/quickReplies/quickReplyAttachments.test.ts +396 -0
- package/src/quickReplies/quickReplyAttachments.ts +289 -0
- package/src/quickReplies/quickReplySearch.test.ts +88 -0
- package/src/quickReplies/quickReplySearch.ts +104 -0
- package/src/quickReplies/quickReplyShortcut.test.ts +38 -0
- package/src/quickReplies/quickReplyShortcut.ts +46 -0
- package/src/quickReplies/resolveConversationVariables.test.ts +63 -0
- package/src/quickReplies/resolveConversationVariables.ts +41 -0
- package/src/quickReplies/useQuickRepliesPicker.test.ts +20 -0
- package/src/quickReplies/useQuickRepliesPicker.ts +121 -0
- package/src/quickReplies/useQuickRepliesWorkspace.test.ts +222 -0
- package/src/quickReplies/useQuickRepliesWorkspace.ts +426 -0
- package/src/quickReplies/useQuickReplyAttachmentUploads.ts +159 -0
- package/src/styles.css +87 -0
- package/src/workspace/ConversationPane.tsx +137 -73
- package/src/workspace/ConversationsWorkspace.tsx +16 -1
- package/src/workspace/QueuedAttachmentsList.test.ts +27 -0
- package/src/workspace/QueuedAttachmentsList.tsx +198 -0
- package/src/workspace/index.ts +1 -0
- package/src/workspace/labels.ts +14 -0
- package/src/workspace/useComposerAttachmentRetry.ts +155 -0
- 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
|
+
}
|
package/src/workspace/index.ts
CHANGED
|
@@ -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'
|
package/src/workspace/labels.ts
CHANGED
|
@@ -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
|
+
}
|