@remit/web-client 0.0.185 → 0.0.186

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.185",
3
+ "version": "0.0.186",
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": {
@@ -888,6 +888,7 @@ export function DailyBrief({
888
888
  <FilterPanelProvider hasSheet={showsRows && !resultsPanelOwnsBody}>
889
889
  <ThreadListInteraction
890
890
  selectedMessageId={selectedMessageId}
891
+ rows={filteredRows}
891
892
  onOpen={openRow}
892
893
  onDeleteMessages={onDeleteMessages}
893
894
  onSelectionVerb={wizard.start}
@@ -0,0 +1,214 @@
1
+ /**
2
+ * DeleteConfirmDialog — the dialog says what the delete will do, and when it
3
+ * cannot say, it refuses (#845, #855).
4
+ *
5
+ * The outcome arrives as a prop, so every branch is reachable here without a
6
+ * query in the way — including the one that matters most, a `/config` read that
7
+ * failed. `deleteOutcomeFor` (see `lib/format.test.ts`) pins which outcome each
8
+ * read state produces; this pins what each outcome puts on screen.
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
12
+ import type { JSDOM } from "jsdom";
13
+ import { act, createElement, Fragment } from "react";
14
+ import { createRoot, type Root } from "react-dom/client";
15
+ import {
16
+ type AuthProvider,
17
+ AuthProviderProvider,
18
+ noneAuthProvider,
19
+ } from "@/auth/provider";
20
+ import type { DeleteOutcome } from "@/lib/format";
21
+ import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
22
+
23
+ let dom: JSDOM;
24
+ let container: HTMLElement;
25
+ let root: Root;
26
+
27
+ before(async () => {
28
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
29
+ dom = new JSDOMCtor(
30
+ "<!doctype html><html><body><div id=root></div></body></html>",
31
+ { url: "http://localhost/", pretendToBeVisual: true },
32
+ );
33
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
34
+ globalThis.document = dom.window.document;
35
+ globalThis.HTMLElement = dom.window.HTMLElement;
36
+ globalThis.Element = dom.window.Element;
37
+ Object.defineProperty(globalThis, "navigator", {
38
+ value: dom.window.navigator,
39
+ configurable: true,
40
+ });
41
+ (
42
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
43
+ ).IS_REACT_ACT_ENVIRONMENT = true;
44
+ });
45
+
46
+ after(() => dom.window.close());
47
+
48
+ beforeEach(() => {
49
+ container = dom.window.document.getElementById(
50
+ "root",
51
+ ) as unknown as HTMLElement;
52
+ container.innerHTML = "";
53
+ root = createRoot(container);
54
+ });
55
+
56
+ afterEach(() => {
57
+ act(() => root.unmount());
58
+ });
59
+
60
+ /** A deployment with an identity system and a live session to sign back into. */
61
+ const sessionAuthProvider = (signOut: () => void): AuthProvider => ({
62
+ ...noneAuthProvider,
63
+ Account: ({ children }) =>
64
+ createElement(
65
+ Fragment,
66
+ null,
67
+ children({ email: "reader@example.com", signOut }),
68
+ ),
69
+ });
70
+
71
+ const mount = (options: {
72
+ outcome: DeleteOutcome;
73
+ count?: number;
74
+ isDeleting?: boolean;
75
+ authProvider?: AuthProvider;
76
+ onConfirm?: () => void;
77
+ }) => {
78
+ const onConfirm = options.onConfirm ?? (() => undefined);
79
+ act(() =>
80
+ root.render(
81
+ createElement(
82
+ AuthProviderProvider,
83
+ { value: options.authProvider ?? noneAuthProvider },
84
+ createElement(DeleteConfirmDialog, {
85
+ isOpen: true,
86
+ count: options.count ?? 1,
87
+ outcome: options.outcome,
88
+ isDeleting: options.isDeleting,
89
+ onConfirm,
90
+ onCancel: () => undefined,
91
+ }),
92
+ ),
93
+ ),
94
+ );
95
+ return {
96
+ text: () => dom.window.document.body.textContent ?? "",
97
+ button: (label: string) =>
98
+ Array.from(
99
+ dom.window.document.querySelectorAll<HTMLButtonElement>("button"),
100
+ ).find((b) => b.textContent === label),
101
+ };
102
+ };
103
+
104
+ describe("DeleteConfirmDialog — the wording follows the consequence", () => {
105
+ it("offers the reversible move outside Trash", () => {
106
+ const view = mount({ outcome: "trash" });
107
+ assert.match(view.text(), /Move 1 message to Trash\?/);
108
+ assert.equal(view.button("Move to Trash")?.disabled, false);
109
+ });
110
+
111
+ it("asks about destruction inside Trash", () => {
112
+ const view = mount({ outcome: "permanent", count: 3 });
113
+ assert.match(view.text(), /Permanently delete 3 messages\?/);
114
+ assert.match(view.text(), /cannot be restored/);
115
+ assert.equal(view.button("Delete permanently")?.disabled, false);
116
+ });
117
+
118
+ it("holds the confirm while the appointment is still arriving", () => {
119
+ const view = mount({ outcome: "unknown" });
120
+ assert.match(view.text(), /Checking where this account files deleted mail/);
121
+ assert.equal(
122
+ view.button("Delete")?.disabled,
123
+ true,
124
+ "the answer decides which of two dialogs this is",
125
+ );
126
+ });
127
+
128
+ it("holds the confirm while a delete is already in flight", () => {
129
+ const view = mount({ outcome: "trash", isDeleting: true });
130
+ assert.equal(view.button("Move to Trash")?.disabled, true);
131
+ });
132
+ });
133
+
134
+ /**
135
+ * The blocking case. TanStack leaves a failed read as `status: "error"` with no
136
+ * data, and an empty Trash set read as "this folder is not Trash" — so an
137
+ * expired session was shown "Move to Trash?" over an expunge. A read that could
138
+ * not answer never renders as an answer.
139
+ */
140
+ describe("DeleteConfirmDialog — a read that failed refuses the delete", () => {
141
+ it("states what failed instead of promising a move", () => {
142
+ const view = mount({ outcome: "unavailable" });
143
+ const text = view.text();
144
+ assert.match(text, /Can't delete 1 message/);
145
+ assert.match(text, /Nothing has been deleted/);
146
+ assert.doesNotMatch(text, /Move 1 message to Trash\?/);
147
+ assert.doesNotMatch(text, /restore them from Trash later/);
148
+ assert.doesNotMatch(
149
+ text,
150
+ /Checking where this account files deleted mail/,
151
+ "no read is in flight, so nothing may claim one is",
152
+ );
153
+ });
154
+
155
+ it("re-authenticates from the affirmative control, and never deletes", () => {
156
+ let signedOut = 0;
157
+ let confirmed = 0;
158
+ const view = mount({
159
+ outcome: "unavailable",
160
+ authProvider: sessionAuthProvider(() => {
161
+ signedOut += 1;
162
+ }),
163
+ onConfirm: () => {
164
+ confirmed += 1;
165
+ },
166
+ });
167
+
168
+ const signIn = view.button("Sign in again");
169
+ assert.ok(signIn, "the way back in is on screen");
170
+ assert.equal(signIn?.disabled, false, "and it can be pressed");
171
+
172
+ act(() => signIn?.click());
173
+ assert.equal(signedOut, 1);
174
+ assert.equal(confirmed, 0, "the refusal never reaches the delete");
175
+ });
176
+
177
+ it("offers a reload where there is no session to sign back into", () => {
178
+ const view = mount({ outcome: "unavailable" });
179
+ assert.equal(view.button("Sign in again"), undefined);
180
+ assert.equal(view.button("Reload reader")?.disabled, false);
181
+ });
182
+ });
183
+
184
+ /**
185
+ * An account that appoints no Trash is a resolved answer of "none", not a
186
+ * missing one: the server refuses that delete outright (#846) rather than
187
+ * moving anything, so the dialog may not offer a move — and the remedy is the
188
+ * appointment, not the session.
189
+ */
190
+ describe("DeleteConfirmDialog — no Trash appointed is not a move", () => {
191
+ it("refuses rather than promising a restore", () => {
192
+ const view = mount({ outcome: "noTrash", count: 2 });
193
+ const text = view.text();
194
+ assert.match(text, /Can.t delete 2 messages/);
195
+ assert.match(text, /appointed as Trash/);
196
+ assert.doesNotMatch(text, /Move 2 messages to Trash?/);
197
+ assert.doesNotMatch(text, /restore them from Trash later/);
198
+ });
199
+
200
+ it("names the screen that fixes it, and cannot reach the delete", () => {
201
+ let confirmed = 0;
202
+ const view = mount({
203
+ outcome: "noTrash",
204
+ onConfirm: () => {
205
+ confirmed += 1;
206
+ },
207
+ });
208
+ const settings = view.button("Open folder settings");
209
+ assert.ok(settings, "the remedy the copy names is on screen");
210
+ assert.equal(settings?.disabled, false, "and it can be pressed");
211
+ assert.equal(view.button("Move to Trash"), undefined);
212
+ assert.equal(confirmed, 0);
213
+ });
214
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The delete confirmation, worded from what the delete will actually do.
3
+ *
4
+ * One component for every list that deletes mail — the mailbox list, the brief
5
+ * and Flagged — so the wording and the refusal cannot drift apart again the way
6
+ * they did between #845 and #855.
7
+ *
8
+ * Two of the outcomes are not confirmations at all. When the account appoints
9
+ * no Trash the server refuses the delete outright, and when its folder settings
10
+ * could not be read reader cannot say whether a delete moves the mail or erases
11
+ * it. Both refuse: the affirmative control carries the remedy the copy names —
12
+ * folder settings, or signing back in — and the caller's `onConfirm` is never
13
+ * reached. Neither may render as "this folder is not Trash".
14
+ */
15
+ import { ConfirmDialog } from "@remit/ui";
16
+ import { useAuthProvider } from "@/auth/provider";
17
+ import { type DeleteOutcome, deleteConfirmationCopy } from "@/lib/format";
18
+
19
+ /** Where a missing Trash appointment is made — the remedy the copy names. */
20
+ const FOLDER_SETTINGS_PATH = "/settings/folders";
21
+
22
+ interface DeleteConfirmDialogProps {
23
+ isOpen: boolean;
24
+ /** How many messages the pending delete covers. */
25
+ count: number;
26
+ outcome: DeleteOutcome;
27
+ /** A delete is already in flight, so the confirm cannot be pressed again. */
28
+ isDeleting?: boolean;
29
+ onConfirm: () => void;
30
+ onCancel: () => void;
31
+ }
32
+
33
+ export const DeleteConfirmDialog = ({
34
+ isOpen,
35
+ count,
36
+ outcome,
37
+ isDeleting = false,
38
+ onConfirm,
39
+ onCancel,
40
+ }: DeleteConfirmDialogProps) => {
41
+ const { Account } = useAuthProvider();
42
+ const copy = deleteConfirmationCopy(count, outcome);
43
+
44
+ if (outcome === "noTrash") {
45
+ return (
46
+ <ConfirmDialog
47
+ isOpen={isOpen}
48
+ {...copy}
49
+ onConfirm={() => {
50
+ window.location.assign(FOLDER_SETTINGS_PATH);
51
+ }}
52
+ onCancel={onCancel}
53
+ />
54
+ );
55
+ }
56
+ if (outcome === "unavailable") {
57
+ return (
58
+ <Account
59
+ fallback={
60
+ <ConfirmDialog
61
+ isOpen={isOpen}
62
+ {...copy}
63
+ // Nothing to sign back into on a deployment with no identity
64
+ // system, so the way forward is the read itself.
65
+ confirmLabel="Reload reader"
66
+ onConfirm={() => window.location.reload()}
67
+ onCancel={onCancel}
68
+ />
69
+ }
70
+ >
71
+ {({ signOut }) => (
72
+ <ConfirmDialog
73
+ isOpen={isOpen}
74
+ {...copy}
75
+ onConfirm={() => signOut()}
76
+ onCancel={onCancel}
77
+ />
78
+ )}
79
+ </Account>
80
+ );
81
+ }
82
+
83
+ return (
84
+ <ConfirmDialog
85
+ isOpen={isOpen}
86
+ {...copy}
87
+ destructive
88
+ // The confirm holds while the appointment is still arriving: the answer
89
+ // is seconds away and it decides which of two dialogs this is.
90
+ isBusy={isDeleting || outcome === "unknown"}
91
+ onConfirm={onConfirm}
92
+ onCancel={onCancel}
93
+ />
94
+ );
95
+ };
@@ -275,6 +275,7 @@ export function FlaggedList({
275
275
  >
276
276
  <ThreadListInteraction
277
277
  selectedMessageId={selectedMessageId}
278
+ rows={rows}
278
279
  onOpen={openRow}
279
280
  onDeleteMessages={onDeleteMessages}
280
281
  onSelectionVerb={wizard.start}
@@ -3,6 +3,8 @@ import { readFileSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { describe, it } from "node:test";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { runEndingBanner } from "@/lib/bulk-action-copy";
7
+ import type { BulkRunOutcome } from "@/lib/bulk-actions";
6
8
 
7
9
  /**
8
10
  * A run states how it ended, to whoever is still there to read it (#521). The
@@ -10,37 +12,97 @@ import { fileURLToPath } from "node:url";
10
12
  * the run with no screen of its own — so the list says what it reached, whether
11
13
  * that was everything or a hundred out of three thousand.
12
14
  *
13
- * The list wires the virtualizer, routing and several data hooks together, so
14
- * as with this package's other component-level rules (see
15
- * `MessageList.selection.test.ts`) the wiring is read off the source. The
16
- * sentences themselves are unit-tested in `../../lib/bulk-action-copy.test.ts`,
17
- * and which runs reach this seam in `SelectionWizardHost.run-exit.test.ts`.
15
+ * Which ending gets which banner is `runEndingBanner`, and it is asserted here
16
+ * by its result. It used to be asserted by matching the source text of
17
+ * `reportRunOutcome`, which meant the rule held only as long as nobody moved
18
+ * those lines adding an argument to the completion call broke all three
19
+ * cases while the behaviour they protect was intact. The one fact that still
20
+ * cannot be read off a result is the wiring: that the wizard is handed
21
+ * somewhere to report an ending after it has closed. That stays a source read,
22
+ * because reaching it otherwise means mounting the list's virtualizer, router
23
+ * and data hooks around a run that has already finished.
24
+ *
25
+ * Which runs reach this seam at all is `SelectionWizardHost.run-exit.test.ts`.
18
26
  */
19
27
 
20
28
  const here = dirname(fileURLToPath(import.meta.url));
21
29
  const source = readFileSync(resolve(here, "MessageList.tsx"), "utf8");
22
30
 
23
- const reportBody = source.match(
24
- /const reportRunOutcome = useCallback\(([\s\S]*?)\n\t\t\[pushError\],/,
25
- )?.[1];
31
+ const ended = (over: Partial<BulkRunOutcome> = {}): BulkRunOutcome => ({
32
+ done: 100,
33
+ failedIds: [],
34
+ cancelled: false,
35
+ ...over,
36
+ });
26
37
 
27
38
  describe("reporting how a run ended", () => {
28
39
  it("hands the wizard somewhere to report an ending it can no longer show", () => {
29
- assert.ok(reportBody, "the list reports no run outcome");
30
40
  const host = source.match(/<SelectionWizardHost[\s\S]*?\/>/)?.[0] ?? "";
31
41
  assert.match(host, /onRunEnded=\{reportRunOutcome\}/);
42
+ assert.match(
43
+ source,
44
+ /const reportRunOutcome = useCallback\(/,
45
+ "the list reports no run outcome",
46
+ );
47
+ assert.match(
48
+ source,
49
+ /runEndingBanner\(kind, matched, outcome, deleteOutcome\)/,
50
+ "and the ending it reports is the one runEndingBanner decides",
51
+ );
32
52
  });
33
53
 
34
54
  it("names both endings: what it covered, and what it stopped short of", () => {
35
- assert.match(reportBody ?? "", /bulkActionStoppedTitle\(outcome\.done\)/);
36
- assert.match(
37
- reportBody ?? "",
38
- /bulkActionCompletionText\(kind, outcome\.done\)/,
55
+ const stopped = runEndingBanner(
56
+ "delete",
57
+ 3000,
58
+ ended({ cancelled: true }),
59
+ "trash",
39
60
  );
61
+ assert.equal(stopped?.title, "Stopped after 100");
62
+ assert.match(stopped?.detail ?? "", /100 of 3,000 moved to Trash\./);
63
+ assert.match(stopped?.detail ?? "", /Nothing was sent for the rest/);
64
+
65
+ const covered = runEndingBanner("delete", 100, ended(), "trash");
66
+ assert.match(covered?.title ?? "", /^100 moved to Trash\./);
40
67
  });
41
68
 
42
69
  it("raises a stopped run as a warning rather than a passing note", () => {
43
- assert.match(reportBody ?? "", /severity: "warning"/);
70
+ assert.equal(
71
+ runEndingBanner("delete", 3000, ended({ cancelled: true }), "trash")
72
+ ?.severity,
73
+ "warning",
74
+ "mail the user asked to be acted on was left untouched",
75
+ );
76
+ assert.equal(
77
+ runEndingBanner("delete", 100, ended(), "trash")?.severity,
78
+ "info",
79
+ );
80
+ });
81
+
82
+ it("says nothing about a run a thrown batch already bannered", () => {
83
+ assert.equal(
84
+ runEndingBanner(
85
+ "delete",
86
+ 100,
87
+ ended({ error: new Error("boom") }),
88
+ "trash",
89
+ ),
90
+ null,
91
+ "saying it twice is the one wrong answer",
92
+ );
93
+ });
94
+
95
+ it("names an expunge as an expunge, however the run ended", () => {
96
+ assert.match(
97
+ runEndingBanner("delete", 100, ended(), "permanent")?.title ?? "",
98
+ /^100 permanently deleted\./,
99
+ );
100
+ assert.match(
101
+ runEndingBanner("delete", 3000, ended({ cancelled: true }), "permanent")
102
+ ?.detail ?? "",
103
+ /100 of 3,000 permanently deleted\./,
104
+ "the half that ran is erased whether or not the rest did",
105
+ );
44
106
  });
45
107
  });
46
108
 
@@ -189,7 +189,7 @@ describe("MessageList confirmed delete stays on the list on mobile (#202)", () =
189
189
  it("raises a completion banner on mobile so the delete is not silent", () => {
190
190
  assert.match(
191
191
  source,
192
- /if \(!isDesktop\) \{\s*setCompletionBanner\(\s*bulkActionCompletionText\("delete", ids\.length\),?\s*\);\s*\}/,
192
+ /if \(!isDesktop\) \{\s*setCompletionBanner\(\s*bulkActionCompletionText\("delete", ids\.length, deleteOutcome\),?\s*\);\s*\}/,
193
193
  );
194
194
  });
195
195
  });
@@ -1,7 +1,6 @@
1
1
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
2
  import {
3
3
  Banner,
4
- ConfirmDialog,
5
4
  cn,
6
5
  type Density,
7
6
  deriveIsMultiSelectMode,
@@ -21,7 +20,8 @@ import type { RefObject } from "react";
21
20
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
21
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
23
22
  import { formatErrorMessage } from "@/components/ui/ErrorState";
24
- import { useJunkMailbox, useTrashMailbox } from "@/hooks/useArchiveMailbox";
23
+ import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
24
+ import { useDeleteOutcome } from "@/hooks/useDeleteOutcome";
25
25
  import {
26
26
  type EscalatedAction,
27
27
  type EscalationSearchQuery,
@@ -37,8 +37,7 @@ import {
37
37
  bulkActionCompletionText,
38
38
  bulkActionProgressLabel,
39
39
  bulkActionProgressTone,
40
- bulkActionStoppedDetail,
41
- bulkActionStoppedTitle,
40
+ runEndingBanner,
42
41
  } from "@/lib/bulk-action-copy";
43
42
  import type { BulkRunOutcome } from "@/lib/bulk-actions";
44
43
  import {
@@ -46,11 +45,7 @@ import {
46
45
  escalatedStatusLabel,
47
46
  escalationActionLabel,
48
47
  } from "@/lib/escalation-label";
49
- import {
50
- type DeleteOutcome,
51
- deleteConfirmationCopy,
52
- formatEmailDate,
53
- } from "@/lib/format";
48
+ import { formatEmailDate } from "@/lib/format";
54
49
  import { junkDestination } from "@/lib/junk-destination";
55
50
  import { tabStopId } from "@/lib/list-focus";
56
51
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
@@ -58,6 +53,7 @@ import { listVerbRequest } from "@/lib/list-verb-request";
58
53
  import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
59
54
  import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
60
55
  import { useRetainOpenPanels } from "@/routing";
56
+ import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
61
57
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
62
58
  import {
63
59
  type EscalatedSelection,
@@ -302,15 +298,13 @@ export const MessageList = ({
302
298
 
303
299
  // Deleting inside Trash is an expunge on the mail server, not a move, so the
304
300
  // 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";
301
+ // Every row here is filed in the open mailbox, so that one folder is the
302
+ // whole set the delete acts on.
303
+ const deleteScope = useMemo(
304
+ () => [{ accountId, mailboxId }],
305
+ [accountId, mailboxId],
306
+ );
307
+ const deleteOutcome = useDeleteOutcome(deleteScope);
314
308
 
315
309
  // Selection state
316
310
  const {
@@ -362,22 +356,12 @@ export const MessageList = ({
362
356
  // once the user has left it, so an ending is never said twice.
363
357
  const reportRunOutcome = useCallback(
364
358
  (kind: BulkActionKind, matched: number, outcome: BulkRunOutcome) => {
359
+ const banner = runEndingBanner(kind, matched, outcome, deleteOutcome);
365
360
  // A run stopped by a thrown batch is already banner-ed where it threw.
366
- if (outcome.error !== undefined) return;
367
- if (outcome.cancelled) {
368
- pushError({
369
- severity: "warning",
370
- title: bulkActionStoppedTitle(outcome.done),
371
- detail: bulkActionStoppedDetail(kind, outcome.done, matched),
372
- });
373
- return;
374
- }
375
- pushError({
376
- severity: "info",
377
- title: bulkActionCompletionText(kind, outcome.done),
378
- });
361
+ if (!banner) return;
362
+ pushError(banner);
379
363
  },
380
- [pushError],
364
+ [pushError, deleteOutcome],
381
365
  );
382
366
 
383
367
  // The one way selection mode ends (#115): cancel, a completed delete or
@@ -759,7 +743,9 @@ export const MessageList = ({
759
743
  // (#202). On desktop the rows leaving the list beside the reading pane is
760
744
  // signal enough.
761
745
  if (!isDesktop) {
762
- setCompletionBanner(bulkActionCompletionText("delete", ids.length));
746
+ setCompletionBanner(
747
+ bulkActionCompletionText("delete", ids.length, deleteOutcome),
748
+ );
763
749
  }
764
750
  }, [
765
751
  pendingDelete,
@@ -769,6 +755,7 @@ export const MessageList = ({
769
755
  openRow,
770
756
  isDesktop,
771
757
  setFocusedMessageId,
758
+ deleteOutcome,
772
759
  ]);
773
760
 
774
761
  // Every way out of the confirmation that isn't the delete — Escape, Cancel,
@@ -1353,11 +1340,11 @@ export const MessageList = ({
1353
1340
  (listState === "ready" ? virtualBody : undefined)
1354
1341
  }
1355
1342
  />
1356
- <ConfirmDialog
1343
+ <DeleteConfirmDialog
1357
1344
  isOpen={pendingDelete !== null}
1358
- {...deleteConfirmationCopy(pendingDelete?.length ?? 0, deleteOutcome)}
1359
- destructive
1360
- isBusy={isDeleting || deleteOutcome === "unknown"}
1345
+ count={pendingDelete?.length ?? 0}
1346
+ outcome={deleteOutcome}
1347
+ isDeleting={isDeleting}
1361
1348
  onConfirm={handleConfirmDelete}
1362
1349
  onCancel={handleCancelDelete}
1363
1350
  />