@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.
@@ -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
+ }