@remit/web-client 0.0.176 → 0.0.177

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.176",
3
+ "version": "0.0.177",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -21,7 +21,7 @@ import type { RefObject } from "react";
21
21
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
22
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
23
23
  import { formatErrorMessage } from "@/components/ui/ErrorState";
24
- import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
24
+ import { useJunkMailbox, useTrashMailbox } from "@/hooks/useArchiveMailbox";
25
25
  import {
26
26
  type EscalatedAction,
27
27
  type EscalationSearchQuery,
@@ -46,7 +46,11 @@ import {
46
46
  escalatedStatusLabel,
47
47
  escalationActionLabel,
48
48
  } from "@/lib/escalation-label";
49
- import { formatDeleteToTrashTitle, formatEmailDate } from "@/lib/format";
49
+ import {
50
+ type DeleteOutcome,
51
+ deleteConfirmationCopy,
52
+ formatEmailDate,
53
+ } from "@/lib/format";
50
54
  import { junkDestination } from "@/lib/junk-destination";
51
55
  import { tabStopId } from "@/lib/list-focus";
52
56
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
@@ -296,6 +300,18 @@ export const MessageList = ({
296
300
  const { junkMailboxId } = useJunkMailbox(accountId);
297
301
  const junkDestinationId = junkDestination(junkMailboxId, mailboxId);
298
302
 
303
+ // Deleting inside Trash is an expunge on the mail server, not a move, so the
304
+ // confirmation has to ask that question instead of "move to Trash?" (#845).
305
+ // Until the appointment resolves the outcome is genuinely unknown, and the
306
+ // dialog says so rather than guessing the reversible half of the answer.
307
+ const { trashMailboxId, isLoading: isTrashRoleLoading } =
308
+ useTrashMailbox(accountId);
309
+ const deleteOutcome: DeleteOutcome = isTrashRoleLoading
310
+ ? "unknown"
311
+ : trashMailboxId === mailboxId
312
+ ? "permanent"
313
+ : "trash";
314
+
299
315
  // Selection state
300
316
  const {
301
317
  selectedIds,
@@ -1339,11 +1355,9 @@ export const MessageList = ({
1339
1355
  />
1340
1356
  <ConfirmDialog
1341
1357
  isOpen={pendingDelete !== null}
1342
- title={formatDeleteToTrashTitle(pendingDelete?.length ?? 0)}
1343
- description="You can restore them from Trash later."
1344
- confirmLabel="Move to Trash"
1358
+ {...deleteConfirmationCopy(pendingDelete?.length ?? 0, deleteOutcome)}
1345
1359
  destructive
1346
- isBusy={isDeleting}
1360
+ isBusy={isDeleting || deleteOutcome === "unknown"}
1347
1361
  onConfirm={handleConfirmDelete}
1348
1362
  onCancel={handleCancelDelete}
1349
1363
  />
@@ -80,6 +80,18 @@ export const useJunkMailbox = (
80
80
  return { junkMailboxId: mailboxId, isLoading };
81
81
  };
82
82
 
83
+ /**
84
+ * Returns the account's appointed Trash mailbox id. The delete confirmation
85
+ * needs it to tell a move-to-Trash apart from a delete inside Trash, which is
86
+ * an unrecoverable expunge and has to be asked as one (#845).
87
+ */
88
+ export const useTrashMailbox = (
89
+ accountId: string | undefined,
90
+ ): { trashMailboxId: string | undefined; isLoading: boolean } => {
91
+ const { mailboxId, isLoading } = useFolderRoleMailbox(accountId, "Trash");
92
+ return { trashMailboxId: mailboxId, isLoading };
93
+ };
94
+
83
95
  /**
84
96
  * Returns an account's full role→mailbox appointment map, for callers that
85
97
  * need more than one role at once (e.g. the Move-to picker excluding both
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
3
  import {
4
+ deleteConfirmationCopy,
4
5
  formatDate,
5
6
  formatDatePreset,
6
7
  formatDeleteToTrashTitle,
@@ -120,3 +121,37 @@ describe("formatDeleteToTrashTitle", () => {
120
121
  );
121
122
  });
122
123
  });
124
+
125
+ describe("deleteConfirmationCopy", () => {
126
+ test("asks to move when the delete files the mail in Trash", () => {
127
+ assert.deepStrictEqual(deleteConfirmationCopy(3, "trash"), {
128
+ title: "Move 3 messages to Trash?",
129
+ description: "You can restore them from Trash later.",
130
+ confirmLabel: "Move to Trash",
131
+ });
132
+ });
133
+
134
+ test("asks about destruction when the delete expunges", () => {
135
+ assert.deepStrictEqual(deleteConfirmationCopy(3, "permanent"), {
136
+ title: "Permanently delete 3 messages?",
137
+ description:
138
+ "They are erased from the mail server and cannot be restored.",
139
+ confirmLabel: "Delete permanently",
140
+ });
141
+ });
142
+
143
+ test("uses the singular noun for one message on the permanent path", () => {
144
+ assert.strictEqual(
145
+ deleteConfirmationCopy(1, "permanent").title,
146
+ "Permanently delete 1 message?",
147
+ );
148
+ });
149
+
150
+ test("commits to neither wording until the Trash appointment resolves", () => {
151
+ assert.deepStrictEqual(deleteConfirmationCopy(3, "unknown"), {
152
+ title: "Delete 3 messages?",
153
+ description: "Checking where this account files deleted mail…",
154
+ confirmLabel: "Delete",
155
+ });
156
+ });
157
+ });
package/src/lib/format.ts CHANGED
@@ -160,3 +160,53 @@ export const formatDeleteToTrashTitle = (count: number): string => {
160
160
  const noun = count === 1 ? "message" : "messages";
161
161
  return `Move ${quantity} ${noun} to Trash?`;
162
162
  };
163
+
164
+ export interface DeleteConfirmationCopy {
165
+ title: string;
166
+ description: string;
167
+ confirmLabel: string;
168
+ }
169
+
170
+ /**
171
+ * What a delete will actually do to the selected mail. `unknown` is the state
172
+ * before the account's Trash appointment has resolved — a real state on a cold
173
+ * open, and the one the copy must not guess at, because guessing "move to
174
+ * Trash" over an expunge is the dishonesty this whole flow exists to remove.
175
+ */
176
+ export type DeleteOutcome = "trash" | "permanent" | "unknown";
177
+
178
+ /**
179
+ * The confirmation for a delete, worded for what the delete actually does.
180
+ * Deleting mail that already sits in Trash expunges it on the server and
181
+ * nothing survives that, so it is asked as a permanent delete — a dialog that
182
+ * says "Move to Trash" over an expunge collects an answer to a question the
183
+ * user was never asked.
184
+ */
185
+ export const deleteConfirmationCopy = (
186
+ count: number,
187
+ outcome: DeleteOutcome,
188
+ ): DeleteConfirmationCopy => {
189
+ const quantity = count === 1 ? "1" : formatNumber(count);
190
+ const noun = count === 1 ? "message" : "messages";
191
+
192
+ if (outcome === "unknown") {
193
+ return {
194
+ title: `Delete ${quantity} ${noun}?`,
195
+ description: "Checking where this account files deleted mail…",
196
+ confirmLabel: "Delete",
197
+ };
198
+ }
199
+ if (outcome === "permanent") {
200
+ return {
201
+ title: `Permanently delete ${quantity} ${noun}?`,
202
+ description:
203
+ "They are erased from the mail server and cannot be restored.",
204
+ confirmLabel: "Delete permanently",
205
+ };
206
+ }
207
+ return {
208
+ title: formatDeleteToTrashTitle(count),
209
+ description: "You can restore them from Trash later.",
210
+ confirmLabel: "Move to Trash",
211
+ };
212
+ };