@remit/web-client 0.0.190 → 0.0.191

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/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 appointed Trash. A key present with `undefined` is an
207
- * account that appoints nonean answer, not a gap. A key absent is an
208
- * account nothing is known about yet.
250
+ * Each account's Trash and where that answer came from. A key present is an
251
+ * answer, `source` and alla key absent is an account nothing is known
252
+ * about yet.
209
253
  */
210
- trashByAccount: ReadonlyMap<string, string | undefined>;
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
- if (!trashByAccount.has(target.accountId)) return "unknown";
244
- const trashMailboxId = trashByAccount.get(target.accountId);
245
- if (trashMailboxId === undefined) return "noTrash";
246
- if (trashMailboxId === target.mailboxId) expunges = true;
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 confirmations at all but refusals:
259
- * nothing is deleted, and the label names the way out rather than the delete.
260
- * The caller wires each to its own remedy folder settings for the missing
261
- * appointment, re-authentication for the failed read, because an account read
262
- * that fails is a session that ended under the reader far more often than it is
263
- * anything else.
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 appointed as Trash, so there is nowhere to move the mail — and deleting it would erase it from the server instead. Appoint a Trash folder to delete from here.",
277
- confirmLabel: "Open folder settings",
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:
@@ -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
  };
@@ -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
- <SelfUpdateProvider>
61
- <ComposeProvider>
62
- <SkipLink />
63
- <main id="main-content" className="h-dvh overflow-hidden">
64
- <Suspense fallback={<AppShellSkeleton />}>
65
- <Outlet />
66
- </Suspense>
67
- </main>
68
- </ComposeProvider>
69
- <SelfUpdateOverlay />
70
- </SelfUpdateProvider>
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, string | null> = {};
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) appointments[role] = appointment.mailboxId ?? null;
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
- Couldn't save that change. Please try again.
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
@@ -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(ErrorBannerProvider, null, element),
103
+ createElement(
104
+ ErrorBannerProvider,
105
+ null,
106
+ createElement(RoleAppointmentPromptProvider, null, element),
107
+ ),
103
108
  ),
104
109
  );
105
110
  },