@remit/web-client 0.0.185 → 0.0.187

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.
@@ -11,19 +11,15 @@ import type {
11
11
  import { useMutation, useQueryClient } from "@tanstack/react-query";
12
12
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
13
13
  import { buildMutationErrorBanner } from "@/components/ui/error-banners";
14
+ import type { SnapshotEntry } from "@/lib/thread-list-cache";
14
15
 
15
16
  interface UseToggleTrustedOptions {
16
17
  messageId: string;
17
18
  }
18
19
 
19
- interface SnapshotEntry {
20
- queryKey: readonly unknown[];
21
- data: RemitImapDescribeMessageResponse;
22
- }
23
-
24
20
  interface ToggleTrustedContext {
25
21
  describePrefix: readonly unknown[];
26
- previous: SnapshotEntry[];
22
+ previous: SnapshotEntry<RemitImapDescribeMessageResponse>[];
27
23
  }
28
24
 
29
25
  export const buildTrustedFlag = (
@@ -1,4 +1,13 @@
1
- import { formatNumber } from "@/lib/format";
1
+ import type { ErrorBannerSeverity } from "@/components/ui/error-banners";
2
+ import type { BulkRunOutcome } from "@/lib/bulk-actions";
3
+ import { type DeleteOutcome, formatNumber } from "@/lib/format";
4
+
5
+ /** A run ending, as the list banners it. */
6
+ export interface RunEndingBanner {
7
+ severity: ErrorBannerSeverity;
8
+ title: string;
9
+ detail?: string;
10
+ }
2
11
 
3
12
  /**
4
13
  * Wording for the three bulk actions a selection can run (#114). One place
@@ -23,6 +32,16 @@ const pastTense: Record<BulkActionKind, string> = {
23
32
  markRead: "marked as read",
24
33
  };
25
34
 
35
+ /**
36
+ * What the run did, in the past tense. A delete inside Trash expunges rather
37
+ * than moves (#855), and that holds for a run that stopped halfway exactly as
38
+ * it does for one that finished — the half that ran is still erased.
39
+ */
40
+ const pastTenseFor = (kind: BulkActionKind, outcome: DeleteOutcome): string =>
41
+ kind === "delete" && outcome === "permanent"
42
+ ? "permanently deleted"
43
+ : pastTense[kind];
44
+
26
45
  const negated: Record<BulkActionKind, string> = {
27
46
  delete: "couldn't be deleted",
28
47
  move: "couldn't be moved",
@@ -52,12 +71,18 @@ export const bulkActionProgressLabel = (
52
71
  * Shown once a run finishes with nothing left over. The second sentence is
53
72
  * the honest part: the bulk endpoints enqueue the IMAP write, so the mail
54
73
  * server is still applying it when this appears.
74
+ *
75
+ * A delete inside Trash expunges rather than moves (#855), so the run that just
76
+ * finished is named by its outcome — telling a reader their mail is "moved to
77
+ * Trash" after it was erased is the same lie the confirmation stopped telling,
78
+ * one screen later.
55
79
  */
56
80
  export const bulkActionCompletionText = (
57
81
  kind: BulkActionKind,
58
82
  done: number,
83
+ outcome: DeleteOutcome = "trash",
59
84
  ): string =>
60
- `${formatNumber(done)} ${pastTense[kind]}. Your mail server is still catching up.`;
85
+ `${formatNumber(done)} ${pastTenseFor(kind, outcome)}. Your mail server is still catching up.`;
61
86
 
62
87
  /**
63
88
  * Shown when a run ended before it covered what it was started against. The
@@ -72,8 +97,9 @@ export const bulkActionStoppedDetail = (
72
97
  kind: BulkActionKind,
73
98
  done: number,
74
99
  total: number,
100
+ outcome: DeleteOutcome = "trash",
75
101
  ): string =>
76
- `${formatNumber(done)} of ${formatNumber(total)} ${pastTense[kind]}. Nothing was sent for the rest, so they are untouched.`;
102
+ `${formatNumber(done)} of ${formatNumber(total)} ${pastTenseFor(kind, outcome)}. Nothing was sent for the rest, so they are untouched.`;
77
103
 
78
104
  /** Error-banner title for a run stopped by an infrastructure failure. */
79
105
  export const bulkActionFailureTitle = (
@@ -91,3 +117,43 @@ export const bulkActionFailureDetail = (kind: BulkActionKind): string =>
91
117
  export const bulkActionProgressTone = (
92
118
  kind: BulkActionKind,
93
119
  ): "danger" | "info" => (kind === "delete" ? "danger" : "info");
120
+
121
+ /**
122
+ * How a run that has already ended is announced, or `null` when it announces
123
+ * itself elsewhere.
124
+ *
125
+ * The run screen invites the user to close it and keeps going past that, so by
126
+ * the time a run ends there is often no screen of its own left to say how it
127
+ * went (#521) — the list says it instead. Three endings, and they are not the
128
+ * same news: a run stopped short is a warning, because mail the user asked to
129
+ * be acted on was left untouched; a run that covered everything is a passing
130
+ * note; and a run stopped by a thrown batch already bannered where it threw, so
131
+ * saying it twice is the one wrong answer.
132
+ *
133
+ * Pure, so the severity of each ending is pinned by its result rather than by
134
+ * the shape of the caller that produces it.
135
+ */
136
+ export const runEndingBanner = (
137
+ kind: BulkActionKind,
138
+ matched: number,
139
+ outcome: BulkRunOutcome,
140
+ deleteOutcome: DeleteOutcome,
141
+ ): RunEndingBanner | null => {
142
+ if (outcome.error !== undefined) return null;
143
+ if (outcome.cancelled) {
144
+ return {
145
+ severity: "warning",
146
+ title: bulkActionStoppedTitle(outcome.done),
147
+ detail: bulkActionStoppedDetail(
148
+ kind,
149
+ outcome.done,
150
+ matched,
151
+ deleteOutcome,
152
+ ),
153
+ };
154
+ }
155
+ return {
156
+ severity: "info",
157
+ title: bulkActionCompletionText(kind, outcome.done, deleteOutcome),
158
+ };
159
+ };
@@ -1,7 +1,9 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
+ import type { DeleteTarget } from "./format.js";
3
4
  import {
4
5
  deleteConfirmationCopy,
6
+ deleteOutcomeFor,
5
7
  formatDate,
6
8
  formatDatePreset,
7
9
  formatDeleteToTrashTitle,
@@ -155,3 +157,183 @@ describe("deleteConfirmationCopy", () => {
155
157
  });
156
158
  });
157
159
  });
160
+
161
+ /**
162
+ * Issue #855. The failure branch is the one that matters: TanStack sets
163
+ * `status: "error"` with `data` undefined, so a config read that failed leaves
164
+ * an empty Trash set behind. Reading that as "this folder is not Trash" hands an
165
+ * expired session a "Move to Trash?" dialog over an expunge — #845 reinstated on
166
+ * the error path.
167
+ */
168
+ describe("deleteOutcomeFor", () => {
169
+ const trashByAccount = new Map([
170
+ ["acct-1", "mbx-trash"],
171
+ ["acct-2", undefined],
172
+ ]);
173
+ const settled = { trashByAccount, hasAppointments: true, isError: false };
174
+ // No default for the account: a default parameter is applied to an explicit
175
+ // `undefined` too, so "the row names no account" silently became "acct-1"
176
+ // and the case asserting it read back as an ordinary move to Trash.
177
+ const target = (
178
+ mailboxId: string,
179
+ accountId: string | undefined,
180
+ ): DeleteTarget => ({ accountId, mailboxId });
181
+
182
+ test("a row outside Trash is a reversible move", () => {
183
+ assert.strictEqual(
184
+ deleteOutcomeFor({
185
+ ...settled,
186
+ targets: [target("mbx-inbox", "acct-1")],
187
+ }),
188
+ "trash",
189
+ );
190
+ });
191
+
192
+ test("a row inside its own account's Trash is an expunge", () => {
193
+ assert.strictEqual(
194
+ deleteOutcomeFor({
195
+ ...settled,
196
+ targets: [target("mbx-trash", "acct-1")],
197
+ }),
198
+ "permanent",
199
+ );
200
+ });
201
+
202
+ test("one row inside Trash makes a mixed set permanent", () => {
203
+ assert.strictEqual(
204
+ deleteOutcomeFor({
205
+ ...settled,
206
+ targets: [target("mbx-inbox", "acct-1"), target("mbx-trash", "acct-1")],
207
+ }),
208
+ "permanent",
209
+ );
210
+ });
211
+
212
+ test("an account that appoints no Trash is its own answer, not a move", () => {
213
+ assert.strictEqual(
214
+ deleteOutcomeFor({
215
+ ...settled,
216
+ targets: [target("mbx-inbox", "acct-2")],
217
+ }),
218
+ "noTrash",
219
+ "the server refuses that delete rather than moving anything",
220
+ );
221
+ });
222
+
223
+ test("a refused account outranks an expunge in the same set", () => {
224
+ assert.strictEqual(
225
+ deleteOutcomeFor({
226
+ ...settled,
227
+ targets: [target("mbx-trash", "acct-1"), target("mbx-inbox", "acct-2")],
228
+ }),
229
+ "noTrash",
230
+ );
231
+ });
232
+
233
+ test("another account's Trash is not this row's Trash", () => {
234
+ assert.strictEqual(
235
+ deleteOutcomeFor({
236
+ ...settled,
237
+ trashByAccount: new Map([
238
+ ["acct-1", "mbx-trash"],
239
+ ["acct-2", "mbx-other-trash"],
240
+ ]),
241
+ targets: [target("mbx-trash", "acct-2")],
242
+ }),
243
+ "trash",
244
+ );
245
+ });
246
+
247
+ test("appointments that have not arrived commit to neither wording", () => {
248
+ assert.strictEqual(
249
+ deleteOutcomeFor({
250
+ targets: [target("mbx-inbox", "acct-1")],
251
+ trashByAccount: new Map(),
252
+ hasAppointments: false,
253
+ isError: false,
254
+ }),
255
+ "unknown",
256
+ "a paused offline query reports neither loading nor error",
257
+ );
258
+ });
259
+
260
+ test("an account nothing is known about yet is unknown, not a move", () => {
261
+ assert.strictEqual(
262
+ deleteOutcomeFor({
263
+ ...settled,
264
+ targets: [target("mbx-inbox", "acct-9")],
265
+ }),
266
+ "unknown",
267
+ );
268
+ });
269
+
270
+ test("a row with no account is unknown, not a move", () => {
271
+ assert.strictEqual(
272
+ deleteOutcomeFor({
273
+ ...settled,
274
+ targets: [target("mbx-inbox", undefined)],
275
+ }),
276
+ "unknown",
277
+ );
278
+ });
279
+
280
+ test("a failed read refuses the delete rather than promising a move", () => {
281
+ assert.strictEqual(
282
+ deleteOutcomeFor({
283
+ targets: [target("mbx-inbox", "acct-1")],
284
+ trashByAccount: new Map(),
285
+ hasAppointments: false,
286
+ isError: true,
287
+ }),
288
+ "unavailable",
289
+ );
290
+ });
291
+
292
+ test("a failed read outranks a settled appointment set", () => {
293
+ assert.strictEqual(
294
+ deleteOutcomeFor({
295
+ ...settled,
296
+ targets: [target("mbx-inbox", "acct-1")],
297
+ isError: true,
298
+ }),
299
+ "unavailable",
300
+ );
301
+ });
302
+
303
+ test("nothing pending is not an answer", () => {
304
+ assert.strictEqual(
305
+ deleteOutcomeFor({ ...settled, targets: [] }),
306
+ "unknown",
307
+ );
308
+ });
309
+ });
310
+
311
+ describe("deleteConfirmationCopy — the refusal", () => {
312
+ test("states what failed and offers the way back in", () => {
313
+ assert.deepStrictEqual(deleteConfirmationCopy(1, "unavailable"), {
314
+ title: "Can't delete 1 message",
315
+ description:
316
+ "reader couldn't read this account's folder settings, so it can't say whether this would move the mail to Trash or erase it. Nothing has been deleted.",
317
+ confirmLabel: "Sign in again",
318
+ });
319
+ });
320
+
321
+ test("never offers the reversible wording on the failure path", () => {
322
+ const copy = deleteConfirmationCopy(3, "unavailable");
323
+ assert.ok(!copy.title.includes("Move"));
324
+ assert.ok(!copy.description.includes("restore"));
325
+ });
326
+
327
+ test("sends an unappointed Trash to the screen that appoints one", () => {
328
+ const copy = deleteConfirmationCopy(3, "noTrash");
329
+ assert.strictEqual(copy.title, "Can't delete 3 messages");
330
+ assert.match(copy.description, /appointed as Trash/);
331
+ assert.strictEqual(copy.confirmLabel, "Open folder settings");
332
+ });
333
+
334
+ test("never promises a restore when no Trash is appointed", () => {
335
+ const copy = deleteConfirmationCopy(3, "noTrash");
336
+ assert.ok(!copy.title.includes("Move"));
337
+ assert.ok(!copy.description.includes("restore"));
338
+ });
339
+ });
package/src/lib/format.ts CHANGED
@@ -168,12 +168,85 @@ export interface DeleteConfirmationCopy {
168
168
  }
169
169
 
170
170
  /**
171
- * What a delete will actually do to the selected mail. `unknown` is the state
172
- * before the account's Trash appointment has resolved — a real state on a cold
173
- * open, and the one the copy must not guess at, because guessing "move to
174
- * Trash" over an expunge is the dishonesty this whole flow exists to remove.
171
+ * What a delete will actually do to the selected mail.
172
+ *
173
+ * `unknown` is the state before the account's Trash appointment has resolved a
174
+ * real state on a cold open, and the one the copy must not guess at, because
175
+ * guessing "move to Trash" over an expunge is the dishonesty this whole flow
176
+ * exists to remove.
177
+ *
178
+ * `noTrash` is a resolved answer of "none": the account appoints no Trash, so
179
+ * the server refuses the delete outright (#846) rather than moving anything.
180
+ * It is a different fact from "this row is not in Trash" and must never share
181
+ * an outcome with it — one promises a restore that can happen, the other a
182
+ * delete that will not.
183
+ *
184
+ * `unavailable` is the appointment failing to resolve at all. A read that
185
+ * cannot answer is not an answer: treating an errored `/config` as "no Trash
186
+ * here" reinstates the same lie on the failure path, where an expired session
187
+ * would collect "Move to Trash?" over an expunge. The delete is refused and the
188
+ * failure is stated instead.
175
189
  */
176
- export type DeleteOutcome = "trash" | "permanent" | "unknown";
190
+ export type DeleteOutcome =
191
+ | "trash"
192
+ | "permanent"
193
+ | "noTrash"
194
+ | "unknown"
195
+ | "unavailable";
196
+
197
+ /** A row about to be deleted, and the account whose Trash decides its fate. */
198
+ export interface DeleteTarget {
199
+ accountId: string | undefined;
200
+ mailboxId: string;
201
+ }
202
+
203
+ export interface DeleteOutcomeInput {
204
+ targets: readonly DeleteTarget[];
205
+ /**
206
+ * Each account's appointed Trash. A key present with `undefined` is an
207
+ * account that appoints none — an answer, not a gap. A key absent is an
208
+ * account nothing is known about yet.
209
+ */
210
+ trashByAccount: ReadonlyMap<string, string | undefined>;
211
+ /**
212
+ * The appointments have actually arrived. Never `!isLoading`: React Query
213
+ * v5 leaves a paused offline query pending-but-not-fetching, which reads as
214
+ * loaded while `data` is still undefined.
215
+ */
216
+ hasAppointments: boolean;
217
+ /** The read for them failed. */
218
+ isError: boolean;
219
+ }
220
+
221
+ /**
222
+ * The outcome of deleting `targets`.
223
+ *
224
+ * One row bound for an expunge makes the whole delete unrecoverable, so a mixed
225
+ * set is permanent: the wording may overstate what is destroyed, never what is
226
+ * kept. One row on an account with no Trash refuses the whole call, so that
227
+ * outranks both. Pure, so every branch — the failure ones above all — is
228
+ * testable without a DOM.
229
+ */
230
+ export const deleteOutcomeFor = ({
231
+ targets,
232
+ trashByAccount,
233
+ hasAppointments,
234
+ isError,
235
+ }: DeleteOutcomeInput): DeleteOutcome => {
236
+ if (isError) return "unavailable";
237
+ if (!hasAppointments) return "unknown";
238
+ if (targets.length === 0) return "unknown";
239
+
240
+ let expunges = false;
241
+ for (const target of targets) {
242
+ if (target.accountId === undefined) return "unknown";
243
+ if (!trashByAccount.has(target.accountId)) return "unknown";
244
+ const trashMailboxId = trashByAccount.get(target.accountId);
245
+ if (trashMailboxId === undefined) return "noTrash";
246
+ if (trashMailboxId === target.mailboxId) expunges = true;
247
+ }
248
+ return expunges ? "permanent" : "trash";
249
+ };
177
250
 
178
251
  /**
179
252
  * The confirmation for a delete, worded for what the delete actually does.
@@ -181,6 +254,13 @@ export type DeleteOutcome = "trash" | "permanent" | "unknown";
181
254
  * nothing survives that, so it is asked as a permanent delete — a dialog that
182
255
  * says "Move to Trash" over an expunge collects an answer to a question the
183
256
  * user was never asked.
257
+ *
258
+ * `noTrash` and `unavailable` are not confirmations at all but refusals:
259
+ * nothing is deleted, and the label names the way out rather than the delete.
260
+ * The caller wires each to its own remedy — folder settings for the missing
261
+ * appointment, re-authentication for the failed read, because an account read
262
+ * that fails is a session that ended under the reader far more often than it is
263
+ * anything else.
184
264
  */
185
265
  export const deleteConfirmationCopy = (
186
266
  count: number,
@@ -189,6 +269,22 @@ export const deleteConfirmationCopy = (
189
269
  const quantity = count === 1 ? "1" : formatNumber(count);
190
270
  const noun = count === 1 ? "message" : "messages";
191
271
 
272
+ if (outcome === "noTrash") {
273
+ return {
274
+ title: `Can't delete ${quantity} ${noun}`,
275
+ description:
276
+ "No folder on this account is appointed as Trash, so there is nowhere to move the mail — and deleting it would erase it from the server instead. Appoint a Trash folder to delete from here.",
277
+ confirmLabel: "Open folder settings",
278
+ };
279
+ }
280
+ if (outcome === "unavailable") {
281
+ return {
282
+ title: `Can't delete ${quantity} ${noun}`,
283
+ description:
284
+ "reader couldn't read this account's folder settings, so it can't say whether this would move the mail to Trash or erase it. Nothing has been deleted.",
285
+ confirmLabel: "Sign in again",
286
+ };
287
+ }
192
288
  if (outcome === "unknown") {
193
289
  return {
194
290
  title: `Delete ${quantity} ${noun}?`,
@@ -7,9 +7,31 @@ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/type
7
7
  import type { QueryClient } from "@tanstack/react-query";
8
8
  import { patchThreadListCache, type ThreadListCache } from "./thread-cache.js";
9
9
 
10
- export interface ThreadListSnapshotEntry {
10
+ export interface SnapshotEntry<T> {
11
11
  queryKey: readonly unknown[];
12
- data: ThreadListCache;
12
+ data: T;
13
+ }
14
+
15
+ export type ThreadListSnapshotEntry = SnapshotEntry<ThreadListCache>;
16
+
17
+ /** A cached page of one thread's messages, as the thread-detail endpoint serves it. */
18
+ export interface ThreadMessagesData {
19
+ items: RemitImapThreadMessageResponse[];
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ /**
24
+ * The rollback state an optimistic thread mutation captures in `onMutate`.
25
+ *
26
+ * The shape belongs to the snapshot/restore helpers below rather than to any
27
+ * one mutation, so mark-read, star, delete and move all carry the same one
28
+ * (#868).
29
+ */
30
+ export interface ThreadMutationContext {
31
+ threadMessagesPrefix: readonly unknown[];
32
+ listPrefixes: ReadonlyArray<readonly unknown[]>;
33
+ previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
34
+ previousThreadsList: ThreadListSnapshotEntry[];
13
35
  }
14
36
 
15
37
  /**
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type {
8
8
  RemitImapAccountResponse,
9
+ RemitImapConfigDescriptionResponse,
9
10
  RemitImapMailboxResponse,
10
11
  RemitImapThreadMessageResponse,
11
12
  } from "@remit/api-http-client/types.gen.ts";
@@ -83,3 +84,16 @@ export const makeThreadMessage = (
83
84
  updatedAt: 0,
84
85
  ...overrides,
85
86
  });
87
+
88
+ export const makeConfig = (
89
+ accounts: RemitImapAccountResponse[],
90
+ ): RemitImapConfigDescriptionResponse => ({
91
+ accountConfig: {
92
+ accountConfigId: "cfg-1",
93
+ userId: "user-1",
94
+ state: "active",
95
+ createdAt: 0,
96
+ updatedAt: 0,
97
+ },
98
+ accounts,
99
+ });