@remit/web-client 0.0.190 → 0.0.192
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/DeleteConfirmDialog.render.test.ts +208 -26
- package/src/components/mail/DeleteConfirmDialog.tsx +53 -17
- package/src/components/mail/EmptyTrashBar.render.test.ts +133 -0
- package/src/components/mail/EmptyTrashBar.tsx +181 -0
- package/src/components/mail/MailboxPane.tsx +39 -1
- package/src/components/mail/MessageList.tsx +71 -62
- package/src/components/mail/RoleAppointmentPromptProvider.render.test.ts +292 -0
- package/src/components/mail/RoleAppointmentPromptProvider.tsx +246 -0
- package/src/components/mail/ThreadListInteraction.test.ts +30 -21
- package/src/components/mail/ThreadListInteraction.tsx +18 -8
- package/src/components/mail/empty-trash-bar.stories.tsx +121 -0
- package/src/components/ui/folder-role-refusal.test.ts +94 -0
- package/src/components/ui/folder-role-refusal.ts +81 -0
- package/src/hooks/useArchiveMailbox.ts +21 -5
- package/src/hooks/useCurrentMailboxName.ts +33 -0
- package/src/hooks/useDeleteMessages.ts +41 -5
- package/src/hooks/useDeleteOutcome.ts +37 -7
- package/src/hooks/useEmptyTrash.render.test.ts +194 -0
- package/src/hooks/useEmptyTrash.ts +187 -0
- package/src/lib/format.test.ts +133 -11
- package/src/lib/format.ts +95 -17
- package/src/lib/move-options.ts +5 -0
- package/src/routes/__root.tsx +14 -11
- package/src/routes/settings/folders.tsx +12 -3
- package/src/test-support/dom.ts +6 -1
package/src/lib/format.ts
CHANGED
|
@@ -167,6 +167,21 @@ export interface DeleteConfirmationCopy {
|
|
|
167
167
|
confirmLabel: string;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* What the caller knows about the account's Trash. Two folder names, never one:
|
|
172
|
+
* the folder reader guessed and the folder that vanished are different facts.
|
|
173
|
+
* Both optional — a caller holding no open mailbox passes neither, and the copy
|
|
174
|
+
* drops the name clause rather than inventing one.
|
|
175
|
+
*/
|
|
176
|
+
export interface DeleteConfirmationContext {
|
|
177
|
+
/** The folder reader files this account's deletes in. */
|
|
178
|
+
trashFolderLabel?: string;
|
|
179
|
+
/** The folder the user appointed, now gone from the mail server. */
|
|
180
|
+
staleFolderLabel?: string;
|
|
181
|
+
/** That folder is a name match nobody ever confirmed. */
|
|
182
|
+
trashIsUnconfirmed?: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
170
185
|
/**
|
|
171
186
|
* What a delete will actually do to the selected mail.
|
|
172
187
|
*
|
|
@@ -186,14 +201,43 @@ export interface DeleteConfirmationCopy {
|
|
|
186
201
|
* here" reinstates the same lie on the failure path, where an expired session
|
|
187
202
|
* would collect "Move to Trash?" over an expunge. The delete is refused and the
|
|
188
203
|
* failure is stated instead.
|
|
204
|
+
*
|
|
205
|
+
* `staleTrash` is a resolved answer of "the folder you chose is gone" (#887).
|
|
206
|
+
* It is a repair rather than a first choice, and the copy says so, which a
|
|
207
|
+
* single `noTrash` member cannot — this function only ever sees the member.
|
|
208
|
+
*
|
|
209
|
+
* `unconfirmed` is a folder reader matched by name that nobody ever confirmed.
|
|
210
|
+
* Only Empty Trash refuses on it (D4); `deleteOutcomeFor` never produces it,
|
|
211
|
+
* because the targets an ordinary delete is about say nothing about a whole
|
|
212
|
+
* folder. The Empty Trash surface derives it from the server's own 409, and the
|
|
213
|
+
* branch lives here so both surfaces word it identically.
|
|
189
214
|
*/
|
|
190
215
|
export type DeleteOutcome =
|
|
191
216
|
| "trash"
|
|
192
217
|
| "permanent"
|
|
193
218
|
| "noTrash"
|
|
219
|
+
| "staleTrash"
|
|
220
|
+
| "unconfirmed"
|
|
194
221
|
| "unknown"
|
|
195
222
|
| "unavailable";
|
|
196
223
|
|
|
224
|
+
/** Where an account's Trash answer came from (`FolderAppointmentSource`). */
|
|
225
|
+
export type TrashSource =
|
|
226
|
+
| "Appointed"
|
|
227
|
+
| "Flagged"
|
|
228
|
+
| "Reserved"
|
|
229
|
+
| "Proposed"
|
|
230
|
+
| "Stale"
|
|
231
|
+
| "None";
|
|
232
|
+
|
|
233
|
+
/** One account's Trash, as `/config` resolved it. */
|
|
234
|
+
export interface TrashResolution {
|
|
235
|
+
mailboxId: string | undefined;
|
|
236
|
+
source: TrashSource;
|
|
237
|
+
/** `Stale` only: the path the folder the user chose last had. */
|
|
238
|
+
staleFolderPath?: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
197
241
|
/** A row about to be deleted, and the account whose Trash decides its fate. */
|
|
198
242
|
export interface DeleteTarget {
|
|
199
243
|
accountId: string | undefined;
|
|
@@ -203,11 +247,11 @@ export interface DeleteTarget {
|
|
|
203
247
|
export interface DeleteOutcomeInput {
|
|
204
248
|
targets: readonly DeleteTarget[];
|
|
205
249
|
/**
|
|
206
|
-
* Each account's
|
|
207
|
-
*
|
|
208
|
-
*
|
|
250
|
+
* Each account's Trash and where that answer came from. A key present is an
|
|
251
|
+
* answer, `source` and all — a key absent is an account nothing is known
|
|
252
|
+
* about yet.
|
|
209
253
|
*/
|
|
210
|
-
trashByAccount: ReadonlyMap<string,
|
|
254
|
+
trashByAccount: ReadonlyMap<string, TrashResolution>;
|
|
211
255
|
/**
|
|
212
256
|
* The appointments have actually arrived. Never `!isLoading`: React Query
|
|
213
257
|
* v5 leaves a paused offline query pending-but-not-fetching, which reads as
|
|
@@ -240,10 +284,11 @@ export const deleteOutcomeFor = ({
|
|
|
240
284
|
let expunges = false;
|
|
241
285
|
for (const target of targets) {
|
|
242
286
|
if (target.accountId === undefined) return "unknown";
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (
|
|
246
|
-
if (
|
|
287
|
+
const trash = trashByAccount.get(target.accountId);
|
|
288
|
+
if (trash === undefined) return "unknown";
|
|
289
|
+
if (trash.source === "Stale") return "staleTrash";
|
|
290
|
+
if (trash.mailboxId === undefined) return "noTrash";
|
|
291
|
+
if (trash.mailboxId === target.mailboxId) expunges = true;
|
|
247
292
|
}
|
|
248
293
|
return expunges ? "permanent" : "trash";
|
|
249
294
|
};
|
|
@@ -255,26 +300,47 @@ export const deleteOutcomeFor = ({
|
|
|
255
300
|
* says "Move to Trash" over an expunge collects an answer to a question the
|
|
256
301
|
* user was never asked.
|
|
257
302
|
*
|
|
258
|
-
* `noTrash` and `unavailable` are not
|
|
259
|
-
* nothing is deleted, and the label names
|
|
260
|
-
*
|
|
261
|
-
* appointment
|
|
262
|
-
*
|
|
263
|
-
*
|
|
303
|
+
* `noTrash`, `staleTrash`, `unconfirmed` and `unavailable` are not
|
|
304
|
+
* confirmations at all but refusals: nothing is deleted, and the label names
|
|
305
|
+
* the way out rather than the delete. The first three are answered in place by
|
|
306
|
+
* the appointment prompt — a link to Settings as the only remedy leaves the
|
|
307
|
+
* user to reassemble what they were doing. `unavailable` still routes to
|
|
308
|
+
* re-authentication, because an account read that fails is a session that ended
|
|
309
|
+
* under the reader far more often than it is anything else.
|
|
264
310
|
*/
|
|
265
311
|
export const deleteConfirmationCopy = (
|
|
266
312
|
count: number,
|
|
267
313
|
outcome: DeleteOutcome,
|
|
314
|
+
context: DeleteConfirmationContext = {},
|
|
268
315
|
): DeleteConfirmationCopy => {
|
|
269
316
|
const quantity = count === 1 ? "1" : formatNumber(count);
|
|
270
317
|
const noun = count === 1 ? "message" : "messages";
|
|
318
|
+
const { trashFolderLabel, staleFolderLabel, trashIsUnconfirmed } = context;
|
|
271
319
|
|
|
272
320
|
if (outcome === "noTrash") {
|
|
273
321
|
return {
|
|
274
|
-
title: `Can't delete ${quantity} ${noun}`,
|
|
322
|
+
title: `Can't delete ${quantity} ${noun} yet`,
|
|
275
323
|
description:
|
|
276
|
-
"No folder on this account is
|
|
277
|
-
confirmLabel: "
|
|
324
|
+
"No folder on this account is set as Trash, so there is nowhere to move the mail. Nothing has been deleted.",
|
|
325
|
+
confirmLabel: "Pick a Trash folder",
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (outcome === "staleTrash") {
|
|
329
|
+
return {
|
|
330
|
+
title: `Can't delete ${quantity} ${noun} yet`,
|
|
331
|
+
description: staleFolderLabel
|
|
332
|
+
? `The folder you set as this account's Trash — ${staleFolderLabel} — is gone from the mail server. Nothing has been deleted.`
|
|
333
|
+
: "The folder you set as this account's Trash is gone from the mail server. Nothing has been deleted.",
|
|
334
|
+
confirmLabel: "Pick another folder",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
if (outcome === "unconfirmed") {
|
|
338
|
+
return {
|
|
339
|
+
title: "Confirm this account's Trash folder",
|
|
340
|
+
description: trashFolderLabel
|
|
341
|
+
? `reader files this account's deleted mail in ${trashFolderLabel} because of its name — nobody confirmed it. Emptying a folder erases everything in it from the mail server, and that cannot be restored. Nothing has been emptied.`
|
|
342
|
+
: "reader files this account's deleted mail in a folder it matched by name — nobody confirmed it. Emptying a folder erases everything in it from the mail server, and that cannot be restored. Nothing has been emptied.",
|
|
343
|
+
confirmLabel: "Confirm the folder",
|
|
278
344
|
};
|
|
279
345
|
}
|
|
280
346
|
if (outcome === "unavailable") {
|
|
@@ -293,6 +359,18 @@ export const deleteConfirmationCopy = (
|
|
|
293
359
|
};
|
|
294
360
|
}
|
|
295
361
|
if (outcome === "permanent") {
|
|
362
|
+
// D4a: the expunge still goes through on a Trash nobody confirmed — the
|
|
363
|
+
// user asked for these specific rows — but they are told which folder
|
|
364
|
+
// reader treats as Trash, and that nobody chose it, before it happens.
|
|
365
|
+
if (trashIsUnconfirmed) {
|
|
366
|
+
return {
|
|
367
|
+
title: `Permanently delete ${quantity} ${noun}?`,
|
|
368
|
+
description: trashFolderLabel
|
|
369
|
+
? `They are in ${trashFolderLabel}, which reader treats as this account's Trash because of its name — nobody confirmed it. They are erased from the mail server and cannot be restored.`
|
|
370
|
+
: "They are in a folder reader treats as this account's Trash because of its name — nobody confirmed it. They are erased from the mail server and cannot be restored.",
|
|
371
|
+
confirmLabel: "Delete permanently",
|
|
372
|
+
};
|
|
373
|
+
}
|
|
296
374
|
return {
|
|
297
375
|
title: `Permanently delete ${quantity} ${noun}?`,
|
|
298
376
|
description:
|
package/src/lib/move-options.ts
CHANGED
|
@@ -33,6 +33,10 @@ export const folderDelimiter = (
|
|
|
33
33
|
* `displayNameOverride`, so a picker reads `Inbox`/`Trash` rather than the
|
|
34
34
|
* provider's leaf, while the provider path it nests and filters under stays
|
|
35
35
|
* untouched.
|
|
36
|
+
*
|
|
37
|
+
* The message count travels with every option (#887): a folder named `Trash`
|
|
38
|
+
* may be an empty look-alike beside the real one, and the count is the only
|
|
39
|
+
* thing that tells them apart. The Move-to picker shows it for the same reason.
|
|
36
40
|
*/
|
|
37
41
|
export const buildMoveOptions = ({
|
|
38
42
|
mailboxes,
|
|
@@ -51,5 +55,6 @@ export const buildMoveOptions = ({
|
|
|
51
55
|
label: labelForMailbox(mailbox, roleMap.get(mailbox.mailboxId), translator),
|
|
52
56
|
path: mailbox.fullPath,
|
|
53
57
|
isCurrent: mailbox.mailboxId === currentMailboxId,
|
|
58
|
+
messageCount: mailbox.messageCount,
|
|
54
59
|
}));
|
|
55
60
|
};
|
package/src/routes/__root.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { Suspense } from "react";
|
|
|
7
7
|
import { useTranslation } from "react-i18next";
|
|
8
8
|
import { ComposeProvider } from "@/components/compose/ComposeProvider";
|
|
9
9
|
import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
|
|
10
|
+
import { RoleAppointmentPromptProvider } from "@/components/mail/RoleAppointmentPromptProvider";
|
|
10
11
|
import { SelfUpdateOverlay } from "@/components/self-update/SelfUpdateOverlay";
|
|
11
12
|
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
12
13
|
import {
|
|
@@ -57,17 +58,19 @@ const SkipLink = () => {
|
|
|
57
58
|
function RootLayout() {
|
|
58
59
|
return (
|
|
59
60
|
<ErrorBannerProvider>
|
|
60
|
-
<
|
|
61
|
-
<
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
<
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
61
|
+
<RoleAppointmentPromptProvider>
|
|
62
|
+
<SelfUpdateProvider>
|
|
63
|
+
<ComposeProvider>
|
|
64
|
+
<SkipLink />
|
|
65
|
+
<main id="main-content" className="h-dvh overflow-hidden">
|
|
66
|
+
<Suspense fallback={<AppShellSkeleton />}>
|
|
67
|
+
<Outlet />
|
|
68
|
+
</Suspense>
|
|
69
|
+
</main>
|
|
70
|
+
</ComposeProvider>
|
|
71
|
+
<SelfUpdateOverlay />
|
|
72
|
+
</SelfUpdateProvider>
|
|
73
|
+
</RoleAppointmentPromptProvider>
|
|
71
74
|
<FatalErrorOverlay />
|
|
72
75
|
</ErrorBannerProvider>
|
|
73
76
|
);
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
FolderRenameDialog,
|
|
18
18
|
type FolderRole,
|
|
19
19
|
type ManagedFolder,
|
|
20
|
+
type RoleAppointment,
|
|
20
21
|
RoleAppointmentList,
|
|
21
22
|
SettingsShell,
|
|
22
23
|
} from "@remit/ui";
|
|
@@ -25,6 +26,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
|
25
26
|
import { useMemo, useState } from "react";
|
|
26
27
|
import { DeleteFolderDialog } from "@/components/settings/DeleteFolderDialog";
|
|
27
28
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
29
|
+
import { isMailboxNotSettledRefusal } from "@/components/ui/folder-role-refusal";
|
|
28
30
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
29
31
|
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
30
32
|
import { guardFolderDeletion } from "@/lib/delete-folder";
|
|
@@ -167,10 +169,15 @@ function AccountFolders({ account }: { account: RemitImapAccountResponse }) {
|
|
|
167
169
|
messageCount: mailbox.messageCount,
|
|
168
170
|
}));
|
|
169
171
|
|
|
170
|
-
const appointments: Record<string,
|
|
172
|
+
const appointments: Record<string, RoleAppointment> = {};
|
|
171
173
|
for (const appointment of account.folderAppointments) {
|
|
172
174
|
const role = CANONICAL_TO_NAV_ROLE[appointment.role];
|
|
173
|
-
if (role)
|
|
175
|
+
if (!role) continue;
|
|
176
|
+
appointments[role] = {
|
|
177
|
+
mailboxId: appointment.mailboxId ?? null,
|
|
178
|
+
source: appointment.source,
|
|
179
|
+
staleFolderPath: appointment.staleAppointmentPath,
|
|
180
|
+
};
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
const displayNames: Record<string, string> = {};
|
|
@@ -192,7 +199,9 @@ function AccountFolders({ account }: { account: RemitImapAccountResponse }) {
|
|
|
192
199
|
<div className="space-y-4">
|
|
193
200
|
{(appointMutation.isError || renameMutation.isError) && (
|
|
194
201
|
<Banner tone="danger" variant="soft">
|
|
195
|
-
|
|
202
|
+
{isMailboxNotSettledRefusal(appointMutation.error)
|
|
203
|
+
? "That folder is still being created on the mail server — wait for it to finish, then try again."
|
|
204
|
+
: "Couldn't save that change. Please try again."}
|
|
196
205
|
</Banner>
|
|
197
206
|
)}
|
|
198
207
|
<RoleAppointmentList
|
package/src/test-support/dom.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
10
10
|
import React, { act, createElement } from "react";
|
|
11
11
|
import { createRoot, type Root } from "react-dom/client";
|
|
12
12
|
import { DEFAULT_VIEWPORT, setViewport } from "../../test-support/dom-env.mjs";
|
|
13
|
+
import { RoleAppointmentPromptProvider } from "../components/mail/RoleAppointmentPromptProvider";
|
|
13
14
|
import { ErrorBannerProvider } from "../components/ui/ErrorBannerProvider";
|
|
14
15
|
|
|
15
16
|
export interface DomHarness {
|
|
@@ -99,7 +100,11 @@ export const createDomHarness = (options: DomOptions = {}): DomHarness => {
|
|
|
99
100
|
createElement(
|
|
100
101
|
QueryClientProvider,
|
|
101
102
|
{ client: queryClient },
|
|
102
|
-
createElement(
|
|
103
|
+
createElement(
|
|
104
|
+
ErrorBannerProvider,
|
|
105
|
+
null,
|
|
106
|
+
createElement(RoleAppointmentPromptProvider, null, element),
|
|
107
|
+
),
|
|
103
108
|
),
|
|
104
109
|
);
|
|
105
110
|
},
|