@remit/web-client 0.0.71 → 0.0.72
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/settings/DeleteFolderDialog.render.test.ts +467 -0
- package/src/components/settings/DeleteFolderDialog.tsx +308 -0
- package/src/hooks/useDeleteFolder.ts +267 -0
- package/src/lib/delete-folder.test.ts +186 -0
- package/src/lib/delete-folder.ts +151 -0
- package/src/routes/settings/folders.tsx +51 -13
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapFolderAppointment,
|
|
3
|
+
RemitImapMailboxResponse,
|
|
4
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import {
|
|
6
|
+
Banner,
|
|
7
|
+
Button,
|
|
8
|
+
Dialog,
|
|
9
|
+
type MoveMailboxOption,
|
|
10
|
+
MoveMailboxPicker,
|
|
11
|
+
} from "@remit/ui";
|
|
12
|
+
import { AlertTriangle, FolderInput, Loader2, Trash2, X } from "lucide-react";
|
|
13
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
14
|
+
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
15
|
+
import { useDeleteFolder } from "@/hooks/useDeleteFolder";
|
|
16
|
+
import {
|
|
17
|
+
excludeFolder,
|
|
18
|
+
initialStage,
|
|
19
|
+
moveProgressLabel,
|
|
20
|
+
} from "@/lib/delete-folder";
|
|
21
|
+
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
22
|
+
import { buildMoveTargets } from "@/lib/move-targets";
|
|
23
|
+
|
|
24
|
+
interface DeleteFolderDialogProps {
|
|
25
|
+
open: boolean;
|
|
26
|
+
accountId: string;
|
|
27
|
+
folder: RemitImapMailboxResponse;
|
|
28
|
+
mailboxes: readonly RemitImapMailboxResponse[];
|
|
29
|
+
appointments: readonly RemitImapFolderAppointment[];
|
|
30
|
+
onClose: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type FateStage =
|
|
34
|
+
| "confirm-empty"
|
|
35
|
+
| "choose-fate"
|
|
36
|
+
| "confirm-delete-all"
|
|
37
|
+
| "pick-destination";
|
|
38
|
+
|
|
39
|
+
const folderLabel = (folder: RemitImapMailboxResponse): string =>
|
|
40
|
+
folder.displayNameOverride?.trim() || getMailboxDisplayName(folder.fullPath);
|
|
41
|
+
|
|
42
|
+
const emailCount = (count: number): string =>
|
|
43
|
+
`${count} ${count === 1 ? "email" : "emails"}`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Per-folder delete wizard. An empty folder is a single destructive confirm; a
|
|
47
|
+
* folder with mail first asks what happens to the messages — delete them with
|
|
48
|
+
* the folder, or move them elsewhere (batched, with visible progress) before
|
|
49
|
+
* the now-empty folder is removed. Closing mid-move keeps already-moved mail
|
|
50
|
+
* moved; re-opening enumerates what's left and continues.
|
|
51
|
+
*/
|
|
52
|
+
export function DeleteFolderDialog({
|
|
53
|
+
open,
|
|
54
|
+
accountId,
|
|
55
|
+
folder,
|
|
56
|
+
mailboxes,
|
|
57
|
+
appointments,
|
|
58
|
+
onClose,
|
|
59
|
+
}: DeleteFolderDialogProps) {
|
|
60
|
+
const [stage, setStage] = useState<FateStage>(() =>
|
|
61
|
+
initialStage(folder.messageCount),
|
|
62
|
+
);
|
|
63
|
+
const { createFolder } = useCreateMailbox(accountId);
|
|
64
|
+
const {
|
|
65
|
+
phase,
|
|
66
|
+
progress,
|
|
67
|
+
errorMessage,
|
|
68
|
+
deleteMailbox,
|
|
69
|
+
moveThenDelete,
|
|
70
|
+
cancel,
|
|
71
|
+
reset,
|
|
72
|
+
} = useDeleteFolder({
|
|
73
|
+
accountId,
|
|
74
|
+
mailboxId: folder.mailboxId,
|
|
75
|
+
onDeleted: onClose,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!open) return;
|
|
80
|
+
setStage(initialStage(folder.messageCount));
|
|
81
|
+
reset();
|
|
82
|
+
}, [open, folder.messageCount, reset]);
|
|
83
|
+
|
|
84
|
+
useEffect(() => cancel, [cancel]);
|
|
85
|
+
|
|
86
|
+
const handleClose = useCallback(() => {
|
|
87
|
+
cancel();
|
|
88
|
+
onClose();
|
|
89
|
+
}, [cancel, onClose]);
|
|
90
|
+
|
|
91
|
+
const destinations = useMemo<MoveMailboxOption[]>(
|
|
92
|
+
() =>
|
|
93
|
+
excludeFolder(
|
|
94
|
+
buildMoveTargets(mailboxes, appointments),
|
|
95
|
+
folder.mailboxId,
|
|
96
|
+
).map((mailbox) => ({
|
|
97
|
+
id: mailbox.mailboxId,
|
|
98
|
+
label: getMailboxDisplayName(mailbox.fullPath),
|
|
99
|
+
searchValue: mailbox.fullPath,
|
|
100
|
+
})),
|
|
101
|
+
[mailboxes, appointments, folder.mailboxId],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const handleCreateFolder = async (
|
|
105
|
+
name: string,
|
|
106
|
+
): Promise<MoveMailboxOption> => {
|
|
107
|
+
const created = await createFolder(name);
|
|
108
|
+
return { id: created.id, label: created.label };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
if (!open) return null;
|
|
112
|
+
|
|
113
|
+
const name = folderLabel(folder);
|
|
114
|
+
const title = `Delete ${name}`;
|
|
115
|
+
|
|
116
|
+
const body = (() => {
|
|
117
|
+
if (phase === "moving") {
|
|
118
|
+
return (
|
|
119
|
+
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
120
|
+
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
121
|
+
<p className="text-sm font-medium text-fg">Moving emails…</p>
|
|
122
|
+
<p className="text-xs text-fg-muted" role="status" aria-live="polite">
|
|
123
|
+
{progress ? moveProgressLabel(progress) : "Moved 0 of 0"}
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (phase === "deleting" || phase === "done") {
|
|
130
|
+
return (
|
|
131
|
+
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
132
|
+
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
133
|
+
<p className="text-sm font-medium text-fg">Deleting folder…</p>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (phase === "error") {
|
|
139
|
+
return (
|
|
140
|
+
<div className="space-y-4 px-5 py-4">
|
|
141
|
+
<Banner tone="danger" variant="soft">
|
|
142
|
+
{errorMessage ?? "Something went wrong. Please try again."}
|
|
143
|
+
</Banner>
|
|
144
|
+
{progress && (
|
|
145
|
+
<p className="text-xs text-fg-muted">
|
|
146
|
+
{moveProgressLabel(progress)}. Already-moved emails stay moved —
|
|
147
|
+
re-open delete to continue with the rest.
|
|
148
|
+
</p>
|
|
149
|
+
)}
|
|
150
|
+
<div className="flex justify-end">
|
|
151
|
+
<Button variant="secondary" size="sm" onClick={handleClose}>
|
|
152
|
+
Close
|
|
153
|
+
</Button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (stage === "confirm-empty") {
|
|
160
|
+
return (
|
|
161
|
+
<>
|
|
162
|
+
<div className="space-y-3 px-5 py-4 text-sm text-fg-muted">
|
|
163
|
+
<p>
|
|
164
|
+
Delete <strong className="text-fg">{name}</strong>? This folder is
|
|
165
|
+
empty and will be removed from the server.
|
|
166
|
+
</p>
|
|
167
|
+
</div>
|
|
168
|
+
<footer className="flex items-center justify-end gap-2 border-t border-line px-5 py-3">
|
|
169
|
+
<Button variant="secondary" size="sm" onClick={handleClose}>
|
|
170
|
+
Cancel
|
|
171
|
+
</Button>
|
|
172
|
+
<Button
|
|
173
|
+
variant="danger"
|
|
174
|
+
size="sm"
|
|
175
|
+
icon={<Trash2 className="size-3.5" />}
|
|
176
|
+
onClick={() => deleteMailbox()}
|
|
177
|
+
>
|
|
178
|
+
Delete folder
|
|
179
|
+
</Button>
|
|
180
|
+
</footer>
|
|
181
|
+
</>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (stage === "choose-fate") {
|
|
186
|
+
return (
|
|
187
|
+
<>
|
|
188
|
+
<div className="space-y-3 px-5 py-4 text-sm text-fg-muted">
|
|
189
|
+
<p>
|
|
190
|
+
<strong className="text-fg">{name}</strong> holds{" "}
|
|
191
|
+
{emailCount(folder.messageCount)}. What should happen to them?
|
|
192
|
+
</p>
|
|
193
|
+
<div className="space-y-2">
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
onClick={() => setStage("confirm-delete-all")}
|
|
197
|
+
className="flex w-full items-center gap-3 rounded-sm border border-line px-3 py-2.5 text-left hover:bg-surface-raised"
|
|
198
|
+
>
|
|
199
|
+
<Trash2 className="size-4 shrink-0 text-danger" />
|
|
200
|
+
<span className="flex-1">
|
|
201
|
+
<span className="block text-sm font-medium text-fg">
|
|
202
|
+
Delete them with the folder
|
|
203
|
+
</span>
|
|
204
|
+
<span className="block text-xs text-fg-muted">
|
|
205
|
+
The emails are removed from the server and can't be
|
|
206
|
+
recovered.
|
|
207
|
+
</span>
|
|
208
|
+
</span>
|
|
209
|
+
</button>
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
onClick={() => setStage("pick-destination")}
|
|
213
|
+
className="flex w-full items-center gap-3 rounded-sm border border-line px-3 py-2.5 text-left hover:bg-surface-raised"
|
|
214
|
+
>
|
|
215
|
+
<FolderInput className="size-4 shrink-0 text-accent-2" />
|
|
216
|
+
<span className="flex-1">
|
|
217
|
+
<span className="block text-sm font-medium text-fg">
|
|
218
|
+
Move them to another folder
|
|
219
|
+
</span>
|
|
220
|
+
<span className="block text-xs text-fg-muted">
|
|
221
|
+
Keep the emails, then delete the emptied folder.
|
|
222
|
+
</span>
|
|
223
|
+
</span>
|
|
224
|
+
</button>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
<footer className="flex items-center justify-end gap-2 border-t border-line px-5 py-3">
|
|
228
|
+
<Button variant="secondary" size="sm" onClick={handleClose}>
|
|
229
|
+
Cancel
|
|
230
|
+
</Button>
|
|
231
|
+
</footer>
|
|
232
|
+
</>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (stage === "confirm-delete-all") {
|
|
237
|
+
return (
|
|
238
|
+
<>
|
|
239
|
+
<div className="space-y-3 px-5 py-4 text-sm text-fg-muted">
|
|
240
|
+
<p>
|
|
241
|
+
Delete <strong className="text-fg">{name}</strong> and its{" "}
|
|
242
|
+
{emailCount(folder.messageCount)}? The messages are removed from
|
|
243
|
+
the server with the folder and can't be recovered.
|
|
244
|
+
</p>
|
|
245
|
+
</div>
|
|
246
|
+
<footer className="flex items-center justify-end gap-2 border-t border-line px-5 py-3">
|
|
247
|
+
<Button
|
|
248
|
+
variant="secondary"
|
|
249
|
+
size="sm"
|
|
250
|
+
onClick={() => setStage("choose-fate")}
|
|
251
|
+
>
|
|
252
|
+
Back
|
|
253
|
+
</Button>
|
|
254
|
+
<Button
|
|
255
|
+
variant="danger"
|
|
256
|
+
size="sm"
|
|
257
|
+
icon={<Trash2 className="size-3.5" />}
|
|
258
|
+
onClick={() => deleteMailbox()}
|
|
259
|
+
>
|
|
260
|
+
Delete folder and emails
|
|
261
|
+
</Button>
|
|
262
|
+
</footer>
|
|
263
|
+
</>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<div className="flex h-80 flex-col">
|
|
269
|
+
<p className="px-5 pt-4 text-sm text-fg-muted">
|
|
270
|
+
Move the {emailCount(folder.messageCount)} in{" "}
|
|
271
|
+
<strong className="text-fg">{name}</strong> to:
|
|
272
|
+
</p>
|
|
273
|
+
<div className="min-h-0 flex-1 px-2 py-2">
|
|
274
|
+
<MoveMailboxPicker
|
|
275
|
+
mailboxes={destinations}
|
|
276
|
+
onSelect={(destinationMailboxId) =>
|
|
277
|
+
moveThenDelete(destinationMailboxId)
|
|
278
|
+
}
|
|
279
|
+
onCreateFolder={handleCreateFolder}
|
|
280
|
+
onCancel={() => setStage("choose-fate")}
|
|
281
|
+
labels={{
|
|
282
|
+
searchPlaceholder: "Move emails to…",
|
|
283
|
+
optionLabel: (label) => `Move to ${label}`,
|
|
284
|
+
createLabel: (query) => `Create "${query}"`,
|
|
285
|
+
}}
|
|
286
|
+
/>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
})();
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<Dialog open={open} onClose={handleClose} title={title}>
|
|
294
|
+
<header className="flex items-center gap-2 border-b border-line px-5 py-3">
|
|
295
|
+
<AlertTriangle className="size-4 shrink-0 text-danger" />
|
|
296
|
+
<span className="flex-1 text-sm font-semibold text-fg">{title}</span>
|
|
297
|
+
<Button
|
|
298
|
+
variant="ghost"
|
|
299
|
+
size="sm"
|
|
300
|
+
icon={<X className="size-3.5" />}
|
|
301
|
+
onClick={handleClose}
|
|
302
|
+
aria-label="Cancel"
|
|
303
|
+
/>
|
|
304
|
+
</header>
|
|
305
|
+
{body}
|
|
306
|
+
</Dialog>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import {
|
|
2
|
+
configOperationsGetConfigQueryKey,
|
|
3
|
+
mailboxOperationsListMailboxesQueryKey,
|
|
4
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
+
import {
|
|
6
|
+
mailboxDetailOperationsDeleteMailbox,
|
|
7
|
+
mailboxOperationsListMailboxes,
|
|
8
|
+
messageBulkOperationsMoveMessages,
|
|
9
|
+
threadOperationsListThreads,
|
|
10
|
+
} from "@remit/api-http-client/sdk.gen.ts";
|
|
11
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
12
|
+
import { useCallback, useRef, useState } from "react";
|
|
13
|
+
import {
|
|
14
|
+
advanceMove,
|
|
15
|
+
beginMove,
|
|
16
|
+
failMove,
|
|
17
|
+
MOVE_BATCH_SIZE,
|
|
18
|
+
type MoveProgress,
|
|
19
|
+
} from "@/lib/delete-folder";
|
|
20
|
+
|
|
21
|
+
const PAGE_CAP = 50;
|
|
22
|
+
|
|
23
|
+
type Outcome<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
24
|
+
|
|
25
|
+
const errorText = (error: unknown): string =>
|
|
26
|
+
error instanceof Error ? error.message : "Something went wrong.";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run a network call to a branchable result instead of a thrown exception, so
|
|
30
|
+
* the caller can surface a failed batch to the UI without a block catch (the
|
|
31
|
+
* fail-fast lint rule reserves those for rethrows).
|
|
32
|
+
*/
|
|
33
|
+
const attempt = <T>(work: Promise<T>): Promise<Outcome<T>> =>
|
|
34
|
+
work.then(
|
|
35
|
+
(value) => ({ ok: true as const, value }),
|
|
36
|
+
(error) => ({ ok: false as const, error: errorText(error) }),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Collect up to `limit` message ids still living in the mailbox, skipping ones
|
|
41
|
+
* already moved this run and anything flagged deleted. Reads fresh from the
|
|
42
|
+
* server every call so a resumed or interrupted move enumerates what is
|
|
43
|
+
* actually left, never a stale snapshot.
|
|
44
|
+
*/
|
|
45
|
+
const collectMovableIds = async (
|
|
46
|
+
mailboxId: string,
|
|
47
|
+
alreadyMoved: ReadonlySet<string>,
|
|
48
|
+
limit: number,
|
|
49
|
+
signal: AbortSignal,
|
|
50
|
+
): Promise<string[]> => {
|
|
51
|
+
const ids: string[] = [];
|
|
52
|
+
let continuationToken: string | undefined;
|
|
53
|
+
for (let page = 0; page < PAGE_CAP; page += 1) {
|
|
54
|
+
if (signal.aborted) return ids;
|
|
55
|
+
const { data } = await threadOperationsListThreads({
|
|
56
|
+
path: { mailboxId },
|
|
57
|
+
query: { order: "desc", continuationToken },
|
|
58
|
+
throwOnError: true,
|
|
59
|
+
});
|
|
60
|
+
for (const item of data.items) {
|
|
61
|
+
if (item.isDeleted) continue;
|
|
62
|
+
if (alreadyMoved.has(item.messageId)) continue;
|
|
63
|
+
if (ids.includes(item.messageId)) continue;
|
|
64
|
+
ids.push(item.messageId);
|
|
65
|
+
if (ids.length >= limit) return ids;
|
|
66
|
+
}
|
|
67
|
+
if (!data.continuationToken) break;
|
|
68
|
+
continuationToken = data.continuationToken;
|
|
69
|
+
}
|
|
70
|
+
return ids;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Count live messages still listed in the mailbox with no `moved`-set filtering,
|
|
75
|
+
* an independent read the move loop cannot fool. The delete gates on this: if any
|
|
76
|
+
* message still shows here — including one that lags in the source listing after a
|
|
77
|
+
* move — the folder is not emptied yet and must not be deleted.
|
|
78
|
+
*/
|
|
79
|
+
const liveMessageCount = async (
|
|
80
|
+
mailboxId: string,
|
|
81
|
+
signal: AbortSignal,
|
|
82
|
+
): Promise<number> => {
|
|
83
|
+
let count = 0;
|
|
84
|
+
let continuationToken: string | undefined;
|
|
85
|
+
for (let page = 0; page < PAGE_CAP; page += 1) {
|
|
86
|
+
if (signal.aborted) return count;
|
|
87
|
+
const { data } = await threadOperationsListThreads({
|
|
88
|
+
path: { mailboxId },
|
|
89
|
+
query: { order: "desc", continuationToken },
|
|
90
|
+
throwOnError: true,
|
|
91
|
+
});
|
|
92
|
+
for (const item of data.items) {
|
|
93
|
+
if (item.isDeleted) continue;
|
|
94
|
+
count += 1;
|
|
95
|
+
}
|
|
96
|
+
if (!data.continuationToken) break;
|
|
97
|
+
continuationToken = data.continuationToken;
|
|
98
|
+
}
|
|
99
|
+
return count;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type DeleteFolderPhase =
|
|
103
|
+
| "idle"
|
|
104
|
+
| "moving"
|
|
105
|
+
| "deleting"
|
|
106
|
+
| "done"
|
|
107
|
+
| "error";
|
|
108
|
+
|
|
109
|
+
interface UseDeleteFolderOptions {
|
|
110
|
+
accountId: string;
|
|
111
|
+
mailboxId: string;
|
|
112
|
+
onDeleted: () => void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function useDeleteFolder({
|
|
116
|
+
accountId,
|
|
117
|
+
mailboxId,
|
|
118
|
+
onDeleted,
|
|
119
|
+
}: UseDeleteFolderOptions) {
|
|
120
|
+
const queryClient = useQueryClient();
|
|
121
|
+
const [phase, setPhase] = useState<DeleteFolderPhase>("idle");
|
|
122
|
+
const [progress, setProgress] = useState<MoveProgress | null>(null);
|
|
123
|
+
const [errorMessage, setErrorMessage] = useState<string>();
|
|
124
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
125
|
+
|
|
126
|
+
const invalidate = useCallback(() => {
|
|
127
|
+
queryClient.invalidateQueries({
|
|
128
|
+
queryKey: mailboxOperationsListMailboxesQueryKey({ path: { accountId } }),
|
|
129
|
+
});
|
|
130
|
+
queryClient.invalidateQueries({
|
|
131
|
+
queryKey: configOperationsGetConfigQueryKey(),
|
|
132
|
+
});
|
|
133
|
+
}, [queryClient, accountId]);
|
|
134
|
+
|
|
135
|
+
const deleteMailbox = useCallback(async () => {
|
|
136
|
+
setPhase("deleting");
|
|
137
|
+
const outcome = await attempt(
|
|
138
|
+
mailboxDetailOperationsDeleteMailbox({
|
|
139
|
+
path: { accountId, mailboxId },
|
|
140
|
+
throwOnError: true,
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
if (!outcome.ok) {
|
|
144
|
+
setErrorMessage(outcome.error);
|
|
145
|
+
setPhase("error");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
invalidate();
|
|
149
|
+
setPhase("done");
|
|
150
|
+
onDeleted();
|
|
151
|
+
}, [accountId, mailboxId, invalidate, onDeleted]);
|
|
152
|
+
|
|
153
|
+
const remainingCount = useCallback(
|
|
154
|
+
(): Promise<number> =>
|
|
155
|
+
mailboxOperationsListMailboxes({
|
|
156
|
+
path: { accountId },
|
|
157
|
+
throwOnError: true,
|
|
158
|
+
}).then(
|
|
159
|
+
({ data }) =>
|
|
160
|
+
data.items.find((box) => box.mailboxId === mailboxId)?.messageCount ??
|
|
161
|
+
0,
|
|
162
|
+
),
|
|
163
|
+
[accountId, mailboxId],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const cancel = useCallback(() => {
|
|
167
|
+
abortRef.current?.abort();
|
|
168
|
+
}, []);
|
|
169
|
+
|
|
170
|
+
const moveThenDelete = useCallback(
|
|
171
|
+
async (destinationMailboxId: string) => {
|
|
172
|
+
const controller = new AbortController();
|
|
173
|
+
abortRef.current = controller;
|
|
174
|
+
const { signal } = controller;
|
|
175
|
+
setPhase("moving");
|
|
176
|
+
setErrorMessage(undefined);
|
|
177
|
+
|
|
178
|
+
const counted = await attempt(remainingCount());
|
|
179
|
+
if (signal.aborted) return;
|
|
180
|
+
if (!counted.ok) {
|
|
181
|
+
setErrorMessage(counted.error);
|
|
182
|
+
setPhase("error");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
let current = beginMove(counted.value);
|
|
186
|
+
setProgress(current);
|
|
187
|
+
|
|
188
|
+
const moved = new Set<string>();
|
|
189
|
+
while (true) {
|
|
190
|
+
if (signal.aborted) return;
|
|
191
|
+
const collected = await attempt(
|
|
192
|
+
collectMovableIds(mailboxId, moved, MOVE_BATCH_SIZE, signal),
|
|
193
|
+
);
|
|
194
|
+
if (signal.aborted) return;
|
|
195
|
+
if (!collected.ok) {
|
|
196
|
+
current = failMove(current, collected.error);
|
|
197
|
+
setProgress(current);
|
|
198
|
+
setErrorMessage(collected.error);
|
|
199
|
+
setPhase("error");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const batch = collected.value;
|
|
203
|
+
if (batch.length === 0) break;
|
|
204
|
+
|
|
205
|
+
const movedBatch = await attempt(
|
|
206
|
+
messageBulkOperationsMoveMessages({
|
|
207
|
+
body: { messageIds: batch, destinationMailboxId },
|
|
208
|
+
throwOnError: true,
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
if (signal.aborted) return;
|
|
212
|
+
if (!movedBatch.ok) {
|
|
213
|
+
current = failMove(current, movedBatch.error);
|
|
214
|
+
setProgress(current);
|
|
215
|
+
setErrorMessage(movedBatch.error);
|
|
216
|
+
setPhase("error");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
for (const id of batch) moved.add(id);
|
|
220
|
+
current = advanceMove(current, batch.length);
|
|
221
|
+
setProgress(current);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const remaining = await attempt(liveMessageCount(mailboxId, signal));
|
|
225
|
+
if (signal.aborted) return;
|
|
226
|
+
if (!remaining.ok) {
|
|
227
|
+
current = failMove(current, remaining.error);
|
|
228
|
+
setProgress(current);
|
|
229
|
+
setErrorMessage(remaining.error);
|
|
230
|
+
setPhase("error");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (remaining.value > 0) {
|
|
234
|
+
const message = `${remaining.value} ${
|
|
235
|
+
remaining.value === 1 ? "email is" : "emails are"
|
|
236
|
+
} still syncing. Re-open delete to finish removing this folder.`;
|
|
237
|
+
current = failMove(current, message);
|
|
238
|
+
setProgress(current);
|
|
239
|
+
setErrorMessage(message);
|
|
240
|
+
setPhase("error");
|
|
241
|
+
invalidate();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await deleteMailbox();
|
|
246
|
+
},
|
|
247
|
+
[mailboxId, remainingCount, deleteMailbox, invalidate],
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const reset = useCallback(() => {
|
|
251
|
+
abortRef.current?.abort();
|
|
252
|
+
abortRef.current = null;
|
|
253
|
+
setPhase("idle");
|
|
254
|
+
setProgress(null);
|
|
255
|
+
setErrorMessage(undefined);
|
|
256
|
+
}, []);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
phase,
|
|
260
|
+
progress,
|
|
261
|
+
errorMessage,
|
|
262
|
+
deleteMailbox,
|
|
263
|
+
moveThenDelete,
|
|
264
|
+
cancel,
|
|
265
|
+
reset,
|
|
266
|
+
};
|
|
267
|
+
}
|