@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
- let queueEmptyAfterSend = false;
5625
- setQueue((current) => {
5626
- const next = current.filter((item) => !sentKeys.has(attachmentKey(item)));
5627
- queueEmptyAfterSend = next.length === 0;
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 (queueEmptyAfterSend) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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
- // 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
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
- if (queueEmptyAfterSend) {
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()