@remit/web-client 0.0.185 → 0.0.186
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/package.json +1 -1
- package/src/components/mail/DailyBrief.tsx +1 -0
- package/src/components/mail/DeleteConfirmDialog.render.test.ts +214 -0
- package/src/components/mail/DeleteConfirmDialog.tsx +95 -0
- package/src/components/mail/FlaggedList.tsx +1 -0
- package/src/components/mail/MessageList.run-reporting.test.ts +76 -14
- package/src/components/mail/MessageList.selection.test.ts +1 -1
- package/src/components/mail/MessageList.tsx +24 -37
- package/src/components/mail/ThreadListInteraction.test.ts +200 -20
- package/src/components/mail/ThreadListInteraction.tsx +67 -16
- package/src/hooks/useArchiveMailbox.ts +38 -8
- package/src/hooks/useDeleteOutcome.ts +35 -0
- package/src/lib/bulk-action-copy.ts +69 -3
- package/src/lib/format.test.ts +182 -0
- package/src/lib/format.ts +101 -5
- package/src/test-support/fixtures.ts +14 -0
|
@@ -6,13 +6,22 @@
|
|
|
6
6
|
* their headers, so what the consumer passed and what is rendered are different
|
|
7
7
|
* lists. These cases mount rows directly and change them, which is the same
|
|
8
8
|
* thing from the provider's point of view.
|
|
9
|
+
*
|
|
10
|
+
* The confirmation is worded from the folder the row is actually filed in
|
|
11
|
+
* (#855): this list spans mailboxes and accounts, and deleting mail that is
|
|
12
|
+
* already in Trash expunges it on the server rather than moving it.
|
|
9
13
|
*/
|
|
10
14
|
import assert from "node:assert/strict";
|
|
11
15
|
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
12
|
-
import
|
|
16
|
+
import { configOperationsGetConfigQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
17
|
+
import type { ThreadRowData, Verb } from "@remit/ui";
|
|
18
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
13
19
|
import type { JSDOM } from "jsdom";
|
|
14
20
|
import { act, createElement, createRef, useState } from "react";
|
|
15
21
|
import { createRoot, type Root } from "react-dom/client";
|
|
22
|
+
import { AuthProviderProvider, noneAuthProvider } from "@/auth/provider";
|
|
23
|
+
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
24
|
+
import { makeAccount, makeConfig } from "@/test-support/fixtures";
|
|
16
25
|
import type { MessageListCommands } from "./MessageList";
|
|
17
26
|
import {
|
|
18
27
|
ThreadListInteraction,
|
|
@@ -57,6 +66,39 @@ afterEach(() => {
|
|
|
57
66
|
act(() => root.unmount());
|
|
58
67
|
});
|
|
59
68
|
|
|
69
|
+
const TRASH_MAILBOX_ID = "mbx-trash";
|
|
70
|
+
|
|
71
|
+
const row = (id: string, mailboxId = "mbx-inbox"): ThreadRowData => ({
|
|
72
|
+
id,
|
|
73
|
+
accountId: "acc-1",
|
|
74
|
+
mailboxId,
|
|
75
|
+
threadId: `t-${id}`,
|
|
76
|
+
fromName: "Alice",
|
|
77
|
+
fromEmail: "alice@example.com",
|
|
78
|
+
subject: "Quarterly report",
|
|
79
|
+
snippet: "",
|
|
80
|
+
timeLabel: "9:42",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A client that already holds the account's folder appointments, so the
|
|
85
|
+
* confirmation's outcome is settled on the first render rather than arriving a
|
|
86
|
+
* frame later.
|
|
87
|
+
*/
|
|
88
|
+
const seededClient = (trashMailboxId: string): QueryClient => {
|
|
89
|
+
const client = new QueryClient();
|
|
90
|
+
client.setQueryData(
|
|
91
|
+
configOperationsGetConfigQueryKey(),
|
|
92
|
+
makeConfig([
|
|
93
|
+
makeAccount({
|
|
94
|
+
accountId: "acc-1",
|
|
95
|
+
folderAppointments: [{ role: "Trash", mailboxId: trashMailboxId }],
|
|
96
|
+
}),
|
|
97
|
+
]),
|
|
98
|
+
);
|
|
99
|
+
return client;
|
|
100
|
+
};
|
|
101
|
+
|
|
60
102
|
const rowElements = (ids: string[]) =>
|
|
61
103
|
ids.map((id) =>
|
|
62
104
|
createElement("button", { key: id, type: "button", "data-message-id": id }),
|
|
@@ -68,26 +110,44 @@ const rowElements = (ids: string[]) =>
|
|
|
68
110
|
*/
|
|
69
111
|
function mountList(options: {
|
|
70
112
|
initialIds: string[];
|
|
113
|
+
rows?: readonly ThreadRowData[];
|
|
114
|
+
client?: QueryClient;
|
|
71
115
|
onDeleteMessages?: (ids: string[]) => void;
|
|
72
116
|
onSelectionVerb?: (verb: Verb) => void;
|
|
73
117
|
}) {
|
|
74
118
|
const onDeleteMessages = options.onDeleteMessages ?? (() => undefined);
|
|
75
119
|
const onSelectionVerb = options.onSelectionVerb ?? (() => undefined);
|
|
120
|
+
const rows = options.rows ?? options.initialIds.map((id) => row(id));
|
|
121
|
+
const client = options.client ?? seededClient(TRASH_MAILBOX_ID);
|
|
122
|
+
const authProvider = noneAuthProvider;
|
|
76
123
|
const commandsRef = createRef<MessageListCommands | null>();
|
|
77
124
|
let setIds: ((ids: string[]) => void) | undefined;
|
|
78
125
|
const Harness = () => {
|
|
79
126
|
const [ids, set] = useState(options.initialIds);
|
|
80
127
|
setIds = set;
|
|
81
128
|
return createElement(
|
|
82
|
-
|
|
83
|
-
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
129
|
+
AuthProviderProvider,
|
|
130
|
+
{ value: authProvider },
|
|
131
|
+
createElement(
|
|
132
|
+
QueryClientProvider,
|
|
133
|
+
{ client },
|
|
134
|
+
createElement(
|
|
135
|
+
ErrorBannerProvider,
|
|
136
|
+
null,
|
|
137
|
+
createElement(
|
|
138
|
+
ThreadListInteraction,
|
|
139
|
+
{
|
|
140
|
+
selectedMessageId: undefined,
|
|
141
|
+
rows,
|
|
142
|
+
onOpen: () => undefined,
|
|
143
|
+
onDeleteMessages,
|
|
144
|
+
onSelectionVerb,
|
|
145
|
+
commandsRef,
|
|
146
|
+
},
|
|
147
|
+
...rowElements(ids),
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
),
|
|
91
151
|
);
|
|
92
152
|
};
|
|
93
153
|
act(() => root.render(createElement(Harness)));
|
|
@@ -271,6 +331,111 @@ describe("ThreadListInteraction — delete confirms first", () => {
|
|
|
271
331
|
});
|
|
272
332
|
});
|
|
273
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Issue #855. Flagged and the brief span mailboxes, so a row here can already
|
|
336
|
+
* be in its account's Trash — where the same keypress is an IMAP expunge, not a
|
|
337
|
+
* move. The confirmation used to promise "Move to Trash" over every one of
|
|
338
|
+
* them, collecting an answer to a question the user was never asked.
|
|
339
|
+
*/
|
|
340
|
+
describe("ThreadListInteraction — the confirmation states the outcome it will produce", () => {
|
|
341
|
+
const dialogText = (): string => dom.window.document.body.textContent ?? "";
|
|
342
|
+
|
|
343
|
+
const askDelete = (list: ReturnType<typeof mountList>) => {
|
|
344
|
+
act(() => list.commands().focusFirst());
|
|
345
|
+
act(() => {
|
|
346
|
+
list.commands().requestVerb("delete");
|
|
347
|
+
});
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
it("asks a row already in Trash as a permanent delete", () => {
|
|
351
|
+
const list = mountList({
|
|
352
|
+
initialIds: ["m1", "m2"],
|
|
353
|
+
rows: [row("m1", TRASH_MAILBOX_ID), row("m2")],
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
askDelete(list);
|
|
357
|
+
|
|
358
|
+
const text = dialogText();
|
|
359
|
+
assert.match(text, /Permanently delete 1 message\?/);
|
|
360
|
+
assert.match(text, /cannot be restored/);
|
|
361
|
+
assert.doesNotMatch(
|
|
362
|
+
text,
|
|
363
|
+
/Move 1 message to Trash\?/,
|
|
364
|
+
"a move is not what this delete does",
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("still offers the reversible move for a row filed outside Trash", () => {
|
|
369
|
+
const list = mountList({
|
|
370
|
+
initialIds: ["m1"],
|
|
371
|
+
rows: [row("m1")],
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
askDelete(list);
|
|
375
|
+
|
|
376
|
+
assert.match(dialogText(), /Move 1 message to Trash\?/);
|
|
377
|
+
assert.match(dialogText(), /restore them from Trash later/);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("refuses a row with no account the same way", () => {
|
|
381
|
+
const deleted: string[][] = [];
|
|
382
|
+
const list = mountList({
|
|
383
|
+
initialIds: ["m1"],
|
|
384
|
+
rows: [{ ...row("m1"), accountId: undefined }],
|
|
385
|
+
onDeleteMessages: (ids) => deleted.push(ids),
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
act(() => list.commands().focusFirst());
|
|
389
|
+
act(() => {
|
|
390
|
+
assert.equal(list.commands().requestVerb("delete"), true);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
assert.match(dialogText(), /Couldn.t delete this message/);
|
|
394
|
+
assert.deepEqual(deleted, []);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("refuses a row it cannot place instead of opening a dialog nobody can answer", () => {
|
|
398
|
+
const deleted: string[][] = [];
|
|
399
|
+
const list = mountList({
|
|
400
|
+
initialIds: ["m1"],
|
|
401
|
+
rows: [],
|
|
402
|
+
onDeleteMessages: (ids) => deleted.push(ids),
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
act(() => list.commands().focusFirst());
|
|
406
|
+
act(() => {
|
|
407
|
+
assert.equal(
|
|
408
|
+
list.commands().requestVerb("delete"),
|
|
409
|
+
true,
|
|
410
|
+
"the press is still claimed — handing it back runs the pane’s own unconfirmed delete",
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const text = dialogText();
|
|
415
|
+
assert.match(text, /Couldn.t delete this message/);
|
|
416
|
+
assert.match(text, /Nothing was deleted/);
|
|
417
|
+
assert.ok(
|
|
418
|
+
Array.from(
|
|
419
|
+
dom.window.document.querySelectorAll<HTMLAnchorElement>("a"),
|
|
420
|
+
).some((link) => link.textContent === "Reload the list"),
|
|
421
|
+
"the refusal offers the control its own sentence names",
|
|
422
|
+
);
|
|
423
|
+
assert.doesNotMatch(
|
|
424
|
+
text,
|
|
425
|
+
/Checking where this account files deleted mail/,
|
|
426
|
+
"no load is happening, so nothing may claim one is",
|
|
427
|
+
);
|
|
428
|
+
assert.equal(
|
|
429
|
+
Array.from(
|
|
430
|
+
dom.window.document.querySelectorAll<HTMLButtonElement>("button"),
|
|
431
|
+
).some((button) => button.textContent === "Move to Trash"),
|
|
432
|
+
false,
|
|
433
|
+
"and no confirmation stands behind the refusal",
|
|
434
|
+
);
|
|
435
|
+
assert.deepEqual(deleted, [], "nothing is deleted by a refusal");
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
274
439
|
/**
|
|
275
440
|
* A background refresh drops the ids that left and keeps every survivor — the
|
|
276
441
|
* same intersect-on-refresh guarantee `useSelection.test.ts` locks for the pure
|
|
@@ -280,6 +445,8 @@ describe("ThreadListInteraction — delete confirms first", () => {
|
|
|
280
445
|
*/
|
|
281
446
|
function mountSelectableList(initialIds: string[]) {
|
|
282
447
|
const commandsRef = createRef<MessageListCommands | null>();
|
|
448
|
+
const client = seededClient(TRASH_MAILBOX_ID);
|
|
449
|
+
const authProvider = noneAuthProvider;
|
|
283
450
|
let setIds: ((ids: string[]) => void) | undefined;
|
|
284
451
|
let selected: string[] = [];
|
|
285
452
|
const Probe = () => {
|
|
@@ -291,16 +458,29 @@ function mountSelectableList(initialIds: string[]) {
|
|
|
291
458
|
const [ids, set] = useState(initialIds);
|
|
292
459
|
setIds = set;
|
|
293
460
|
return createElement(
|
|
294
|
-
|
|
295
|
-
{
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
461
|
+
AuthProviderProvider,
|
|
462
|
+
{ value: authProvider },
|
|
463
|
+
createElement(
|
|
464
|
+
QueryClientProvider,
|
|
465
|
+
{ client },
|
|
466
|
+
createElement(
|
|
467
|
+
ErrorBannerProvider,
|
|
468
|
+
null,
|
|
469
|
+
createElement(
|
|
470
|
+
ThreadListInteraction,
|
|
471
|
+
{
|
|
472
|
+
selectedMessageId: undefined,
|
|
473
|
+
rows: initialIds.map((id) => row(id)),
|
|
474
|
+
onOpen: () => undefined,
|
|
475
|
+
onDeleteMessages: () => undefined,
|
|
476
|
+
onSelectionVerb: () => undefined,
|
|
477
|
+
commandsRef,
|
|
478
|
+
},
|
|
479
|
+
...rowElements(ids),
|
|
480
|
+
createElement(Probe, { key: "probe" }),
|
|
481
|
+
),
|
|
482
|
+
),
|
|
483
|
+
),
|
|
304
484
|
);
|
|
305
485
|
};
|
|
306
486
|
act(() => root.render(createElement(Harness)));
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* verb acts on a message the user cannot see.
|
|
18
18
|
*/
|
|
19
19
|
import {
|
|
20
|
-
ConfirmDialog,
|
|
21
20
|
SelectionTopBar,
|
|
21
|
+
type ThreadRowData,
|
|
22
22
|
useListCursor,
|
|
23
23
|
type Verb,
|
|
24
24
|
} from "@remit/ui";
|
|
@@ -33,15 +33,27 @@ import {
|
|
|
33
33
|
useRef,
|
|
34
34
|
useState,
|
|
35
35
|
} from "react";
|
|
36
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
37
|
+
import { useDeleteOutcome } from "@/hooks/useDeleteOutcome";
|
|
36
38
|
import { useFollowFocusOpen } from "@/hooks/useFollowFocusOpen";
|
|
37
39
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
38
40
|
import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
|
|
39
|
-
import {
|
|
41
|
+
import type { DeleteTarget } from "@/lib/format";
|
|
40
42
|
import { tabStopId } from "@/lib/list-focus";
|
|
41
43
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
44
|
+
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
|
|
42
45
|
import type { MessageListCommands } from "./MessageList";
|
|
43
46
|
import type { MessageRowSelection } from "./MessageRow";
|
|
44
47
|
|
|
48
|
+
/** Nothing pending, as a stable identity so the outcome memo does not churn. */
|
|
49
|
+
const NO_TARGETS: readonly DeleteTarget[] = [];
|
|
50
|
+
|
|
51
|
+
interface PendingDelete {
|
|
52
|
+
ids: string[];
|
|
53
|
+
/** Where those rows were filed when the delete was asked for. */
|
|
54
|
+
targets: DeleteTarget[];
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
interface ThreadRowInteraction {
|
|
46
58
|
focused: boolean;
|
|
47
59
|
isTabStop: boolean;
|
|
@@ -147,6 +159,13 @@ export interface OpenMessageOptions {
|
|
|
147
159
|
|
|
148
160
|
interface ThreadListInteractionProps {
|
|
149
161
|
selectedMessageId: string | undefined;
|
|
162
|
+
/**
|
|
163
|
+
* The rows this list is showing. Read only to name the folder a row is
|
|
164
|
+
* filed in, which is what decides whether deleting it moves it to Trash or
|
|
165
|
+
* expunges it — this list spans mailboxes and accounts, so the answer is per
|
|
166
|
+
* row and there is no route mailbox to take it from (#855).
|
|
167
|
+
*/
|
|
168
|
+
rows: readonly ThreadRowData[];
|
|
150
169
|
/** Opens a row — the same navigation a click performs. */
|
|
151
170
|
onOpen: (messageId: string, options?: OpenMessageOptions) => void;
|
|
152
171
|
/** Deletes a set of messages. Absent disables the delete key for this list. */
|
|
@@ -168,6 +187,7 @@ interface ThreadListInteractionProps {
|
|
|
168
187
|
|
|
169
188
|
export function ThreadListInteraction({
|
|
170
189
|
selectedMessageId,
|
|
190
|
+
rows,
|
|
171
191
|
onOpen,
|
|
172
192
|
onDeleteMessages,
|
|
173
193
|
onSelectionVerb,
|
|
@@ -178,6 +198,7 @@ export function ThreadListInteraction({
|
|
|
178
198
|
children,
|
|
179
199
|
}: ThreadListInteractionProps) {
|
|
180
200
|
const isDesktop = useIsDesktop();
|
|
201
|
+
const { pushError } = useErrorBanners();
|
|
181
202
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
182
203
|
const orderedIds = useRenderedRowIds(containerRef);
|
|
183
204
|
const cursor = useListCursor({
|
|
@@ -255,11 +276,16 @@ export function ThreadListInteraction({
|
|
|
255
276
|
open: followOpen,
|
|
256
277
|
});
|
|
257
278
|
|
|
258
|
-
// Pending
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
279
|
+
// Pending delete for the row under the cursor, awaiting confirmation. Both
|
|
280
|
+
// the id and the folder it is filed in are snapshotted at request time, so
|
|
281
|
+
// neither a cursor move nor a background refresh behind the dialog can
|
|
282
|
+
// retarget it or change the question it is asking. A delete over a selection
|
|
283
|
+
// is a bulk action and walks the wizard instead — the same contract the
|
|
284
|
+
// mailbox list's delete has.
|
|
285
|
+
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(
|
|
286
|
+
null,
|
|
287
|
+
);
|
|
288
|
+
const deleteOutcome = useDeleteOutcome(pendingDelete?.targets ?? NO_TARGETS);
|
|
263
289
|
|
|
264
290
|
// A verb, routed the same way the bar routes its own (#477 1.4, #508). Over a
|
|
265
291
|
// selection every verb opens the wizard, so the keyboard cannot reach a bulk
|
|
@@ -276,15 +302,42 @@ export function ThreadListInteraction({
|
|
|
276
302
|
return true;
|
|
277
303
|
}
|
|
278
304
|
if (verb !== "delete" || !focusedMessageId) return false;
|
|
279
|
-
|
|
305
|
+
// A row this list cannot place is a row whose delete cannot be worded,
|
|
306
|
+
// and a confirmation nobody can answer is worse than no confirmation.
|
|
307
|
+
// The press is still claimed — handing it back runs the pane's own
|
|
308
|
+
// unconfirmed delete — and the refusal is said out loud, with the one
|
|
309
|
+
// control that can actually change the answer.
|
|
310
|
+
const pending = rows.find((row) => row.id === focusedMessageId);
|
|
311
|
+
if (!pending?.mailboxId || !pending.accountId) {
|
|
312
|
+
pushError({
|
|
313
|
+
title: "Couldn't delete this message",
|
|
314
|
+
detail:
|
|
315
|
+
"This list has lost track of which account and folder it is in, so reader can't tell whether deleting it would move it to Trash or erase it. Nothing was deleted.",
|
|
316
|
+
action: { label: "Reload the list", href: window.location.href },
|
|
317
|
+
});
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
setPendingDelete({
|
|
321
|
+
ids: [focusedMessageId],
|
|
322
|
+
targets: [
|
|
323
|
+
{ accountId: pending.accountId, mailboxId: pending.mailboxId },
|
|
324
|
+
],
|
|
325
|
+
});
|
|
280
326
|
return true;
|
|
281
327
|
},
|
|
282
|
-
[
|
|
328
|
+
[
|
|
329
|
+
pendingDelete,
|
|
330
|
+
selectedCount,
|
|
331
|
+
onSelectionVerb,
|
|
332
|
+
focusedMessageId,
|
|
333
|
+
rows,
|
|
334
|
+
pushError,
|
|
335
|
+
],
|
|
283
336
|
);
|
|
284
337
|
|
|
285
338
|
const confirmDelete = useCallback(() => {
|
|
286
339
|
if (pendingDelete === null) return;
|
|
287
|
-
onDeleteMessages(pendingDelete);
|
|
340
|
+
onDeleteMessages(pendingDelete.ids);
|
|
288
341
|
setPendingDelete(null);
|
|
289
342
|
exitSelection();
|
|
290
343
|
}, [pendingDelete, onDeleteMessages, exitSelection]);
|
|
@@ -416,13 +469,11 @@ export function ThreadListInteraction({
|
|
|
416
469
|
<div ref={containerRef} className="contents">
|
|
417
470
|
{children}
|
|
418
471
|
</div>
|
|
419
|
-
<
|
|
472
|
+
<DeleteConfirmDialog
|
|
420
473
|
isOpen={confirmOpen}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
destructive
|
|
425
|
-
isBusy={isDeleting}
|
|
474
|
+
count={pendingDelete?.ids.length ?? 0}
|
|
475
|
+
outcome={deleteOutcome}
|
|
476
|
+
isDeleting={isDeleting}
|
|
426
477
|
onConfirm={confirmDelete}
|
|
427
478
|
onCancel={cancelDelete}
|
|
428
479
|
/>
|
|
@@ -81,15 +81,45 @@ export const useJunkMailbox = (
|
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
*
|
|
85
|
-
* needs it to tell a move-to-Trash apart from a delete inside
|
|
86
|
-
* an unrecoverable expunge and has to be asked as one (#845)
|
|
84
|
+
* Each account's appointed Trash mailbox, keyed by account. The delete
|
|
85
|
+
* confirmation needs it to tell a move-to-Trash apart from a delete inside
|
|
86
|
+
* Trash, which is an unrecoverable expunge and has to be asked as one (#845),
|
|
87
|
+
* and apart from an account that appoints no Trash at all, where the server
|
|
88
|
+
* refuses the delete outright (#846).
|
|
89
|
+
*
|
|
90
|
+
* A map rather than a set of ids, because the brief and Flagged answer for
|
|
91
|
+
* selections that span accounts (#855): "is this row in Trash" is a question
|
|
92
|
+
* about the row's own account, and "does any Trash exist" has to be answerable
|
|
93
|
+
* as "no" rather than as silence.
|
|
94
|
+
*
|
|
95
|
+
* `hasAppointments` is data presence, never `!isLoading`. React Query v5
|
|
96
|
+
* computes `isLoading` as `isPending && isFetching`, so an offline query is
|
|
97
|
+
* paused and reports neither loading nor error while `data` is still
|
|
98
|
+
* undefined — which read as "no Trash anywhere", and promised a reversible
|
|
99
|
+
* move over an expunge that would replay on reconnect.
|
|
87
100
|
*/
|
|
88
|
-
export const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
export const useTrashByAccount = (): {
|
|
102
|
+
trashByAccount: ReadonlyMap<string, string | undefined>;
|
|
103
|
+
hasAppointments: boolean;
|
|
104
|
+
isError: boolean;
|
|
105
|
+
} => {
|
|
106
|
+
const { data: config, isError } = useQuery({
|
|
107
|
+
...configOperationsGetConfigOptions(),
|
|
108
|
+
staleTime: Infinity,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const trashByAccount = useMemo(() => {
|
|
112
|
+
const byAccount = new Map<string, string | undefined>();
|
|
113
|
+
for (const account of config?.accounts ?? []) {
|
|
114
|
+
byAccount.set(
|
|
115
|
+
account.accountId,
|
|
116
|
+
account.folderAppointments.find((fa) => fa.role === "Trash")?.mailboxId,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return byAccount;
|
|
120
|
+
}, [config]);
|
|
121
|
+
|
|
122
|
+
return { trashByAccount, hasAppointments: config !== undefined, isError };
|
|
93
123
|
};
|
|
94
124
|
|
|
95
125
|
/**
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a delete is about to do to a specific set of rows — the one derivation
|
|
3
|
+
* every delete confirmation words itself from (#845, #855).
|
|
4
|
+
*
|
|
5
|
+
* `MessageMoveService.deleteMessages` moves a message to its account's Trash
|
|
6
|
+
* unless it is already there, in which case the same keypress is an IMAP
|
|
7
|
+
* expunge and nothing survives it; and it refuses the call outright when the
|
|
8
|
+
* account appoints no Trash. That branch is per message and per account, not
|
|
9
|
+
* per view, so the mailbox list, the brief and Flagged all have to ask it of
|
|
10
|
+
* the rows they are actually about to delete — the brief and Flagged hold rows
|
|
11
|
+
* from several mailboxes and several accounts at once.
|
|
12
|
+
*
|
|
13
|
+
* The decision itself is `deleteOutcomeFor`, kept pure in `lib/format`; this is
|
|
14
|
+
* only the read that feeds it.
|
|
15
|
+
*/
|
|
16
|
+
import { useMemo } from "react";
|
|
17
|
+
import {
|
|
18
|
+
type DeleteOutcome,
|
|
19
|
+
type DeleteTarget,
|
|
20
|
+
deleteOutcomeFor,
|
|
21
|
+
} from "@/lib/format";
|
|
22
|
+
import { useTrashByAccount } from "./useArchiveMailbox";
|
|
23
|
+
|
|
24
|
+
/** The outcome of deleting `targets`. */
|
|
25
|
+
export const useDeleteOutcome = (
|
|
26
|
+
targets: readonly DeleteTarget[],
|
|
27
|
+
): DeleteOutcome => {
|
|
28
|
+
const { trashByAccount, hasAppointments, isError } = useTrashByAccount();
|
|
29
|
+
|
|
30
|
+
return useMemo(
|
|
31
|
+
() =>
|
|
32
|
+
deleteOutcomeFor({ targets, trashByAccount, hasAppointments, isError }),
|
|
33
|
+
[targets, trashByAccount, hasAppointments, isError],
|
|
34
|
+
);
|
|
35
|
+
};
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
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)} ${
|
|
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)} ${
|
|
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
|
+
};
|