@remit/web-client 0.0.272 → 0.0.274

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 (31) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/BlockedImagesNotice.render.test.ts +54 -5
  3. package/src/components/mail/BlockedImagesNotice.tsx +40 -18
  4. package/src/components/mail/BulkRunProvider.tsx +14 -1
  5. package/src/components/mail/MessageBody.blocked-sender.test.ts +193 -0
  6. package/src/components/mail/MessageBody.tsx +21 -2
  7. package/src/components/mail/MessageCard.blocked-sender.test.ts +246 -0
  8. package/src/components/mail/MessageCard.tsx +8 -1
  9. package/src/components/mail/MessageList.run-reporting.test.ts +1 -0
  10. package/src/components/mail/MessageListItem.test.ts +59 -3
  11. package/src/components/mail/MessageSettlementAlert.render.test.ts +182 -0
  12. package/src/components/mail/MessageSettlementAlert.tsx +77 -19
  13. package/src/components/mail/blocked-images-notice.stories.tsx +90 -0
  14. package/src/components/mail/bulk-run-state.test.ts +1 -0
  15. package/src/components/mail/conversation-reply-identity.render.test.ts +1 -0
  16. package/src/components/mail/conversation-reply-reach.render.test.ts +1 -0
  17. package/src/components/mail/selection-wizard-run-controls.test.ts +1 -0
  18. package/src/components/mail/two-window-listeners-one-key.render.test.ts +1 -0
  19. package/src/components/ui/placement-refusal.test.ts +7 -20
  20. package/src/components/ui/placement-refusal.ts +10 -12
  21. package/src/hooks/useDeleteMessages.ts +19 -0
  22. package/src/hooks/useMailboxAccount.ts +5 -0
  23. package/src/hooks/useToggleStar.test.ts +1 -0
  24. package/src/hooks/useToggleTrusted.test.ts +1 -0
  25. package/src/lib/brief.test.ts +1 -0
  26. package/src/lib/bulk-actions.test.ts +29 -1
  27. package/src/lib/bulk-actions.ts +59 -16
  28. package/src/lib/dedupe-thread-messages.test.ts +1 -0
  29. package/src/lib/row-settlement.ts +21 -8
  30. package/src/lib/thread-search-tokens.test.ts +1 -0
  31. package/src/test-support/fixtures.ts +1 -0
@@ -9,11 +9,13 @@ import type { PushErrorInput } from "@/components/ui/error-banners";
9
9
  import { type CodedApiErrorBody, codedApiErrorBody } from "@/lib/api";
10
10
 
11
11
  /**
12
- * `in_flight` clears on its own once the mail server confirms the move;
13
- * `unverified` does not, because the move gave up without confirming and
14
- * nothing routine repairs the row.
12
+ * One reason, because one state produces the refusal: a mutation is still in
13
+ * flight and the pair clears on its own, so the copy words a wait. A mutation
14
+ * that gave up hands the row back to a placement the mail server confirmed
15
+ * before it stops (imap-mutations R3), and that row is acted on rather than
16
+ * refused — there is no second reason left to word.
15
17
  */
16
- export type PlacementRefusalReason = "in_flight" | "unverified";
18
+ export type PlacementRefusalReason = "in_flight";
17
19
 
18
20
  export interface PlacementRefusal {
19
21
  reason: PlacementRefusalReason;
@@ -22,7 +24,6 @@ export interface PlacementRefusal {
22
24
 
23
25
  const REASONS: ReadonlySet<string> = new Set<PlacementRefusalReason>([
24
26
  "in_flight",
25
- "unverified",
26
27
  ]);
27
28
 
28
29
  const stringAt = (
@@ -53,11 +54,11 @@ export const isPlacementRefusal = (
53
54
  export type PlacementRefusalAction = "delete" | "move";
54
55
 
55
56
  /**
56
- * The banner copy. Both reasons say what happened and what to do; neither is a
57
- * dead end, and neither repeats the server's uuid at the user.
57
+ * The banner copy. It says what happened and what to do, is not a dead end, and
58
+ * does not repeat the server's uuid at the user.
58
59
  */
59
60
  export const placementRefusalBanner = (
60
- refusal: PlacementRefusal,
61
+ _refusal: PlacementRefusal,
61
62
  count: number,
62
63
  action: PlacementRefusalAction,
63
64
  ): PushErrorInput => ({
@@ -66,8 +67,5 @@ export const placementRefusalBanner = (
66
67
  count > 1
67
68
  ? `Couldn't ${action} ${count} messages yet`
68
69
  : `Couldn't ${action} this message yet`,
69
- detail:
70
- refusal.reason === "in_flight"
71
- ? "It is still being moved on the mail server. Try again in a moment."
72
- : `An earlier move never finished, so where this message sits is unknown. Sync the folder, then ${action} it again.`,
70
+ detail: "It is still being moved on the mail server. Try again in a moment.",
73
71
  });
@@ -161,6 +161,25 @@ export const useDeleteMessages = ({
161
161
  previousThreadsList,
162
162
  };
163
163
  },
164
+ // A batch that partly applied answers 200 with a non-zero `failureCount`:
165
+ // the rows whose placement changed under it were never claimed
166
+ // (imap-mutations R3). `onSettled` invalidates, so those rows come back on
167
+ // their own — this is what stops them coming back unexplained, which is
168
+ // the dead-button failure wearing a different face (#1229).
169
+ onSuccess: (data, vars) => {
170
+ const refused = data?.failureCount ?? 0;
171
+ if (refused === 0) return;
172
+ const asked = vars.body.messageIds?.length ?? 0;
173
+ pushError({
174
+ severity: "warning",
175
+ title:
176
+ refused > 1
177
+ ? `Couldn't delete ${refused} of ${asked} messages yet`
178
+ : "Couldn't delete this message yet",
179
+ detail:
180
+ "Something else moved it while the delete was being prepared. It is back where it now sits — try again.",
181
+ });
182
+ },
164
183
  onError: (err, vars, context) => {
165
184
  if (context) {
166
185
  for (const entry of context.previousThreadMessages) {
@@ -25,9 +25,14 @@ interface MailboxAccountResolution {
25
25
  export const useMailboxAccount = (
26
26
  mailboxId: string | undefined,
27
27
  ): MailboxAccountResolution => {
28
+ // Disabled where there is no mailbox to resolve, the way the fan-out below
29
+ // already is. A caller that already knows its account passes `undefined` and
30
+ // this subscribes to nothing — which keeps the hook from pulling `/config`
31
+ // into trees that never asked for it.
28
32
  const { data: config, isLoading: isConfigLoading } = useQuery({
29
33
  ...configOperationsGetConfigOptions(),
30
34
  staleTime: Infinity,
35
+ enabled: !!mailboxId,
31
36
  });
32
37
 
33
38
  const accountIds = useMemo(
@@ -14,6 +14,7 @@ const make = (
14
14
  senderTrust: "unknown",
15
15
  status: "active",
16
16
  syncStatus: "pending",
17
+ abandonedMutation: "none",
17
18
  category: "uncategorized",
18
19
  threadId: "t1",
19
20
  threadMessageId: `tm-${messageId}`,
@@ -70,6 +70,7 @@ describe("patchDescribeMessage", () => {
70
70
  internalDate: 1,
71
71
  status: "active",
72
72
  syncStatus: "pending",
73
+ abandonedMutation: "none",
73
74
  },
74
75
  envelope: {
75
76
  messageId: "m",
@@ -43,6 +43,7 @@ function threadResponse(
43
43
  senderTrust: "unknown",
44
44
  status: "active",
45
45
  syncStatus: "pending",
46
+ abandonedMutation: "none",
46
47
  muted: false,
47
48
  createdAt: 0,
48
49
  updatedAt: 0,
@@ -132,6 +132,28 @@ describe("chunkTargets", () => {
132
132
  describe("runChunkedAction", () => {
133
133
  const noopProgress = () => undefined;
134
134
 
135
+ // A select-all is walked a hundred ids at a time. Summed here so the caller
136
+ // states the refusals once for the run, rather than pushing a banner per
137
+ // chunk until the surface caps them.
138
+ test("sums the rows the server refused across every chunk", async () => {
139
+ const outcome = await runChunkedAction(
140
+ Array.from({ length: 250 }, (_, i) => ({
141
+ id: `m${i}`,
142
+ accountId: "acc-1",
143
+ })),
144
+ async (chunk) => ({
145
+ successCount: chunk.length - 1,
146
+ failureCount: 1,
147
+ }),
148
+ noopProgress,
149
+ neverAborted(),
150
+ );
151
+
152
+ assert.equal(outcome.refused, 3, "one per chunk, over three chunks");
153
+ assert.equal(outcome.done, 250, "every id was reached");
154
+ assert.deepEqual(outcome.failedIds, [], "and none was left unattempted");
155
+ });
156
+
135
157
  test("zero ids does nothing and reports done=0", async () => {
136
158
  const calls: string[][] = [];
137
159
  const outcome = await runChunkedAction(
@@ -146,6 +168,7 @@ describe("runChunkedAction", () => {
146
168
  assert.deepEqual(outcome, {
147
169
  done: 0,
148
170
  failedIds: [],
171
+ refused: 0,
149
172
  cancelled: false,
150
173
  });
151
174
  assert.equal(calls.length, 0);
@@ -356,7 +379,12 @@ describe("runPredicateAction", () => {
356
379
  noopProgress,
357
380
  neverAborted(),
358
381
  );
359
- assert.deepEqual(outcome, { done: 0, failedIds: [], cancelled: false });
382
+ assert.deepEqual(outcome, {
383
+ done: 0,
384
+ failedIds: [],
385
+ refused: 0,
386
+ cancelled: false,
387
+ });
360
388
  });
361
389
 
362
390
  test("exactly 100 matches — a page size's worth — resolves in a single page with no continuation", async () => {
@@ -10,10 +10,13 @@
10
10
  *
11
11
  * The endpoints enqueue the IMAP write and return; they do not apply it. So a
12
12
  * returned call means every id in it was accepted, not that the mail server
13
- * applied it — there is no per-id success/failure in the response to read. The
14
- * only failure this layer can observe is a thrown call: an infrastructure
15
- * failure (auth, the write, or the enqueue) that takes out the whole batch and
16
- * stops the run.
13
+ * applied it. Delete is the one action that can accept some of a batch and not
14
+ * the rest — a row whose placement changed under it is refused and counted in
15
+ * the response (imap-mutations R3) — and it reports that itself, on the call,
16
+ * rather than here: this layer only ever hands ids back, and an id this run did
17
+ * reach is not one it should re-offer. The only failure it observes is a thrown
18
+ * call: an infrastructure failure (auth, the write, or the enqueue) that takes
19
+ * out the whole batch and stops the run.
17
20
  *
18
21
  * Cancellation is an `AbortSignal` the caller owns, handed to every call the run
19
22
  * makes (#113). Aborting ends the request in flight rather than waiting for it,
@@ -165,10 +168,19 @@ export interface BulkActionOutcome {
165
168
  * An aborted batch is counted here too: aborting the request does not
166
169
  * un-send what the server already accepted, so its ids are "may not have
167
170
  * landed" rather than "did not", and every action here is idempotent.
168
- * There is no per-id failure source: a returned batch call counts every id in
169
- * it as accepted (see the module header).
171
+ * A returned batch call counts every id in it as reached. Delete's own
172
+ * per-row refusals ride its response and are surfaced by the caller, not
173
+ * folded in here (see the module header).
170
174
  */
171
175
  failedIds: string[];
176
+ /**
177
+ * Rows the server reached and refused: their placement changed under the
178
+ * run, so the action never claimed them (imap-mutations R3). Summed across
179
+ * every chunk so the caller states it once for the whole run rather than
180
+ * once per hundred ids. Not folded into `failedIds` — those were never
181
+ * reached, and these were.
182
+ */
183
+ refused: number;
172
184
  cancelled: boolean;
173
185
  /** Set when a batch call threw — an infrastructure failure, not a
174
186
  * per-item failure. The run stops at the point it was raised. */
@@ -198,26 +210,34 @@ export const runChunkedAction = async (
198
210
  const chunks = chunkTargets(targets);
199
211
  const total = targets.length;
200
212
  let done = 0;
213
+ let refused = 0;
201
214
  const failedIds: string[] = [];
202
215
 
203
216
  for (let i = 0; i < chunks.length; i++) {
204
217
  if (signal.aborted) {
205
218
  failedIds.push(...chunks.slice(i).flat());
206
- return { done, failedIds, cancelled: true };
219
+ return { done, failedIds, refused, cancelled: true };
207
220
  }
208
221
  const chunk = chunks[i];
209
222
  const attempted = await attempt(applyBatch(chunk, signal));
210
223
  if (!attempted.ok) {
211
224
  failedIds.push(...chunks.slice(i).flat());
212
- if (signal.aborted) return { done, failedIds, cancelled: true };
225
+ if (signal.aborted) return { done, failedIds, refused, cancelled: true };
213
226
  onProgress({ done, total });
214
- return { done, failedIds, cancelled: false, error: attempted.error };
227
+ return {
228
+ done,
229
+ failedIds,
230
+ refused,
231
+ cancelled: false,
232
+ error: attempted.error,
233
+ };
215
234
  }
235
+ refused += attempted.value?.failureCount ?? 0;
216
236
  done += chunk.length;
217
237
  onProgress({ done, total });
218
238
  }
219
239
 
220
- return { done, failedIds, cancelled: false };
240
+ return { done, failedIds, refused, cancelled: false };
221
241
  };
222
242
 
223
243
  /**
@@ -290,27 +310,42 @@ export const runPredicateAction = async (
290
310
  signal: AbortSignal,
291
311
  ): Promise<BulkActionOutcome> => {
292
312
  let done = 0;
313
+ let refused = 0;
293
314
  const failedIds: string[] = [];
294
315
  let token: string | undefined;
295
316
 
296
317
  do {
297
318
  if (signal.aborted) {
298
- return { done, failedIds, cancelled: true };
319
+ return { done, failedIds, refused, cancelled: true };
299
320
  }
300
321
 
301
322
  const fetched = await attempt(fetchIdsPage(token, signal));
302
323
  if (!fetched.ok) {
303
- if (signal.aborted) return { done, failedIds, cancelled: true };
304
- return { done, failedIds, cancelled: false, error: fetched.error };
324
+ if (signal.aborted) return { done, failedIds, refused, cancelled: true };
325
+ return {
326
+ done,
327
+ failedIds,
328
+ refused,
329
+ cancelled: false,
330
+ error: fetched.error,
331
+ };
305
332
  }
306
333
  const page = fetched.value;
307
334
 
308
335
  if (page.ids.length > 0) {
309
336
  const attempted = await attempt(applyBatch(page.ids, signal));
310
337
  if (!attempted.ok) {
311
- if (signal.aborted) return { done, failedIds, cancelled: true };
312
- return { done, failedIds, cancelled: false, error: attempted.error };
338
+ if (signal.aborted)
339
+ return { done, failedIds, refused, cancelled: true };
340
+ return {
341
+ done,
342
+ failedIds,
343
+ refused,
344
+ cancelled: false,
345
+ error: attempted.error,
346
+ };
313
347
  }
348
+ refused += attempted.value?.failureCount ?? 0;
314
349
  done += page.ids.length;
315
350
  onProgress({ done, total });
316
351
  }
@@ -318,7 +353,7 @@ export const runPredicateAction = async (
318
353
  token = page.continuationToken;
319
354
  } while (token);
320
355
 
321
- return { done, failedIds, cancelled: false };
356
+ return { done, failedIds, refused, cancelled: false };
322
357
  };
323
358
 
324
359
  /**
@@ -342,6 +377,14 @@ export const honestProgress = (
342
377
  export interface BulkRunOutcome {
343
378
  done: number;
344
379
  failedIds: string[];
380
+ /**
381
+ * Rows the server reached and refused: their placement changed under the
382
+ * run, so the action never claimed them (imap-mutations R3). Summed across
383
+ * every chunk so the caller states it once for the whole run rather than
384
+ * once per hundred ids. Not folded into `failedIds` — those were never
385
+ * reached, and these were.
386
+ */
387
+ refused: number;
345
388
  cancelled: boolean;
346
389
  error?: unknown;
347
390
  }
@@ -26,6 +26,7 @@ function threadMessage(
26
26
  senderTrust: "unknown",
27
27
  status: "active",
28
28
  syncStatus: "pending",
29
+ abandonedMutation: "none",
29
30
  muted: false,
30
31
  createdAt: 0,
31
32
  updatedAt: 0,
@@ -1,15 +1,28 @@
1
1
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
- import { hasAbandonedDelete } from "@remit/data-ports/message-settlement";
2
+ import { abandonedMutationOf } from "@remit/data-ports/message-settlement";
3
+ import { MessageMutation } from "@remit/domain-enums";
3
4
  import type { ThreadRowData } from "@remit/ui";
4
5
 
5
6
  /**
6
- * The row treatment for a message whose delete Remit abandoned (issue #1002). The
7
- * client decides nothing: `hasAbandonedDelete` owns which `status`/`syncStatus`
8
- * pair proves a terminal give-up, and why no other pair does. Any other row —
9
- * settled, mid-retry, or an undecidable move — carries no key at all and
7
+ * The row treatment for a message whose mutation Remit gave up on (issue
8
+ * #1002). The client decides nothing: `abandonedMutationOf` owns which values
9
+ * prove a terminal give-up and which operation it was. A copy that gave up
10
+ * leaves a `deleted` row no listing carries, so it reaches no row here; any
11
+ * other row — settled, mid-retry, or in flight — carries no key at all and
10
12
  * renders exactly as it always has.
11
13
  */
12
14
  export const rowSettlement = (
13
- thread: Pick<RemitImapThreadMessageResponse, "status" | "syncStatus">,
14
- ): Pick<ThreadRowData, "settlement"> =>
15
- hasAbandonedDelete(thread) ? { settlement: "delete_failed" } : {};
15
+ thread: Pick<
16
+ RemitImapThreadMessageResponse,
17
+ "status" | "syncStatus" | "abandonedMutation"
18
+ >,
19
+ ): Pick<ThreadRowData, "settlement"> => {
20
+ switch (abandonedMutationOf(thread)) {
21
+ case MessageMutation.delete:
22
+ return { settlement: "delete_failed" };
23
+ case MessageMutation.move:
24
+ return { settlement: "move_failed" };
25
+ default:
26
+ return {};
27
+ }
28
+ };
@@ -43,6 +43,7 @@ const thread = (
43
43
  senderTrust: "unknown",
44
44
  status: "active",
45
45
  syncStatus: "pending",
46
+ abandonedMutation: "none",
46
47
  muted: false,
47
48
  createdAt: 0,
48
49
  updatedAt: 0,
@@ -80,6 +80,7 @@ export const makeThreadMessage = (
80
80
  isDeleted: false,
81
81
  status: "active",
82
82
  syncStatus: "pending",
83
+ abandonedMutation: "none",
83
84
  senderTrust: "unknown",
84
85
  muted: false,
85
86
  createdAt: 0,