@remit/web-client 0.0.195 → 0.0.197
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/intelligence-auto-open-pref.render.test.ts +412 -0
- package/src/components/mail/intelligence-shortcut-surface.render.test.ts +40 -10
- package/src/components/settings/DeleteFolderDialog.render.test.ts +220 -5
- package/src/components/settings/DeleteFolderDialog.tsx +90 -13
- package/src/hooks/search-mirror-convergence.render.test.ts +1 -0
- package/src/hooks/search-mirror-detail.render.test.ts +1 -0
- package/src/hooks/useDeleteFolder.ts +113 -0
- package/src/hooks/useIntelligenceSurface.ts +9 -4
- package/src/hooks/useRailPanels.ts +128 -0
- package/src/lib/delete-folder.test.ts +14 -0
- package/src/lib/delete-folder.ts +6 -0
- package/src/lib/fresh-mailbox-count.test.ts +145 -0
- package/src/lib/fresh-mailbox-count.ts +114 -0
- package/src/lib/intelligence-pref.test.ts +68 -1
- package/src/lib/intelligence-pref.ts +20 -6
- package/src/lib/mail-context.ts +8 -0
- package/src/lib/mailbox-sync-wait.ts +6 -2
- package/src/routes/mail.tsx +15 -68
|
@@ -64,6 +64,7 @@ i18n.use(initReactI18next).init({
|
|
|
64
64
|
|
|
65
65
|
let container: HTMLElement;
|
|
66
66
|
let root: Root;
|
|
67
|
+
let queryClient: QueryClient;
|
|
67
68
|
const originalFetch = globalThis.fetch;
|
|
68
69
|
|
|
69
70
|
interface FetchCall {
|
|
@@ -71,7 +72,7 @@ interface FetchCall {
|
|
|
71
72
|
method: string;
|
|
72
73
|
body: string;
|
|
73
74
|
}
|
|
74
|
-
type FetchRoute = (call: FetchCall) => Response
|
|
75
|
+
type FetchRoute = (call: FetchCall) => Response | Promise<Response>;
|
|
75
76
|
let route: FetchRoute = () => new Response("{}", { status: 200 });
|
|
76
77
|
|
|
77
78
|
const json = (body: unknown): Response =>
|
|
@@ -80,6 +81,31 @@ const json = (body: unknown): Response =>
|
|
|
80
81
|
headers: { "Content-Type": "application/json" },
|
|
81
82
|
});
|
|
82
83
|
|
|
84
|
+
/**
|
|
85
|
+
* `GET /sync/status`, the read the empty-folder delete waits on. `lastSyncedAt`
|
|
86
|
+
* only advances once `POST /sync` has been answered, so a count read before the
|
|
87
|
+
* trigger is distinguishable from one a triggered round wrote.
|
|
88
|
+
*/
|
|
89
|
+
const syncStatus = (
|
|
90
|
+
counts: Readonly<Record<string, number>>,
|
|
91
|
+
rounds: number,
|
|
92
|
+
) => ({
|
|
93
|
+
accountId: "acc-1",
|
|
94
|
+
syncPhase: "complete",
|
|
95
|
+
mailboxes: mailboxes.map((box) => ({
|
|
96
|
+
mailboxId: box.mailboxId,
|
|
97
|
+
fullPath: box.fullPath,
|
|
98
|
+
phase: "complete",
|
|
99
|
+
messagesTotal: counts[box.mailboxId] ?? box.messageCount,
|
|
100
|
+
messagesSynced: 0,
|
|
101
|
+
lastSyncedAt: 1000 + rounds,
|
|
102
|
+
})),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const isSyncStatus = (url: string): boolean => url.includes("/sync/status");
|
|
106
|
+
const isSyncTrigger = (url: string, method: string): boolean =>
|
|
107
|
+
method === "POST" && url.endsWith("/sync");
|
|
108
|
+
|
|
83
109
|
const threadItems = (ids: readonly string[]) => ({
|
|
84
110
|
items: ids.map((id) => ({
|
|
85
111
|
threadMessageId: `t-${id}`,
|
|
@@ -95,16 +121,22 @@ beforeEach(() => {
|
|
|
95
121
|
container = document.createElement("div");
|
|
96
122
|
document.body.appendChild(container);
|
|
97
123
|
root = createRoot(container);
|
|
124
|
+
queryClient = new QueryClient({
|
|
125
|
+
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
126
|
+
});
|
|
98
127
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
99
128
|
const request = input as Request;
|
|
100
129
|
const body = request.method === "GET" ? "" : await request.clone().text();
|
|
101
|
-
return route({ url: request.url, method: request.method, body });
|
|
130
|
+
return await route({ url: request.url, method: request.method, body });
|
|
102
131
|
}) as typeof fetch;
|
|
103
132
|
});
|
|
104
133
|
|
|
105
134
|
afterEach(() => {
|
|
106
135
|
act(() => root.unmount());
|
|
107
136
|
container.remove();
|
|
137
|
+
// Drops the cached queries with their gc timers, which otherwise hold the
|
|
138
|
+
// test process open for their full gcTime.
|
|
139
|
+
queryClient.clear();
|
|
108
140
|
globalThis.fetch = originalFetch;
|
|
109
141
|
});
|
|
110
142
|
|
|
@@ -122,7 +154,7 @@ const render = (props: {
|
|
|
122
154
|
{ i18n },
|
|
123
155
|
createElement(
|
|
124
156
|
QueryClientProvider,
|
|
125
|
-
{ client:
|
|
157
|
+
{ client: queryClient },
|
|
126
158
|
createElement(DeleteFolderDialog, {
|
|
127
159
|
open: props.open,
|
|
128
160
|
accountId: "acc-1",
|
|
@@ -353,9 +385,17 @@ describe("DeleteFolderDialog", () => {
|
|
|
353
385
|
|
|
354
386
|
it("deletes an empty folder and closes on success", async () => {
|
|
355
387
|
let closed = false;
|
|
356
|
-
|
|
388
|
+
let rounds = 0;
|
|
389
|
+
let triggered = 0;
|
|
390
|
+
route = ({ url, method }) => {
|
|
357
391
|
if (method === "DELETE") return new Response(null, { status: 204 });
|
|
358
|
-
return
|
|
392
|
+
if (isSyncStatus(url)) return json(syncStatus({}, rounds));
|
|
393
|
+
if (isSyncTrigger(url, method)) {
|
|
394
|
+
triggered += 1;
|
|
395
|
+
rounds += 1;
|
|
396
|
+
return json({ triggered: true, message: "ok" });
|
|
397
|
+
}
|
|
398
|
+
return json({ items: mailboxes });
|
|
359
399
|
};
|
|
360
400
|
render({
|
|
361
401
|
open: true,
|
|
@@ -368,9 +408,184 @@ describe("DeleteFolderDialog", () => {
|
|
|
368
408
|
buttonByText(/^Delete folder$/)?.click();
|
|
369
409
|
});
|
|
370
410
|
await flush();
|
|
411
|
+
assert.equal(triggered, 1, "the delete asks the server for a sync round");
|
|
371
412
|
assert.equal(closed, true);
|
|
372
413
|
});
|
|
373
414
|
|
|
415
|
+
it("refuses to delete a folder the server says still holds mail", async () => {
|
|
416
|
+
let deleted = false;
|
|
417
|
+
let closed = false;
|
|
418
|
+
let rounds = 0;
|
|
419
|
+
route = ({ url, method }) => {
|
|
420
|
+
if (method === "DELETE") {
|
|
421
|
+
deleted = true;
|
|
422
|
+
return new Response(null, { status: 204 });
|
|
423
|
+
}
|
|
424
|
+
// Mail landed in Empty since its last sync round, so the count the
|
|
425
|
+
// round reports contradicts the zero the dialog opened on.
|
|
426
|
+
if (isSyncStatus(url)) return json(syncStatus({ empty: 2 }, rounds));
|
|
427
|
+
if (isSyncTrigger(url, method)) {
|
|
428
|
+
rounds += 1;
|
|
429
|
+
return json({ triggered: true, message: "ok" });
|
|
430
|
+
}
|
|
431
|
+
return json({ items: mailboxes });
|
|
432
|
+
};
|
|
433
|
+
render({
|
|
434
|
+
open: true,
|
|
435
|
+
folder: mailboxes[2] as RemitImapMailboxResponse,
|
|
436
|
+
onClose: () => {
|
|
437
|
+
closed = true;
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
assert.match(container.textContent ?? "", /empty and will be removed/);
|
|
441
|
+
await act(async () => {
|
|
442
|
+
buttonByText(/^Delete folder$/)?.click();
|
|
443
|
+
});
|
|
444
|
+
await flush();
|
|
445
|
+
assert.equal(deleted, false, "a fresh count above zero blocks the delete");
|
|
446
|
+
assert.equal(closed, false, "the dialog stays open on the mail it found");
|
|
447
|
+
assert.match(container.textContent ?? "", /This folder is not empty/);
|
|
448
|
+
assert.match(container.textContent ?? "", /reports 2 emails/);
|
|
449
|
+
assert.match(container.textContent ?? "", /holds 2 emails/);
|
|
450
|
+
assert.ok(
|
|
451
|
+
buttonByText(/Move them to another folder/),
|
|
452
|
+
"the non-empty flow takes over",
|
|
453
|
+
);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("refuses the delete when the count cannot be read", async () => {
|
|
457
|
+
let deleted = false;
|
|
458
|
+
route = ({ url, method }) => {
|
|
459
|
+
if (method === "DELETE") {
|
|
460
|
+
deleted = true;
|
|
461
|
+
return new Response(null, { status: 204 });
|
|
462
|
+
}
|
|
463
|
+
if (isSyncStatus(url))
|
|
464
|
+
return new Response(JSON.stringify({ detail: "server exploded" }), {
|
|
465
|
+
status: 500,
|
|
466
|
+
headers: { "Content-Type": "application/json" },
|
|
467
|
+
});
|
|
468
|
+
if (isSyncTrigger(url, method))
|
|
469
|
+
return json({ triggered: true, message: "ok" });
|
|
470
|
+
return json({ items: mailboxes });
|
|
471
|
+
};
|
|
472
|
+
render({ open: true, folder: mailboxes[2] as RemitImapMailboxResponse });
|
|
473
|
+
await act(async () => {
|
|
474
|
+
buttonByText(/^Delete folder$/)?.click();
|
|
475
|
+
});
|
|
476
|
+
await flush();
|
|
477
|
+
assert.equal(deleted, false, "an unreadable count is not an empty folder");
|
|
478
|
+
assert.ok(
|
|
479
|
+
buttonByText(/^Close$/),
|
|
480
|
+
"the failure is stated, not swallowed into a delete",
|
|
481
|
+
);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("does not delete when the dialog is closed mid-check", async () => {
|
|
485
|
+
let deleted = false;
|
|
486
|
+
let closed = false;
|
|
487
|
+
let rounds = 0;
|
|
488
|
+
let releaseStatus: (() => void) | undefined;
|
|
489
|
+
route = ({ url, method }) => {
|
|
490
|
+
if (method === "DELETE") {
|
|
491
|
+
deleted = true;
|
|
492
|
+
return new Response(null, { status: 204 });
|
|
493
|
+
}
|
|
494
|
+
if (isSyncStatus(url)) {
|
|
495
|
+
const answer = json(syncStatus({}, rounds));
|
|
496
|
+
if (rounds === 0) return answer;
|
|
497
|
+
// The round has landed but the answer is still in flight; the user
|
|
498
|
+
// gets to cancel before it arrives.
|
|
499
|
+
return new Promise<Response>((resolve) => {
|
|
500
|
+
releaseStatus = () => resolve(answer);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
if (isSyncTrigger(url, method)) {
|
|
504
|
+
rounds += 1;
|
|
505
|
+
return json({ triggered: true, message: "ok" });
|
|
506
|
+
}
|
|
507
|
+
return json({ items: mailboxes });
|
|
508
|
+
};
|
|
509
|
+
render({
|
|
510
|
+
open: true,
|
|
511
|
+
folder: mailboxes[2] as RemitImapMailboxResponse,
|
|
512
|
+
onClose: () => {
|
|
513
|
+
closed = true;
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
await act(async () => {
|
|
517
|
+
buttonByText(/^Delete folder$/)?.click();
|
|
518
|
+
});
|
|
519
|
+
await flush();
|
|
520
|
+
assert.ok(releaseStatus, "the check is waiting on the server");
|
|
521
|
+
await act(async () => {
|
|
522
|
+
container
|
|
523
|
+
.querySelector<HTMLButtonElement>('button[aria-label="Cancel"]')
|
|
524
|
+
?.click();
|
|
525
|
+
});
|
|
526
|
+
await act(async () => {
|
|
527
|
+
releaseStatus?.();
|
|
528
|
+
});
|
|
529
|
+
await flush();
|
|
530
|
+
assert.equal(closed, true, "the cancel closes the dialog");
|
|
531
|
+
assert.equal(deleted, false, "a cancelled check never reaches the delete");
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("asks whether to keep waiting, and resumes without a second round", async () => {
|
|
535
|
+
let deleted = false;
|
|
536
|
+
let reported = false;
|
|
537
|
+
let triggered = 0;
|
|
538
|
+
route = ({ url, method }) => {
|
|
539
|
+
if (method === "DELETE") {
|
|
540
|
+
deleted = true;
|
|
541
|
+
return new Response(null, { status: 204 });
|
|
542
|
+
}
|
|
543
|
+
if (isSyncStatus(url)) return json(syncStatus({}, reported ? 1 : 0));
|
|
544
|
+
if (isSyncTrigger(url, method)) {
|
|
545
|
+
triggered += 1;
|
|
546
|
+
return json({ triggered: true, message: "ok" });
|
|
547
|
+
}
|
|
548
|
+
return json({ items: mailboxes });
|
|
549
|
+
};
|
|
550
|
+
render({ open: true, folder: mailboxes[2] as RemitImapMailboxResponse });
|
|
551
|
+
|
|
552
|
+
// A sync fans the whole account out, so the folder's round can land long
|
|
553
|
+
// after the trigger. Run the clock forward instead of waiting on it.
|
|
554
|
+
const realNow = Date.now;
|
|
555
|
+
let fake = realNow();
|
|
556
|
+
// Past the segment in one step, so the stall is reached without a poll
|
|
557
|
+
// sleeping through it.
|
|
558
|
+
Date.now = () => {
|
|
559
|
+
fake += 200_000;
|
|
560
|
+
return fake;
|
|
561
|
+
};
|
|
562
|
+
try {
|
|
563
|
+
await act(async () => {
|
|
564
|
+
buttonByText(/^Delete folder$/)?.click();
|
|
565
|
+
});
|
|
566
|
+
await flush();
|
|
567
|
+
} finally {
|
|
568
|
+
Date.now = realNow;
|
|
569
|
+
// The reads taken under the fake clock cached a future timestamp, which
|
|
570
|
+
// reads as fresh once the real clock is back; drop them so the resumed
|
|
571
|
+
// wait talks to the server again.
|
|
572
|
+
queryClient.clear();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
assert.equal(triggered, 1, "one round is asked for");
|
|
576
|
+
assert.equal(deleted, false, "an unreported folder is not deleted");
|
|
577
|
+
assert.match(container.textContent ?? "", /hasn't reported back/);
|
|
578
|
+
assert.ok(buttonByText(/^Keep waiting$/), "the wait is the user's call");
|
|
579
|
+
|
|
580
|
+
reported = true;
|
|
581
|
+
await act(async () => {
|
|
582
|
+
buttonByText(/^Keep waiting$/)?.click();
|
|
583
|
+
});
|
|
584
|
+
await flush();
|
|
585
|
+
assert.equal(triggered, 1, "resuming asks for no second round");
|
|
586
|
+
assert.equal(deleted, true, "the reported count settles the delete");
|
|
587
|
+
});
|
|
588
|
+
|
|
374
589
|
it("moves the mail in batches then deletes the emptied folder", async () => {
|
|
375
590
|
let closed = false;
|
|
376
591
|
let moved: string[] = [];
|
|
@@ -14,7 +14,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
14
14
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
15
15
|
import { useDeleteFolder } from "@/hooks/useDeleteFolder";
|
|
16
16
|
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
elapsedLabel,
|
|
19
|
+
initialStage,
|
|
20
|
+
moveProgressLabel,
|
|
21
|
+
} from "@/lib/delete-folder";
|
|
18
22
|
import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
|
|
19
23
|
import { buildMoveOptions } from "@/lib/move-options";
|
|
20
24
|
|
|
@@ -37,11 +41,14 @@ const emailCount = (count: number): string =>
|
|
|
37
41
|
`${count} ${count === 1 ? "email" : "emails"}`;
|
|
38
42
|
|
|
39
43
|
/**
|
|
40
|
-
* Per-folder delete wizard. An empty folder is a single destructive confirm
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
44
|
+
* Per-folder delete wizard. An empty folder is a single destructive confirm,
|
|
45
|
+
* settled against a sync round asked for on the spot rather than the folder's
|
|
46
|
+
* last synced count — a wait the user can watch, keep, or cancel, and which
|
|
47
|
+
* deletes nothing until the server answers; a folder with mail asks what
|
|
48
|
+
* happens to the messages first — delete them with the folder, or move them
|
|
49
|
+
* elsewhere (batched, with visible progress) before the now-empty folder is
|
|
50
|
+
* removed. Closing mid-move keeps
|
|
51
|
+
* already-moved mail moved; re-opening enumerates what's left and continues.
|
|
45
52
|
*/
|
|
46
53
|
export function DeleteFolderDialog({
|
|
47
54
|
open,
|
|
@@ -54,6 +61,7 @@ export function DeleteFolderDialog({
|
|
|
54
61
|
const [stage, setStage] = useState<FateStage>(() =>
|
|
55
62
|
initialStage(folder.messageCount),
|
|
56
63
|
);
|
|
64
|
+
const [arrivedSinceSync, setArrivedSinceSync] = useState<number>();
|
|
57
65
|
const [destinationId, setDestinationId] = useState<string>();
|
|
58
66
|
const { createFolderIn } = useCreateMailbox(accountId);
|
|
59
67
|
const translator = useFolderLabelTranslator();
|
|
@@ -61,7 +69,9 @@ export function DeleteFolderDialog({
|
|
|
61
69
|
phase,
|
|
62
70
|
progress,
|
|
63
71
|
errorMessage,
|
|
72
|
+
checkStartedAt,
|
|
64
73
|
deleteMailbox,
|
|
74
|
+
deleteIfEmpty,
|
|
65
75
|
moveThenDelete,
|
|
66
76
|
cancel,
|
|
67
77
|
reset,
|
|
@@ -70,12 +80,30 @@ export function DeleteFolderDialog({
|
|
|
70
80
|
mailboxId: folder.mailboxId,
|
|
71
81
|
onDeleted: onClose,
|
|
72
82
|
});
|
|
83
|
+
const [checkElapsed, setCheckElapsed] = useState("0:00");
|
|
84
|
+
|
|
85
|
+
// The check has no progress to report — only that it is still waiting — so
|
|
86
|
+
// the wait itself is what the surface shows, ticking, for as long as it runs.
|
|
87
|
+
const waiting = phase === "checking" || phase === "check-stalled";
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!waiting || checkStartedAt === undefined) return;
|
|
90
|
+
const update = () =>
|
|
91
|
+
setCheckElapsed(elapsedLabel(Date.now() - checkStartedAt));
|
|
92
|
+
update();
|
|
93
|
+
const timer = setInterval(update, 1000);
|
|
94
|
+
return () => clearInterval(timer);
|
|
95
|
+
}, [waiting, checkStartedAt]);
|
|
73
96
|
|
|
97
|
+
// Opening the dialog stages it; a count arriving later deliberately does not.
|
|
98
|
+
// The refusal below invalidates the folder list on purpose, and re-staging on
|
|
99
|
+
// the count that refetch brings back would wipe the refusal off the screen.
|
|
100
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: folder.messageCount is read at open and must not re-run this.
|
|
74
101
|
useEffect(() => {
|
|
75
102
|
if (!open) return;
|
|
76
103
|
setStage(initialStage(folder.messageCount));
|
|
104
|
+
setArrivedSinceSync(undefined);
|
|
77
105
|
reset();
|
|
78
|
-
}, [open,
|
|
106
|
+
}, [open, reset]);
|
|
79
107
|
|
|
80
108
|
useEffect(() => {
|
|
81
109
|
if (!open) return;
|
|
@@ -89,6 +117,13 @@ export function DeleteFolderDialog({
|
|
|
89
117
|
onClose();
|
|
90
118
|
}, [cancel, onClose]);
|
|
91
119
|
|
|
120
|
+
const handleDeleteEmpty = useCallback(async () => {
|
|
121
|
+
const outcome = await deleteIfEmpty();
|
|
122
|
+
if (outcome.status !== "blocked") return;
|
|
123
|
+
setArrivedSinceSync(outcome.messageCount);
|
|
124
|
+
setStage("choose-fate");
|
|
125
|
+
}, [deleteIfEmpty]);
|
|
126
|
+
|
|
92
127
|
const destinations = useMemo<FolderTreeNode[]>(
|
|
93
128
|
() =>
|
|
94
129
|
buildMoveOptions({
|
|
@@ -117,6 +152,7 @@ export function DeleteFolderDialog({
|
|
|
117
152
|
if (!open) return null;
|
|
118
153
|
|
|
119
154
|
const title = `Delete ${name}`;
|
|
155
|
+
const messageCount = arrivedSinceSync ?? folder.messageCount;
|
|
120
156
|
|
|
121
157
|
const body = (() => {
|
|
122
158
|
if (phase === "moving") {
|
|
@@ -131,6 +167,42 @@ export function DeleteFolderDialog({
|
|
|
131
167
|
);
|
|
132
168
|
}
|
|
133
169
|
|
|
170
|
+
if (phase === "checking") {
|
|
171
|
+
return (
|
|
172
|
+
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
173
|
+
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
174
|
+
<p className="text-sm font-medium text-fg">
|
|
175
|
+
Checking the folder on the mail server…
|
|
176
|
+
</p>
|
|
177
|
+
<p className="text-xs text-fg-muted" role="status" aria-live="polite">
|
|
178
|
+
{`Waiting ${checkElapsed}. Nothing is deleted until the server answers.`}
|
|
179
|
+
</p>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (phase === "check-stalled") {
|
|
185
|
+
return (
|
|
186
|
+
<div className="space-y-4 px-5 py-4">
|
|
187
|
+
<Banner tone="warning" variant="soft">
|
|
188
|
+
{`The mail server hasn't reported back on this folder yet — ${checkElapsed} so far. Nothing has been deleted.`}
|
|
189
|
+
</Banner>
|
|
190
|
+
<p className="text-xs text-fg-muted">
|
|
191
|
+
A sync covers the whole account, so a busy mailbox can take a while
|
|
192
|
+
to come round to this folder.
|
|
193
|
+
</p>
|
|
194
|
+
<div className="flex justify-end gap-2">
|
|
195
|
+
<Button variant="secondary" size="sm" onClick={handleClose}>
|
|
196
|
+
Cancel
|
|
197
|
+
</Button>
|
|
198
|
+
<Button size="sm" onClick={() => handleDeleteEmpty()}>
|
|
199
|
+
Keep waiting
|
|
200
|
+
</Button>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
134
206
|
if (phase === "deleting" || phase === "done") {
|
|
135
207
|
return (
|
|
136
208
|
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
@@ -178,7 +250,7 @@ export function DeleteFolderDialog({
|
|
|
178
250
|
variant="danger"
|
|
179
251
|
size="sm"
|
|
180
252
|
icon={<Trash2 className="size-3.5" />}
|
|
181
|
-
onClick={() =>
|
|
253
|
+
onClick={() => handleDeleteEmpty()}
|
|
182
254
|
>
|
|
183
255
|
Delete folder
|
|
184
256
|
</Button>
|
|
@@ -191,9 +263,14 @@ export function DeleteFolderDialog({
|
|
|
191
263
|
return (
|
|
192
264
|
<>
|
|
193
265
|
<div className="space-y-3 px-5 py-4 text-sm text-fg-muted">
|
|
266
|
+
{arrivedSinceSync !== undefined && (
|
|
267
|
+
<Banner tone="warning" variant="soft">
|
|
268
|
+
{`This folder is not empty: the mail server reports ${emailCount(arrivedSinceSync)} in it. Nothing was deleted.`}
|
|
269
|
+
</Banner>
|
|
270
|
+
)}
|
|
194
271
|
<p>
|
|
195
272
|
<strong className="text-fg">{name}</strong> holds{" "}
|
|
196
|
-
{emailCount(
|
|
273
|
+
{emailCount(messageCount)}. What should happen to them?
|
|
197
274
|
</p>
|
|
198
275
|
<div className="space-y-2">
|
|
199
276
|
<button
|
|
@@ -244,8 +321,8 @@ export function DeleteFolderDialog({
|
|
|
244
321
|
<div className="space-y-3 px-5 py-4 text-sm text-fg-muted">
|
|
245
322
|
<p>
|
|
246
323
|
Delete <strong className="text-fg">{name}</strong> and its{" "}
|
|
247
|
-
{emailCount(
|
|
248
|
-
|
|
324
|
+
{emailCount(messageCount)}? The messages are removed from the
|
|
325
|
+
server with the folder and can't be recovered.
|
|
249
326
|
</p>
|
|
250
327
|
</div>
|
|
251
328
|
<footer className="flex items-center justify-end gap-2 border-t border-line px-5 py-3">
|
|
@@ -272,7 +349,7 @@ export function DeleteFolderDialog({
|
|
|
272
349
|
return (
|
|
273
350
|
<div className="flex h-[26rem] flex-col">
|
|
274
351
|
<p className="px-5 pt-4 text-sm text-fg-muted">
|
|
275
|
-
Move the {emailCount(
|
|
352
|
+
Move the {emailCount(messageCount)} in{" "}
|
|
276
353
|
<strong className="text-fg">{name}</strong> to:
|
|
277
354
|
</p>
|
|
278
355
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
|
@@ -301,7 +378,7 @@ export function DeleteFolderDialog({
|
|
|
301
378
|
className="h-11 w-full font-semibold"
|
|
302
379
|
>
|
|
303
380
|
<span className="truncate">
|
|
304
|
-
{`Move ${emailCount(
|
|
381
|
+
{`Move ${emailCount(messageCount)} to ${destination.label}`}
|
|
305
382
|
</span>
|
|
306
383
|
</Button>
|
|
307
384
|
</footer>
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
configOperationsGetConfigQueryKey,
|
|
3
3
|
mailboxOperationsListMailboxesQueryKey,
|
|
4
|
+
syncOperationsGetSyncStatusOptions,
|
|
4
5
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
6
|
import {
|
|
6
7
|
mailboxDetailOperationsDeleteMailbox,
|
|
7
8
|
mailboxOperationsListMailboxes,
|
|
8
9
|
messageBulkOperationsMoveMessages,
|
|
10
|
+
syncOperationsTriggerSync,
|
|
9
11
|
threadOperationsListThreads,
|
|
10
12
|
} from "@remit/api-http-client/sdk.gen.ts";
|
|
11
13
|
import { useQueryClient } from "@tanstack/react-query";
|
|
@@ -17,6 +19,12 @@ import {
|
|
|
17
19
|
MOVE_BATCH_SIZE,
|
|
18
20
|
type MoveProgress,
|
|
19
21
|
} from "@/lib/delete-folder";
|
|
22
|
+
import {
|
|
23
|
+
awaitFreshMailboxCount,
|
|
24
|
+
type FreshCountOutcome,
|
|
25
|
+
type MailboxCountReading,
|
|
26
|
+
mailboxSyncStamp,
|
|
27
|
+
} from "@/lib/fresh-mailbox-count";
|
|
20
28
|
|
|
21
29
|
const PAGE_CAP = 50;
|
|
22
30
|
|
|
@@ -101,11 +109,25 @@ const liveMessageCount = async (
|
|
|
101
109
|
|
|
102
110
|
export type DeleteFolderPhase =
|
|
103
111
|
| "idle"
|
|
112
|
+
| "checking"
|
|
113
|
+
| "check-stalled"
|
|
104
114
|
| "moving"
|
|
105
115
|
| "deleting"
|
|
106
116
|
| "done"
|
|
107
117
|
| "error";
|
|
108
118
|
|
|
119
|
+
/**
|
|
120
|
+
* What a delete-as-empty did. `blocked` carries the count that stopped it;
|
|
121
|
+
* `pending` means the server has not reported yet and the user decides whether
|
|
122
|
+
* to wait on; `failed` means nothing was established. Neither is ever treated
|
|
123
|
+
* as empty.
|
|
124
|
+
*/
|
|
125
|
+
export type EmptyDeleteOutcome =
|
|
126
|
+
| { status: "deleted" }
|
|
127
|
+
| { status: "blocked"; messageCount: number }
|
|
128
|
+
| { status: "pending" }
|
|
129
|
+
| { status: "failed" };
|
|
130
|
+
|
|
109
131
|
interface UseDeleteFolderOptions {
|
|
110
132
|
accountId: string;
|
|
111
133
|
mailboxId: string;
|
|
@@ -121,7 +143,11 @@ export function useDeleteFolder({
|
|
|
121
143
|
const [phase, setPhase] = useState<DeleteFolderPhase>("idle");
|
|
122
144
|
const [progress, setProgress] = useState<MoveProgress | null>(null);
|
|
123
145
|
const [errorMessage, setErrorMessage] = useState<string>();
|
|
146
|
+
const [checkStartedAt, setCheckStartedAt] = useState<number>();
|
|
124
147
|
const abortRef = useRef<AbortController | null>(null);
|
|
148
|
+
/** The folder's sync stamp before the round was asked for; set while a check
|
|
149
|
+
* is running or paused, so resuming it re-uses the same baseline. */
|
|
150
|
+
const sinceRef = useRef<number | undefined>(undefined);
|
|
125
151
|
|
|
126
152
|
const invalidate = useCallback(() => {
|
|
127
153
|
queryClient.invalidateQueries({
|
|
@@ -163,6 +189,88 @@ export function useDeleteFolder({
|
|
|
163
189
|
[accountId, mailboxId],
|
|
164
190
|
);
|
|
165
191
|
|
|
192
|
+
const readMailboxSyncStatus = useCallback(
|
|
193
|
+
(): Promise<readonly MailboxCountReading[]> =>
|
|
194
|
+
queryClient
|
|
195
|
+
.fetchQuery({
|
|
196
|
+
...syncOperationsGetSyncStatusOptions({ path: { accountId } }),
|
|
197
|
+
staleTime: 0,
|
|
198
|
+
})
|
|
199
|
+
.then((data) => data.mailboxes ?? []),
|
|
200
|
+
[queryClient, accountId],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Delete a folder the user was told is empty. Every count the client holds is
|
|
205
|
+
* the last sync round's, so this waits (R2 of the IMAP mutation rules) for a
|
|
206
|
+
* round asked for here to report on the folder, and deletes only on the count
|
|
207
|
+
* that round read. A read failure or a folder gone from the account refuses
|
|
208
|
+
* the delete: not knowing what a folder holds is never permission.
|
|
209
|
+
*
|
|
210
|
+
* The round can take minutes — it fans the whole account out behind INBOX —
|
|
211
|
+
* so the wait runs in segments. A segment that ends unreported returns
|
|
212
|
+
* `pending` and the user decides; calling again resumes the same wait against
|
|
213
|
+
* the same baseline, and never asks for a second round.
|
|
214
|
+
*/
|
|
215
|
+
const deleteIfEmpty = useCallback(async (): Promise<EmptyDeleteOutcome> => {
|
|
216
|
+
const controller = new AbortController();
|
|
217
|
+
abortRef.current = controller;
|
|
218
|
+
const { signal } = controller;
|
|
219
|
+
setPhase("checking");
|
|
220
|
+
setErrorMessage(undefined);
|
|
221
|
+
|
|
222
|
+
const resuming = sinceRef.current !== undefined;
|
|
223
|
+
const counted = await attempt(
|
|
224
|
+
(async (): Promise<FreshCountOutcome> => {
|
|
225
|
+
if (!resuming) {
|
|
226
|
+
signal.throwIfAborted();
|
|
227
|
+
sinceRef.current = mailboxSyncStamp(
|
|
228
|
+
await readMailboxSyncStatus(),
|
|
229
|
+
mailboxId,
|
|
230
|
+
);
|
|
231
|
+
signal.throwIfAborted();
|
|
232
|
+
setCheckStartedAt(Date.now());
|
|
233
|
+
await syncOperationsTriggerSync({
|
|
234
|
+
path: { accountId },
|
|
235
|
+
throwOnError: true,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return awaitFreshMailboxCount({
|
|
239
|
+
readMailboxes: readMailboxSyncStatus,
|
|
240
|
+
mailboxId,
|
|
241
|
+
since: sinceRef.current ?? 0,
|
|
242
|
+
signal,
|
|
243
|
+
});
|
|
244
|
+
})(),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
if (signal.aborted) {
|
|
248
|
+
// The dialog is closing or the user cancelled; leave nothing running
|
|
249
|
+
// and no phase for a later caller to inherit.
|
|
250
|
+
setPhase("idle");
|
|
251
|
+
sinceRef.current = undefined;
|
|
252
|
+
return { status: "failed" };
|
|
253
|
+
}
|
|
254
|
+
if (!counted.ok) {
|
|
255
|
+
sinceRef.current = undefined;
|
|
256
|
+
setErrorMessage(counted.error);
|
|
257
|
+
setPhase("error");
|
|
258
|
+
return { status: "failed" };
|
|
259
|
+
}
|
|
260
|
+
if (counted.value.status === "pending") {
|
|
261
|
+
setPhase("check-stalled");
|
|
262
|
+
return { status: "pending" };
|
|
263
|
+
}
|
|
264
|
+
sinceRef.current = undefined;
|
|
265
|
+
if (counted.value.messageCount > 0) {
|
|
266
|
+
setPhase("idle");
|
|
267
|
+
invalidate();
|
|
268
|
+
return { status: "blocked", messageCount: counted.value.messageCount };
|
|
269
|
+
}
|
|
270
|
+
await deleteMailbox();
|
|
271
|
+
return { status: "deleted" };
|
|
272
|
+
}, [accountId, mailboxId, readMailboxSyncStatus, deleteMailbox, invalidate]);
|
|
273
|
+
|
|
166
274
|
const cancel = useCallback(() => {
|
|
167
275
|
abortRef.current?.abort();
|
|
168
276
|
}, []);
|
|
@@ -250,16 +358,21 @@ export function useDeleteFolder({
|
|
|
250
358
|
const reset = useCallback(() => {
|
|
251
359
|
abortRef.current?.abort();
|
|
252
360
|
abortRef.current = null;
|
|
361
|
+
sinceRef.current = undefined;
|
|
253
362
|
setPhase("idle");
|
|
254
363
|
setProgress(null);
|
|
255
364
|
setErrorMessage(undefined);
|
|
365
|
+
setCheckStartedAt(undefined);
|
|
256
366
|
}, []);
|
|
257
367
|
|
|
258
368
|
return {
|
|
259
369
|
phase,
|
|
260
370
|
progress,
|
|
261
371
|
errorMessage,
|
|
372
|
+
/** When the running check asked for its round; the surface shows the wait. */
|
|
373
|
+
checkStartedAt,
|
|
262
374
|
deleteMailbox,
|
|
375
|
+
deleteIfEmpty,
|
|
263
376
|
moveThenDelete,
|
|
264
377
|
cancel,
|
|
265
378
|
reset,
|