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