@adatechnology/conversations-ui 0.2.0 → 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/index.js
CHANGED
|
@@ -4256,6 +4256,16 @@ function sameUploadIds(a, b) {
|
|
|
4256
4256
|
if (a.length !== b.length) return false;
|
|
4257
4257
|
return a.every((id, index) => id === b[index]);
|
|
4258
4258
|
}
|
|
4259
|
+
function hasSentEveryStoredUpload(storedUploadIds, sentAttachmentKeys) {
|
|
4260
|
+
if (storedUploadIds.length === 0) return true;
|
|
4261
|
+
const sentKeys = new Set(sentAttachmentKeys);
|
|
4262
|
+
return storedUploadIds.every((uploadId) => sentKeys.has(uploadId));
|
|
4263
|
+
}
|
|
4264
|
+
function shouldResetRetryKey(params) {
|
|
4265
|
+
const { current, attempted, sentAttachmentKeys, uploadId } = params;
|
|
4266
|
+
if (!sentAttachmentKeys.includes(uploadId)) return false;
|
|
4267
|
+
return current === attempted;
|
|
4268
|
+
}
|
|
4259
4269
|
function attachmentSendStatusOf(status) {
|
|
4260
4270
|
if (status === "sent") return "sent";
|
|
4261
4271
|
if (status === "skipped") return "skipped";
|
|
@@ -5497,6 +5507,14 @@ function useComposerAttachmentRetry(params) {
|
|
|
5497
5507
|
if (isSameConversation()) setAttachmentStatus((current) => ({ ...current, [statusKey]: status }));
|
|
5498
5508
|
}
|
|
5499
5509
|
});
|
|
5510
|
+
if (shouldResetRetryKey({
|
|
5511
|
+
current: retryIdempotencyKeyRef.current,
|
|
5512
|
+
attempted: idempotencyState,
|
|
5513
|
+
sentAttachmentKeys: result.sentAttachmentKeys,
|
|
5514
|
+
uploadId: item.uploadId
|
|
5515
|
+
})) {
|
|
5516
|
+
retryIdempotencyKeyRef.current = void 0;
|
|
5517
|
+
}
|
|
5500
5518
|
setQueue(
|
|
5501
5519
|
(current) => resolveRetryOutcome({
|
|
5502
5520
|
conversationIdAtRetry,
|
|
@@ -5621,15 +5639,14 @@ function useComposerQueue(params) {
|
|
|
5621
5639
|
if (!isSameConversation()) return;
|
|
5622
5640
|
if (!result.textSent) return;
|
|
5623
5641
|
const sentKeys = new Set(result.sentAttachmentKeys);
|
|
5624
|
-
|
|
5625
|
-
|
|
5626
|
-
const next = current
|
|
5627
|
-
|
|
5642
|
+
setQueue((current) => current.filter((item) => !sentKeys.has(attachmentKey(item))));
|
|
5643
|
+
setAttachmentStatus((current) => {
|
|
5644
|
+
const next = { ...current };
|
|
5645
|
+
for (const key of sentKeys) delete next[key];
|
|
5628
5646
|
return next;
|
|
5629
5647
|
});
|
|
5630
|
-
if (
|
|
5648
|
+
if (idempotencyKeyRef.current === idempotencyState && hasSentEveryStoredUpload(idempotencyState.uploadIds, result.sentAttachmentKeys)) {
|
|
5631
5649
|
idempotencyKeyRef.current = void 0;
|
|
5632
|
-
setAttachmentStatus({});
|
|
5633
5650
|
}
|
|
5634
5651
|
if (draft.trim()) setDraft("");
|
|
5635
5652
|
await refetch();
|
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
attachmentKey,
|
|
6
6
|
canAddAttachments,
|
|
7
7
|
excludeRetryingItems,
|
|
8
|
+
hasSentEveryStoredUpload,
|
|
8
9
|
orderOutgoingItems,
|
|
9
10
|
queuedAttachmentsFromQuickReply,
|
|
10
11
|
resolveIdempotencyKey,
|
|
@@ -12,6 +13,8 @@ import {
|
|
|
12
13
|
resolveRetryOutcome,
|
|
13
14
|
retryStoredAttachments,
|
|
14
15
|
sendQueuedMessage,
|
|
16
|
+
shouldResetRetryKey,
|
|
17
|
+
type IdempotencyKeyState,
|
|
15
18
|
} from './quickReplyAttachments'
|
|
16
19
|
import type { QueuedAttachment } from './quickReply.types'
|
|
17
20
|
|
|
@@ -261,6 +264,74 @@ describe('resolveIdempotencyKey', () => {
|
|
|
261
264
|
})
|
|
262
265
|
})
|
|
263
266
|
|
|
267
|
+
describe('hasSentEveryStoredUpload', () => {
|
|
268
|
+
it('true quando não havia guardado nenhum nesta tentativa', () => {
|
|
269
|
+
expect(hasSentEveryStoredUpload([], [])).toBe(true)
|
|
270
|
+
expect(hasSentEveryStoredUpload([], ['a'])).toBe(true)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('true quando todo uploadId da tentativa saiu', () => {
|
|
274
|
+
expect(hasSentEveryStoredUpload(['a', 'b'], ['a', 'b'])).toBe(true)
|
|
275
|
+
expect(hasSentEveryStoredUpload(['a', 'b'], ['b', 'a'])).toBe(true)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('true mesmo com chave extra em sentAttachmentKeys (ex: anexo local também enviado)', () => {
|
|
279
|
+
expect(hasSentEveryStoredUpload(['a'], ['a', 'local-1'])).toBe(true)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('falso quando um uploadId falhou ou foi pulado', () => {
|
|
283
|
+
expect(hasSentEveryStoredUpload(['a', 'b'], ['a'])).toBe(false)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('falso quando nada saiu', () => {
|
|
287
|
+
expect(hasSentEveryStoredUpload(['a', 'b'], [])).toBe(false)
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
describe('shouldResetRetryKey', () => {
|
|
292
|
+
const stateA: IdempotencyKeyState = { key: 'key-a', uploadIds: ['upload-1'] }
|
|
293
|
+
const stateB: IdempotencyKeyState = { key: 'key-b', uploadIds: ['upload-1'] }
|
|
294
|
+
|
|
295
|
+
it('descarta quando o item saiu e o ref ainda é o mesmo da tentativa (duas tentativas seguidas do mesmo uploadId recebem chaves diferentes)', () => {
|
|
296
|
+
expect(
|
|
297
|
+
shouldResetRetryKey({
|
|
298
|
+
current: stateA,
|
|
299
|
+
attempted: stateA,
|
|
300
|
+
sentAttachmentKeys: ['upload-1'],
|
|
301
|
+
uploadId: 'upload-1',
|
|
302
|
+
}),
|
|
303
|
+
).toBe(true)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('mantém a chave quando o retry falhou (uploadId não está em sentAttachmentKeys)', () => {
|
|
307
|
+
expect(
|
|
308
|
+
shouldResetRetryKey({ current: stateA, attempted: stateA, sentAttachmentKeys: [], uploadId: 'upload-1' }),
|
|
309
|
+
).toBe(false)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('mantém a chave quando um retry concorrente já trocou o ref por identidade', () => {
|
|
313
|
+
expect(
|
|
314
|
+
shouldResetRetryKey({
|
|
315
|
+
current: stateB,
|
|
316
|
+
attempted: stateA,
|
|
317
|
+
sentAttachmentKeys: ['upload-1'],
|
|
318
|
+
uploadId: 'upload-1',
|
|
319
|
+
}),
|
|
320
|
+
).toBe(false)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('mantém a chave quando o ref já foi limpo (undefined) por outra resolução', () => {
|
|
324
|
+
expect(
|
|
325
|
+
shouldResetRetryKey({
|
|
326
|
+
current: undefined,
|
|
327
|
+
attempted: stateA,
|
|
328
|
+
sentAttachmentKeys: ['upload-1'],
|
|
329
|
+
uploadId: 'upload-1',
|
|
330
|
+
}),
|
|
331
|
+
).toBe(false)
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
264
335
|
describe('retryStoredAttachments', () => {
|
|
265
336
|
it('reenvia só o uploadId pedido, sem tocar no resto da fila', async () => {
|
|
266
337
|
const calls: { uploadIds: readonly string[]; idempotencyKey: string }[] = []
|
|
@@ -112,6 +112,45 @@ function sameUploadIds(a: readonly string[], b: readonly string[]): boolean {
|
|
|
112
112
|
return a.every((id, index) => id === b[index])
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Decide se a chave de idempotência pode ser descartada depois de um envio (M3-bug): todo
|
|
117
|
+
* `uploadId` da tentativa saiu com sucesso. `setState` com updater NÃO roda de forma síncrona
|
|
118
|
+
* dentro do handler — uma variável `let` atualizada por ele e lida logo em seguida sempre lê o
|
|
119
|
+
* valor antigo. Esta decisão usa só os parâmetros da própria tentativa (nunca o estado da fila),
|
|
120
|
+
* então não depende de nenhum `setState` ter aplicado.
|
|
121
|
+
*/
|
|
122
|
+
export function hasSentEveryStoredUpload(
|
|
123
|
+
storedUploadIds: readonly string[],
|
|
124
|
+
sentAttachmentKeys: readonly string[],
|
|
125
|
+
): boolean {
|
|
126
|
+
if (storedUploadIds.length === 0) return true
|
|
127
|
+
const sentKeys = new Set(sentAttachmentKeys)
|
|
128
|
+
return storedUploadIds.every((uploadId) => sentKeys.has(uploadId))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type ShouldResetRetryKeyParams = {
|
|
132
|
+
/** Valor corrente do ref no momento da checagem — lido depois do `await`. */
|
|
133
|
+
readonly current: IdempotencyKeyState | undefined
|
|
134
|
+
/** Estado usado NESTA tentativa, capturado antes do `await`. */
|
|
135
|
+
readonly attempted: IdempotencyKeyState
|
|
136
|
+
readonly sentAttachmentKeys: readonly string[]
|
|
137
|
+
readonly uploadId: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Decide se a chave de idempotência do retry avulso pode ser descartada (M3-retry-bug): o item
|
|
142
|
+
* saiu com sucesso E ninguém trocou o ref por identidade desde então. A checagem de identidade
|
|
143
|
+
* (`current === attempted`) importa porque um retry avulso reusa `uploadId` de itens guardados
|
|
144
|
+
* (mensagens prontas) — um segundo retry do MESMO `uploadId` pode começar e sobrescrever o ref
|
|
145
|
+
* antes desta tentativa terminar; sem a checagem, o `undefined` desta tentativa apagaria a chave
|
|
146
|
+
* da tentativa mais nova, e o próximo envio dela reusaria uma chave já consumida pelo servidor.
|
|
147
|
+
*/
|
|
148
|
+
export function shouldResetRetryKey(params: ShouldResetRetryKeyParams): boolean {
|
|
149
|
+
const { current, attempted, sentAttachmentKeys, uploadId } = params
|
|
150
|
+
if (!sentAttachmentKeys.includes(uploadId)) return false
|
|
151
|
+
return current === attempted
|
|
152
|
+
}
|
|
153
|
+
|
|
115
154
|
export type SendQueuedMessageParams = {
|
|
116
155
|
readonly text: string
|
|
117
156
|
readonly queue: readonly QueuedAttachment[]
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
resolveIdempotencyKey,
|
|
19
19
|
resolveRetryOutcome,
|
|
20
20
|
retryStoredAttachments,
|
|
21
|
+
shouldResetRetryKey,
|
|
21
22
|
type AttachmentSendStatus,
|
|
22
23
|
type IdempotencyKeyState,
|
|
23
24
|
} from '../quickReplies/quickReplyAttachments'
|
|
@@ -116,6 +117,19 @@ export function useComposerAttachmentRetry(params: UseComposerAttachmentRetryPar
|
|
|
116
117
|
if (isSameConversation()) setAttachmentStatus((current) => ({ ...current, [statusKey]: status }))
|
|
117
118
|
},
|
|
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
|
+
}
|
|
119
133
|
setQueue(
|
|
120
134
|
(current) =>
|
|
121
135
|
resolveRetryOutcome({
|
|
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
7
7
|
import {
|
|
8
8
|
attachmentKey,
|
|
9
9
|
excludeRetryingItems,
|
|
10
|
+
hasSentEveryStoredUpload,
|
|
10
11
|
resolveIdempotencyKey,
|
|
11
12
|
sendQueuedMessage,
|
|
12
13
|
type AttachmentSendStatus,
|
|
@@ -162,17 +163,25 @@ export function useComposerQueue(params: UseComposerQueueParams): UseComposerQue
|
|
|
162
163
|
// Filtra por chave sobre a fila CORRENTE, não sobrescreve com `result.remainingQueue` (que foi
|
|
163
164
|
// calculado sobre a fila capturada antes do `await` e perderia item adicionado durante o envio).
|
|
164
165
|
const sentKeys = new Set(result.sentAttachmentKeys)
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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]
|
|
171
175
|
return next
|
|
172
176
|
})
|
|
173
|
-
|
|
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
|
+
) {
|
|
174
184
|
idempotencyKeyRef.current = undefined
|
|
175
|
-
setAttachmentStatus({})
|
|
176
185
|
}
|
|
177
186
|
if (draft.trim()) setDraft('')
|
|
178
187
|
await refetch()
|