@rapidmx/web-client 0.16.0 → 0.17.0
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/README.md +36 -28
- package/apps/admin/distribution-lists/new/index.tsx +3 -1
- package/apps/admin/domains/[uid].tsx +3 -1
- package/apps/admin/domains/new/index.tsx +3 -1
- package/apps/admin/escrow-scopes/[uid].tsx +3 -1
- package/apps/admin/escrow-scopes/new/index.tsx +3 -1
- package/apps/admin/index.tsx +3 -1
- package/apps/admin/mailboxes/[uid].tsx +3 -1
- package/apps/admin/mailboxes/new/index.tsx +3 -1
- package/apps/admin/transport-rules/new/index.tsx +3 -1
- package/apps/escrow/matters/new/index.tsx +3 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +4 -1
- package/apps/shared/components/admin/setup/SetupWizard.tsx +3 -1
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +2 -2
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +3 -2
- package/apps/shared/components/layout/AppShell.tsx +24 -43
- package/apps/shared/components/mail/ConversationThreadPane.tsx +1 -0
- package/apps/shared/components/mail/EmptyFolderBar.tsx +41 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +19 -15
- package/apps/shared/components/mail/MenuButton.tsx +31 -11
- package/apps/shared/components/mail/MessageDetailPane.tsx +202 -4
- package/apps/shared/components/mail/PermanentDeleteDialog.tsx +47 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +37 -34
- package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -0
- package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -0
- package/apps/shared/components/mail/reading/messageExport.ts +59 -0
- package/apps/shared/components/mail/reading/printMessage.ts +99 -0
- package/apps/shared/components/mail/reading/useMessageActions.ts +443 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +4 -2
- package/apps/shared/components/tasks/layout/TasksShell.tsx +3 -2
- package/apps/shared/keyboard/GlobalShortcuts.tsx +1 -1
- package/apps/shared/mail/folderOfType.ts +49 -0
- package/apps/shared/mail/permanentDelete.ts +160 -0
- package/apps/shared/mail/reportNotices.ts +66 -0
- package/apps/shared/mail/senderBlocking.ts +141 -0
- package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -0
- package/apps/shared/mail/usePermanentDelete.tsx +160 -0
- package/apps/shared/navigation/frameContext.tsx +1 -1
- package/apps/shared/navigation/index.ts +43 -0
- package/apps/shared/navigation/pageTitle.ts +19 -0
- package/apps/www/_shell.tsx +82 -0
- package/apps/www/calendar/index.tsx +5 -2
- package/apps/www/contacts/[uid].tsx +6 -3
- package/apps/www/contacts/index.tsx +8 -3
- package/apps/www/index.tsx +146 -31
- package/apps/www/messages/[uid].tsx +8 -4
- package/apps/www/settings/appearance/index.tsx +5 -2
- package/apps/www/settings/auto-reply/index.tsx +5 -2
- package/apps/www/settings/blocked-senders/index.tsx +303 -0
- package/apps/www/settings/encryption/index.tsx +5 -2
- package/apps/www/settings/filters/[uid].tsx +5 -2
- package/apps/www/settings/filters/index.tsx +5 -2
- package/apps/www/settings/filters/new/index.tsx +30 -5
- package/apps/www/settings/labels/index.tsx +5 -2
- package/apps/www/settings/privacy/index.tsx +5 -2
- package/apps/www/settings/profile/index.tsx +5 -2
- package/apps/www/settings/read-receipts/index.tsx +5 -2
- package/apps/www/settings/sharing/index.tsx +5 -2
- package/apps/www/settings/signatures/[uid].tsx +5 -2
- package/apps/www/settings/signatures/index.tsx +5 -2
- package/apps/www/settings/signatures/new/index.tsx +6 -3
- package/apps/www/tasks/index.tsx +7 -2
- package/dist/apps/admin/distribution-lists/new/index.js +3 -1
- package/dist/apps/admin/domains/[uid].js +3 -1
- package/dist/apps/admin/domains/new/index.js +3 -1
- package/dist/apps/admin/escrow-scopes/[uid].js +3 -1
- package/dist/apps/admin/escrow-scopes/new/index.js +3 -1
- package/dist/apps/admin/index.js +3 -1
- package/dist/apps/admin/mailboxes/[uid].js +3 -1
- package/dist/apps/admin/mailboxes/new/index.js +3 -1
- package/dist/apps/admin/transport-rules/new/index.js +3 -1
- package/dist/apps/escrow/matters/new/index.js +3 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +4 -1
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +3 -1
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +2 -2
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +3 -2
- package/dist/apps/shared/components/layout/AppShell.d.ts +8 -11
- package/dist/apps/shared/components/layout/AppShell.js +24 -33
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +1 -1
- package/dist/apps/shared/components/mail/EmptyFolderBar.d.ts +18 -0
- package/dist/apps/shared/components/mail/EmptyFolderBar.js +10 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +9 -4
- package/dist/apps/shared/components/mail/MailSelectionBar.js +10 -12
- package/dist/apps/shared/components/mail/MenuButton.d.ts +9 -2
- package/dist/apps/shared/components/mail/MenuButton.js +15 -11
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +5 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +147 -7
- package/dist/apps/shared/components/mail/PermanentDeleteDialog.d.ts +19 -0
- package/dist/apps/shared/components/mail/PermanentDeleteDialog.js +11 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +13 -9
- package/dist/apps/shared/components/mail/layout/MailShell.js +27 -33
- package/dist/apps/shared/components/mail/reading/MessageMoreMenu.d.ts +56 -0
- package/dist/apps/shared/components/mail/reading/MessageMoreMenu.js +153 -0
- package/dist/apps/shared/components/mail/reading/MessageSourceDialog.d.ts +27 -0
- package/dist/apps/shared/components/mail/reading/MessageSourceDialog.js +27 -0
- package/dist/apps/shared/components/mail/reading/messageExport.d.ts +19 -0
- package/dist/apps/shared/components/mail/reading/messageExport.js +51 -0
- package/dist/apps/shared/components/mail/reading/printMessage.d.ts +30 -0
- package/dist/apps/shared/components/mail/reading/printMessage.js +70 -0
- package/dist/apps/shared/components/mail/reading/useMessageActions.d.ts +57 -0
- package/dist/apps/shared/components/mail/reading/useMessageActions.js +366 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +4 -2
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +3 -2
- package/dist/apps/shared/keyboard/GlobalShortcuts.js +1 -1
- package/dist/apps/shared/mail/folderOfType.d.ts +13 -0
- package/dist/apps/shared/mail/folderOfType.js +40 -0
- package/dist/apps/shared/mail/permanentDelete.d.ts +62 -0
- package/dist/apps/shared/mail/permanentDelete.js +127 -0
- package/dist/apps/shared/mail/reportNotices.d.ts +21 -0
- package/dist/apps/shared/mail/reportNotices.js +47 -0
- package/dist/apps/shared/mail/senderBlocking.d.ts +26 -0
- package/dist/apps/shared/mail/senderBlocking.js +92 -0
- package/dist/apps/shared/mail/useMailboxUpdateAccess.d.ts +11 -0
- package/dist/apps/shared/mail/useMailboxUpdateAccess.js +52 -0
- package/dist/apps/shared/mail/usePermanentDelete.d.ts +35 -0
- package/dist/apps/shared/mail/usePermanentDelete.js +96 -0
- package/dist/apps/shared/navigation/frameContext.d.ts +1 -1
- package/dist/apps/shared/navigation/index.d.ts +16 -0
- package/dist/apps/shared/navigation/index.js +34 -0
- package/dist/apps/shared/navigation/pageTitle.d.ts +11 -0
- package/dist/apps/shared/navigation/pageTitle.js +12 -0
- package/dist/apps/www/_shell.d.ts +26 -0
- package/dist/apps/www/_shell.js +31 -0
- package/dist/apps/www/calendar/index.d.ts +7 -2
- package/dist/apps/www/calendar/index.js +4 -2
- package/dist/apps/www/contacts/[uid].d.ts +14 -3
- package/dist/apps/www/contacts/[uid].js +5 -3
- package/dist/apps/www/contacts/index.d.ts +7 -2
- package/dist/apps/www/contacts/index.js +6 -4
- package/dist/apps/www/index.d.ts +7 -2
- package/dist/apps/www/index.js +117 -19
- package/dist/apps/www/messages/[uid].d.ts +14 -3
- package/dist/apps/www/messages/[uid].js +7 -5
- package/dist/apps/www/settings/appearance/index.d.ts +8 -2
- package/dist/apps/www/settings/appearance/index.js +4 -2
- package/dist/apps/www/settings/auto-reply/index.d.ts +7 -2
- package/dist/apps/www/settings/auto-reply/index.js +4 -2
- package/dist/apps/www/settings/blocked-senders/index.d.ts +9 -0
- package/dist/apps/www/settings/blocked-senders/index.js +134 -0
- package/dist/apps/www/settings/encryption/index.d.ts +7 -2
- package/dist/apps/www/settings/encryption/index.js +4 -2
- package/dist/apps/www/settings/filters/[uid].d.ts +7 -2
- package/dist/apps/www/settings/filters/[uid].js +4 -2
- package/dist/apps/www/settings/filters/index.d.ts +7 -2
- package/dist/apps/www/settings/filters/index.js +4 -2
- package/dist/apps/www/settings/filters/new/index.d.ts +7 -2
- package/dist/apps/www/settings/filters/new/index.js +28 -5
- package/dist/apps/www/settings/labels/index.d.ts +7 -2
- package/dist/apps/www/settings/labels/index.js +4 -2
- package/dist/apps/www/settings/privacy/index.d.ts +7 -2
- package/dist/apps/www/settings/privacy/index.js +4 -2
- package/dist/apps/www/settings/profile/index.d.ts +7 -2
- package/dist/apps/www/settings/profile/index.js +4 -2
- package/dist/apps/www/settings/read-receipts/index.d.ts +7 -2
- package/dist/apps/www/settings/read-receipts/index.js +4 -2
- package/dist/apps/www/settings/sharing/index.d.ts +7 -2
- package/dist/apps/www/settings/sharing/index.js +4 -2
- package/dist/apps/www/settings/signatures/[uid].d.ts +7 -2
- package/dist/apps/www/settings/signatures/[uid].js +4 -2
- package/dist/apps/www/settings/signatures/index.d.ts +7 -2
- package/dist/apps/www/settings/signatures/index.js +4 -2
- package/dist/apps/www/settings/signatures/new/index.d.ts +7 -2
- package/dist/apps/www/settings/signatures/new/index.js +5 -3
- package/dist/apps/www/tasks/index.d.ts +7 -2
- package/dist/apps/www/tasks/index.js +5 -3
- package/package.json +4 -2
- package/apps/shared/navigation/AppRouter.tsx +0 -300
- package/apps/shared/navigation/idle.ts +0 -45
- package/apps/shared/navigation/routerContext.tsx +0 -83
- package/apps/shared/navigation/routes.ts +0 -72
- package/apps/www/_routedPage.tsx +0 -24
- package/apps/www/_routes.ts +0 -37
- package/dist/apps/shared/navigation/AppRouter.d.ts +0 -52
- package/dist/apps/shared/navigation/AppRouter.js +0 -242
- package/dist/apps/shared/navigation/idle.d.ts +0 -14
- package/dist/apps/shared/navigation/idle.js +0 -43
- package/dist/apps/shared/navigation/routerContext.d.ts +0 -37
- package/dist/apps/shared/navigation/routerContext.js +0 -56
- package/dist/apps/shared/navigation/routes.d.ts +0 -32
- package/dist/apps/shared/navigation/routes.js +0 -37
- package/dist/apps/www/_routedPage.d.ts +0 -12
- package/dist/apps/www/_routedPage.js +0 -19
- package/dist/apps/www/_routes.d.ts +0 -11
- package/dist/apps/www/_routes.js +0 -31
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { HiOutlineTrash } from "react-icons/hi2";
|
|
7
|
+
|
|
8
|
+
export interface EmptyFolderBarProps {
|
|
9
|
+
/** The folder the list is showing ("Deleted Items", "Junk Email"). */
|
|
10
|
+
folderName: string;
|
|
11
|
+
/** How many messages it holds, when that is known. */
|
|
12
|
+
count?: number;
|
|
13
|
+
/** Nothing to empty, or not allowed to. */
|
|
14
|
+
disabled: boolean;
|
|
15
|
+
/** Why, shown as the button's tooltip while it is disabled. */
|
|
16
|
+
disabledReason?: string;
|
|
17
|
+
onEmpty: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Outlook's "Empty folder", at the top of the list of Deleted Items and of Junk Email: the whole folder is permanently deleted, after the
|
|
22
|
+
* page has asked (this only reports the click). A row of its own under the toolbar rather than a toolbar button, so the toolbar keeps the
|
|
23
|
+
* width a phone gives it and the action is never taken for one of the list's arrangement controls.
|
|
24
|
+
*/
|
|
25
|
+
export default function EmptyFolderBar({ folderName, count, disabled, disabledReason, onEmpty }: EmptyFolderBarProps) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex items-center justify-between gap-2 px-3 py-1 border-b border-border text-xs text-text-muted">
|
|
28
|
+
<span>{count === undefined ? "" : `${count} ${count === 1 ? "item" : "items"}`}</span>
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
onClick={onEmpty}
|
|
32
|
+
disabled={disabled}
|
|
33
|
+
title={disabled ? disabledReason : `Permanently delete everything in ${folderName}`}
|
|
34
|
+
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-sm text-danger hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent"
|
|
35
|
+
>
|
|
36
|
+
<HiOutlineTrash size={16} aria-hidden="true" className="shrink-0" />
|
|
37
|
+
{`Empty ${folderName}`}
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -46,7 +46,12 @@ export interface MailSelectionBarProps {
|
|
|
46
46
|
* prompt, which would be claiming the move simply didn't happen. */
|
|
47
47
|
onMoveTo: (folderUid: string) => Promise<void>;
|
|
48
48
|
onReportJunk: () => void;
|
|
49
|
+
/** Delete: moves the selection to Deleted Items - or, when `deletesPermanently`, permanently deletes it (the page asks first). */
|
|
49
50
|
onDelete: () => void;
|
|
51
|
+
/** The selection is in Deleted Items, so Delete is the permanent one: the button says "Delete permanently" and is styled as the destructive
|
|
52
|
+
* action it is. The page decides (a search over several mailboxes lists messages from more than one folder, so the folder being viewed is not
|
|
53
|
+
* always the answer). */
|
|
54
|
+
deletesPermanently?: boolean;
|
|
50
55
|
/** A bulk action is in flight - every action is held until it settles, since the next one would send
|
|
51
56
|
* `version`s the first has already superseded. */
|
|
52
57
|
busy: boolean;
|
|
@@ -60,8 +65,8 @@ export interface MailSelectionBarProps {
|
|
|
60
65
|
labelsDisabledReason?: string;
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
function actionClassName(): string {
|
|
64
|
-
return
|
|
68
|
+
function actionClassName(destructive = false): string {
|
|
69
|
+
return `px-2 py-1 rounded-md text-sm ${destructive ? "text-danger font-semibold" : "text-text"} hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent`;
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/**
|
|
@@ -69,9 +74,9 @@ function actionClassName(): string {
|
|
|
69
74
|
* select-all/clear, and the bulk actions Outlook offers for a multi-selection - mark read/unread, flag,
|
|
70
75
|
* archive, move, report junk and delete.
|
|
71
76
|
*
|
|
72
|
-
* "Delete" means *move to Deleted Items
|
|
73
|
-
*
|
|
74
|
-
* target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
|
|
77
|
+
* "Delete" means *move to Deleted Items* - except on messages that are already in Deleted Items (`deletesPermanently`),
|
|
78
|
+
* where it is "Delete permanently": the page confirms, then erases them for good, as Outlook's Delete does there. An
|
|
79
|
+
* action whose target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
|
|
75
80
|
* of actions doesn't shift around between folders.
|
|
76
81
|
*/
|
|
77
82
|
export default function MailSelectionBar({
|
|
@@ -94,17 +99,16 @@ export default function MailSelectionBar({
|
|
|
94
99
|
onMoveTo,
|
|
95
100
|
onReportJunk,
|
|
96
101
|
onDelete,
|
|
102
|
+
deletesPermanently = false,
|
|
97
103
|
busy,
|
|
98
104
|
shortcuts,
|
|
99
105
|
moveDisabledReason,
|
|
100
106
|
labelsDisabledReason,
|
|
101
107
|
}: MailSelectionBarProps) {
|
|
102
108
|
const env = useKeyEnvironment();
|
|
103
|
-
/** `title` and `aria-keyshortcuts` for an action the keyboard also does
|
|
104
|
-
const hint = (label: string, shortcut: ShortcutDef
|
|
105
|
-
shortcuts
|
|
106
|
-
? { title: reason ?? withHint(label, shortcut, env), "aria-keyshortcuts": ariaKeyShortcuts(shortcut, env) }
|
|
107
|
-
: { title: reason };
|
|
109
|
+
/** `title` and `aria-keyshortcuts` for an action the keyboard also does. */
|
|
110
|
+
const hint = (label: string, shortcut: ShortcutDef) =>
|
|
111
|
+
shortcuts ? { title: withHint(label, shortcut, env), "aria-keyshortcuts": ariaKeyShortcuts(shortcut, env) } : {};
|
|
108
112
|
const selectedCount = totals?.selected ?? selected.length;
|
|
109
113
|
const listedCount = totals?.listed ?? listed.length;
|
|
110
114
|
// Counted on the rows that were ticked, but *emptied* on the messages: a ticked conversation whose
|
|
@@ -122,7 +126,7 @@ export default function MailSelectionBar({
|
|
|
122
126
|
// them is already being in the folder they would move to.
|
|
123
127
|
const archiveReason = currentType === "archive" ? "These messages are already in Archive" : undefined;
|
|
124
128
|
const junkReason = currentType === "junk" ? "These messages are already in Junk" : undefined;
|
|
125
|
-
const
|
|
129
|
+
const deleteLabel = deletesPermanently ? "Delete permanently" : "Delete";
|
|
126
130
|
|
|
127
131
|
// A label every selected message already carries starts ticked; one only some of them carry starts
|
|
128
132
|
// partially applied, and is left exactly as it is unless the reader touches that row.
|
|
@@ -224,11 +228,11 @@ export default function MailSelectionBar({
|
|
|
224
228
|
<button
|
|
225
229
|
type="button"
|
|
226
230
|
onClick={onDelete}
|
|
227
|
-
disabled={none || busy
|
|
228
|
-
className={actionClassName()}
|
|
229
|
-
{...hint(
|
|
231
|
+
disabled={none || busy}
|
|
232
|
+
className={actionClassName(deletesPermanently)}
|
|
233
|
+
{...hint(deleteLabel, SHORTCUTS.mail.delete)}
|
|
230
234
|
>
|
|
231
|
-
|
|
235
|
+
{deleteLabel}
|
|
232
236
|
</button>
|
|
233
237
|
</div>
|
|
234
238
|
</div>
|
|
@@ -19,6 +19,10 @@ interface MenuItemBase {
|
|
|
19
19
|
checked?: boolean | "mixed";
|
|
20
20
|
/** A colour swatch before the label - a label's own colour. */
|
|
21
21
|
swatchColor?: string;
|
|
22
|
+
/** An icon before the label, for a menu whose rows are commands (the reading pane's "More actions"). */
|
|
23
|
+
icon?: ReactNode;
|
|
24
|
+
/** A tooltip for the row - the full text of a label the row truncates. */
|
|
25
|
+
title?: string;
|
|
22
26
|
disabled?: boolean;
|
|
23
27
|
/** Leaves the menu open after choosing this row, for a multi-select list where several rows are ticked
|
|
24
28
|
* before one command commits them all. */
|
|
@@ -97,8 +101,11 @@ export function menuHeight(sections: MenuSectionSpec[], width: number = DEFAULT_
|
|
|
97
101
|
}
|
|
98
102
|
|
|
99
103
|
export interface MenuButtonProps {
|
|
100
|
-
/** The trigger's visible label. */
|
|
104
|
+
/** The trigger's visible label. Not drawn (nor is the chevron) with `iconOnly`, where `aria-label` is all the trigger says. */
|
|
101
105
|
label: ReactNode;
|
|
106
|
+
/** The trigger is just its `icon` - a round icon button like the reading pane's own command row, whose `className` it takes over
|
|
107
|
+
* completely. `aria-label` (and `title`) name it. */
|
|
108
|
+
iconOnly?: boolean;
|
|
102
109
|
/** The trigger's accessible name, which also names the menu itself - includes the current selection
|
|
103
110
|
* where there is one ("Filter: Unread"), so the button says what it's set to, not just what it does. */
|
|
104
111
|
"aria-label": string;
|
|
@@ -127,6 +134,7 @@ export interface MenuButtonProps {
|
|
|
127
134
|
*/
|
|
128
135
|
export default function MenuButton({
|
|
129
136
|
label,
|
|
137
|
+
iconOnly = false,
|
|
130
138
|
icon,
|
|
131
139
|
disabled,
|
|
132
140
|
title,
|
|
@@ -190,8 +198,10 @@ export default function MenuButton({
|
|
|
190
198
|
|
|
191
199
|
function enterSubmenu(item: MenuSubmenuSpec) {
|
|
192
200
|
setSubmenuKey(item.key);
|
|
193
|
-
|
|
194
|
-
|
|
201
|
+
const rows = item.submenu.flatMap((section) => section.items);
|
|
202
|
+
// Past the Back row, onto the first row of the submenu itself - or, when none of them can take focus (every row is unavailable right now), onto
|
|
203
|
+
// the Back row, so the keyboard still has a place to be.
|
|
204
|
+
setActiveIndex(rows.some((row) => !row.disabled) ? 1 + initialActiveIndex(rows) : 0);
|
|
195
205
|
}
|
|
196
206
|
|
|
197
207
|
function leaveSubmenu() {
|
|
@@ -285,16 +295,20 @@ export default function MenuButton({
|
|
|
285
295
|
disabled={disabled}
|
|
286
296
|
onClick={() => (open ? closeMenu(false) : openMenu())}
|
|
287
297
|
onKeyDown={handleTriggerKeyDown}
|
|
288
|
-
className={
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
298
|
+
className={
|
|
299
|
+
iconOnly
|
|
300
|
+
? className
|
|
301
|
+
: [
|
|
302
|
+
// `min-w-0` so a long label ("Filter: Has attachments") truncates instead of pushing
|
|
303
|
+
// whatever sits beside it in the toolbar off the edge of a 384px list column.
|
|
304
|
+
"inline-flex min-w-0 items-center gap-1 px-2 py-1 rounded-md text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent",
|
|
305
|
+
className,
|
|
306
|
+
].join(" ")
|
|
307
|
+
}
|
|
294
308
|
>
|
|
295
309
|
{icon}
|
|
296
|
-
<span className="truncate">{label}</span>
|
|
297
|
-
<HiChevronDown size={14} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
310
|
+
{!iconOnly && <span className="truncate">{label}</span>}
|
|
311
|
+
{!iconOnly && <HiChevronDown size={14} aria-hidden="true" className="shrink-0 text-text-muted" />}
|
|
298
312
|
</button>
|
|
299
313
|
{open && (
|
|
300
314
|
<PopoverPortal
|
|
@@ -332,6 +346,7 @@ export default function MenuButton({
|
|
|
332
346
|
type="button"
|
|
333
347
|
role={role}
|
|
334
348
|
disabled={item.disabled}
|
|
349
|
+
title={item.title}
|
|
335
350
|
tabIndex={index === activeIndex ? 0 : -1}
|
|
336
351
|
{...(role === "menuitem"
|
|
337
352
|
? {}
|
|
@@ -352,6 +367,11 @@ export default function MenuButton({
|
|
|
352
367
|
</span>
|
|
353
368
|
<span className="min-w-0 flex-1">
|
|
354
369
|
<span className="flex h-5 items-center gap-1.5">
|
|
370
|
+
{item.icon && (
|
|
371
|
+
<span aria-hidden="true" className="shrink-0 text-text-muted">
|
|
372
|
+
{item.icon}
|
|
373
|
+
</span>
|
|
374
|
+
)}
|
|
355
375
|
{item.swatchColor && (
|
|
356
376
|
<span
|
|
357
377
|
aria-hidden="true"
|
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
HiOutlineCheck,
|
|
11
11
|
HiOutlineExclamationTriangle,
|
|
12
12
|
HiOutlineFolderArrowDown,
|
|
13
|
+
HiOutlineInboxArrowDown,
|
|
13
14
|
HiOutlineLockClosed,
|
|
14
15
|
HiOutlineMoon,
|
|
16
|
+
HiOutlineNoSymbol,
|
|
15
17
|
HiOutlineSun,
|
|
16
18
|
} from "react-icons/hi2";
|
|
17
19
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
@@ -68,6 +70,10 @@ import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
|
|
|
68
70
|
import { useShortcut } from "../../keyboard/useShortcut.js";
|
|
69
71
|
import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
|
|
70
72
|
import { moveLocalEntity } from "../../search/localIndexRpcClient.js";
|
|
73
|
+
import { useNavigate } from "../../navigation/index.js";
|
|
74
|
+
import { notify } from "../../notifications/store.js";
|
|
75
|
+
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
76
|
+
import { useMailboxUpdateAccess } from "../../mail/useMailboxUpdateAccess.js";
|
|
71
77
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
72
78
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
73
79
|
import LabelMenuButton from "./labelMenu.js";
|
|
@@ -76,7 +82,12 @@ import EncryptedBody from "./reading/EncryptedBody.js";
|
|
|
76
82
|
import { displaySubject } from "./reading/EncryptedPreview.js";
|
|
77
83
|
import MessageBody, { BodySkeleton } from "./reading/MessageBody.js";
|
|
78
84
|
import { BODY_FONT_STYLE, CardShell, SenderAvatar, SubjectCard } from "./reading/MessageCard.js";
|
|
79
|
-
import
|
|
85
|
+
import MessageMoreMenu, { MessageMenuActions } from "./reading/MessageMoreMenu.js";
|
|
86
|
+
import MessageSourceDialog, { SourceMode, SourceState } from "./reading/MessageSourceDialog.js";
|
|
87
|
+
import { useMessageActions } from "./reading/useMessageActions.js";
|
|
88
|
+
import { saveAsEml } from "./reading/messageExport.js";
|
|
89
|
+
import { buildPrintDocument, printDocument } from "./reading/printMessage.js";
|
|
90
|
+
import { type BodyContent, fetchBodyContent } from "./reading/bodyContent.js";
|
|
80
91
|
import { useViewOriginal } from "./reading/viewOriginal.js";
|
|
81
92
|
import { ROW_FOCUS_CLASS, UnreadLabel, dateClass, senderClass } from "./unreadStyle.js";
|
|
82
93
|
|
|
@@ -198,11 +209,16 @@ function downloadMimeAttachment(attachment: MimeAttachment): void {
|
|
|
198
209
|
* A plain `<button>` rather than react-shared's `Button`, whose padding and minimum width are sized for a
|
|
199
210
|
* text label. Ordinary DOM order, so the keyboard reaches these in the order they are read.
|
|
200
211
|
*/
|
|
212
|
+
/** The look of the card's round icon buttons, shared with the "More actions" trigger. */
|
|
213
|
+
const ICON_BUTTON_CLASS =
|
|
214
|
+
"inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent";
|
|
215
|
+
|
|
201
216
|
function IconAction({
|
|
202
217
|
icon,
|
|
203
218
|
label,
|
|
204
219
|
onClick,
|
|
205
220
|
disabled,
|
|
221
|
+
reason,
|
|
206
222
|
busy,
|
|
207
223
|
onPrefetch,
|
|
208
224
|
shortcut,
|
|
@@ -211,6 +227,8 @@ function IconAction({
|
|
|
211
227
|
label: string;
|
|
212
228
|
onClick: () => void;
|
|
213
229
|
disabled?: boolean;
|
|
230
|
+
/** Why the button is disabled, when it is: the tooltip says so instead of naming the action. */
|
|
231
|
+
reason?: string;
|
|
214
232
|
busy?: boolean;
|
|
215
233
|
/** Called when the pointer or keyboard reaches the button - the moment to start fetching what a click will need. */
|
|
216
234
|
onPrefetch?: () => void;
|
|
@@ -223,14 +241,14 @@ function IconAction({
|
|
|
223
241
|
<button
|
|
224
242
|
type="button"
|
|
225
243
|
aria-label={label}
|
|
226
|
-
title={shortcut ? withHint(label, shortcut, env) : label}
|
|
244
|
+
title={reason ?? (shortcut ? withHint(label, shortcut, env) : label)}
|
|
227
245
|
aria-keyshortcuts={shortcut ? ariaKeyShortcuts(shortcut, env) : undefined}
|
|
228
246
|
aria-busy={busy || undefined}
|
|
229
247
|
disabled={disabled}
|
|
230
248
|
onClick={onClick}
|
|
231
249
|
onPointerEnter={onPrefetch}
|
|
232
250
|
onFocus={onPrefetch}
|
|
233
|
-
className=
|
|
251
|
+
className={ICON_BUTTON_CLASS}
|
|
234
252
|
>
|
|
235
253
|
{icon}
|
|
236
254
|
</button>
|
|
@@ -333,6 +351,11 @@ export interface MessageDetailPaneProps {
|
|
|
333
351
|
* server-side 400 guard); Drafts is detected via `draftsFolderUid` (already passed by every caller
|
|
334
352
|
* for the Outbox-cancel flow) rather than a new prop. */
|
|
335
353
|
onArchived?: (updated: Message) => void;
|
|
354
|
+
/** Called with the server's updated copy (and the copy it replaces) after the card's More actions menu marked the message read or unread, or
|
|
355
|
+
* flagged or unflagged it - it stays where it is, so a caller patches it in place, as it does for `onLabelsChanged`. The menu's Delete, Report
|
|
356
|
+
* junk and Block move the message out of its folder and are reported through `onMoved`, which is what makes a thread's card leave it and the
|
|
357
|
+
* pane advance, exactly as after Archive. */
|
|
358
|
+
onChanged?: (updated: Message, previous?: Message) => void;
|
|
336
359
|
/** Every label defined in this message's mailbox, for the label-assignment popover below — each
|
|
337
360
|
* caller fetches its own mailbox's labels the same way it already resolves `draftsFolderUid`
|
|
338
361
|
* (`listLabels()`). Absent/empty simply hides the Labels control - there's nothing to assign. */
|
|
@@ -369,9 +392,14 @@ export interface MessageDetailPaneProps {
|
|
|
369
392
|
shortcuts?: boolean;
|
|
370
393
|
}
|
|
371
394
|
|
|
395
|
+
/** A subject without its \`Re:\`/\`Fwd:\` prefixes. */
|
|
396
|
+
function withoutSubjectPrefixes(subject: string | undefined): string {
|
|
397
|
+
return (subject ?? "").replace(/^(\s*(re|fw|fwd|aw|sv)\s*:)+/i, "").trim();
|
|
398
|
+
}
|
|
399
|
+
|
|
372
400
|
/** A subject without its \`Re:\`/\`Fwd:\` prefixes, lower-cased - what makes two messages of one thread the same subject. */
|
|
373
401
|
function subjectCore(subject: string | undefined): string {
|
|
374
|
-
return (subject
|
|
402
|
+
return withoutSubjectPrefixes(subject).toLowerCase();
|
|
375
403
|
}
|
|
376
404
|
|
|
377
405
|
/** Whether a message's subject says nothing its thread's subject doesn't. */
|
|
@@ -425,6 +453,7 @@ function MessageDetailContent({
|
|
|
425
453
|
onFolderCreated,
|
|
426
454
|
onReceiptHandled,
|
|
427
455
|
onArchived,
|
|
456
|
+
onChanged,
|
|
428
457
|
labels,
|
|
429
458
|
onLabelsChanged,
|
|
430
459
|
onLabelCreated,
|
|
@@ -922,6 +951,37 @@ function MessageDetailContent({
|
|
|
922
951
|
useShortcut(SHORTCUTS.mail.archive, () => void (!archiving && handleArchive()), { enabled: keyboard && archivable });
|
|
923
952
|
useShortcut(SHORTCUTS.mail.move, () => setMovePrompt(true), { enabled: keyboard && movable });
|
|
924
953
|
|
|
954
|
+
// The card's Report junk button and "More actions" menu: they act on this message, in the mailbox it is in. Nothing of them applies to a message
|
|
955
|
+
// being written or sent (Drafts, Outbox), and Report junk and Block are not for mail the reader sent.
|
|
956
|
+
const folderType = folders?.find((folder) => folder.uid === message.folderUid)?.type;
|
|
957
|
+
const inJunk = folderType === "junk";
|
|
958
|
+
const inDeletedItems = folderType === "deleted_items";
|
|
959
|
+
const reportable = archivable && !isSentItems;
|
|
960
|
+
const writable = useMailboxUpdateAccess(readerMailbox);
|
|
961
|
+
const remember = (updated: Message) => {
|
|
962
|
+
latestLabelsMessageRef.current = updated;
|
|
963
|
+
setLabelsMessage(updated);
|
|
964
|
+
};
|
|
965
|
+
// Every address the reader owns, in any of their mailboxes: a sender among them is not blocked.
|
|
966
|
+
const readerOwnAddresses = mailboxes.flatMap((mb) => [mb.primarySmtpAddress, ...(mb.aliasAddresses ?? [])]).filter((address) => !!address).map((address) => address.toLowerCase());
|
|
967
|
+
const messageActions = useMessageActions({
|
|
968
|
+
message,
|
|
969
|
+
ownAddresses: readerOwnAddresses,
|
|
970
|
+
newest: () => (message.version >= latestLabelsMessageRef.current.version ? message : latestLabelsMessageRef.current),
|
|
971
|
+
remember,
|
|
972
|
+
folders,
|
|
973
|
+
inJunk,
|
|
974
|
+
inDeletedItems,
|
|
975
|
+
trackMessageChange,
|
|
976
|
+
onMoved,
|
|
977
|
+
onChanged,
|
|
978
|
+
onFolderCreated,
|
|
979
|
+
});
|
|
980
|
+
const navigate = useNavigate();
|
|
981
|
+
const [sourceView, setSourceView] = useState<{ mode: SourceMode; source: SourceState } | null>(null);
|
|
982
|
+
// Which "View source" request the dialog is waiting for: one answering after the dialog was closed (or another was opened) is not shown.
|
|
983
|
+
const sourceRequestRef = useRef(0);
|
|
984
|
+
|
|
925
985
|
// Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
|
|
926
986
|
// is loaded — same real invariant as every other handler above.
|
|
927
987
|
async function handleReceipt(type: ReceiptType, action: "approve" | "decline") {
|
|
@@ -1040,6 +1100,108 @@ function MessageDetailContent({
|
|
|
1040
1100
|
</>
|
|
1041
1101
|
);
|
|
1042
1102
|
|
|
1103
|
+
// ---- The "More actions" menu's own rows (Print, View, Save as, Create rule); the rest are `messageActions`. ----
|
|
1104
|
+
|
|
1105
|
+
/** Prints this one message: its header lines and the body the pane shows (what was decrypted or verified here, else the server's sanitized
|
|
1106
|
+
* body), in a frame of its own - see `printMessage.ts`. */
|
|
1107
|
+
async function handlePrint() {
|
|
1108
|
+
try {
|
|
1109
|
+
const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
|
|
1110
|
+
const format = (list: Recipient[]) => list.map((r) => formatMailAddress(r)).join(", ");
|
|
1111
|
+
const printable = buildPrintDocument({
|
|
1112
|
+
subject: shownSubject,
|
|
1113
|
+
headers: [
|
|
1114
|
+
{ name: "From", value: senderLabel },
|
|
1115
|
+
{ name: "To", value: format(message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc")) },
|
|
1116
|
+
{ name: "Cc", value: format(message.recipients.filter((r) => r.type === "cc")) },
|
|
1117
|
+
{ name: "Date", value: new Date(message.receivedDate).toLocaleString() },
|
|
1118
|
+
],
|
|
1119
|
+
content,
|
|
1120
|
+
attachments,
|
|
1121
|
+
inlineParts: innerAttachments,
|
|
1122
|
+
});
|
|
1123
|
+
if (printable === undefined) {
|
|
1124
|
+
notify({ kind: "warning", title: "This message is too large to print here" });
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
printDocument(printable);
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
notifyApiError(err, "Couldn't print this message");
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** Opens the source (or just the headers) of the message as the server has it - for an encrypted message, its ciphertext. */
|
|
1134
|
+
function openSource(mode: SourceMode) {
|
|
1135
|
+
const request = ++sourceRequestRef.current;
|
|
1136
|
+
setSourceView({ mode, source: { status: "loading" } });
|
|
1137
|
+
getMessageRawContent(message.uid).then(
|
|
1138
|
+
(raw) => {
|
|
1139
|
+
if (sourceRequestRef.current === request) {
|
|
1140
|
+
setSourceView({ mode, source: { status: "ready", raw } });
|
|
1141
|
+
}
|
|
1142
|
+
},
|
|
1143
|
+
(err) => {
|
|
1144
|
+
if (sourceRequestRef.current === request) {
|
|
1145
|
+
setSourceView({ mode, source: { status: "error", message: err instanceof ApiRequestError ? err.message : "Could not load this message's source." } });
|
|
1146
|
+
}
|
|
1147
|
+
},
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function closeSource() {
|
|
1152
|
+
sourceRequestRef.current++;
|
|
1153
|
+
setSourceView(null);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
async function handleSaveAsEml() {
|
|
1157
|
+
try {
|
|
1158
|
+
saveAsEml(await getMessageRawContent(message.uid), protectedSubject ?? displaySubject(message.subject));
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
notifyApiError(err, "Couldn't save this message");
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** The rules page's "New mail filter", started from this message: its sender and (for a message the server can read) its subject. */
|
|
1165
|
+
function handleCreateRule() {
|
|
1166
|
+
const params = new URLSearchParams({ mailboxUid: message.mailboxUid, from: message.from.address });
|
|
1167
|
+
const subject = message.encrypted ? "" : withoutSubjectPrefixes(message.subject);
|
|
1168
|
+
if (subject) {
|
|
1169
|
+
params.set("subject", subject);
|
|
1170
|
+
}
|
|
1171
|
+
navigate(`/settings/filters/new?${params.toString()}`);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const menuActions: MessageMenuActions = {
|
|
1175
|
+
replyAll: () => void handleReplyOrForward("replyAll"),
|
|
1176
|
+
forward: () => void handleReplyOrForward("forward"),
|
|
1177
|
+
deleteMessage: () => void messageActions.deleteMessage(),
|
|
1178
|
+
toggleRead: () => void messageActions.toggleRead(),
|
|
1179
|
+
toggleFlag: () => void messageActions.toggleFlag(),
|
|
1180
|
+
reportJunk: () => void messageActions.reportJunk(),
|
|
1181
|
+
reportPhishing: () => void messageActions.reportPhishing(),
|
|
1182
|
+
notJunk: () => void messageActions.notJunk(),
|
|
1183
|
+
notJunkAndTrust: () => void messageActions.notJunkAndTrust(),
|
|
1184
|
+
blockSender: () => void messageActions.blockSender(),
|
|
1185
|
+
neverBlockSender: () => void messageActions.neverBlockSender(),
|
|
1186
|
+
print: () => void handlePrint(),
|
|
1187
|
+
viewSource: () => openSource("source"),
|
|
1188
|
+
viewDetails: () => openSource("headers"),
|
|
1189
|
+
saveAsEml: () => void handleSaveAsEml(),
|
|
1190
|
+
createRule: handleCreateRule,
|
|
1191
|
+
};
|
|
1192
|
+
// An encrypted message can be printed once it has been opened here: while it is still opening, or when it can't be, there is nothing to print.
|
|
1193
|
+
const printReason = !message.encrypted
|
|
1194
|
+
? undefined
|
|
1195
|
+
: security === null
|
|
1196
|
+
? "Still opening this message"
|
|
1197
|
+
: security.decryptError === undefined
|
|
1198
|
+
? undefined
|
|
1199
|
+
: lockedRef.current
|
|
1200
|
+
? "Unlock this message to print it"
|
|
1201
|
+
: "This message can't be read, so it can't be printed";
|
|
1202
|
+
// In Junk Email the card's button is the way back (Not junk), which only needs the right to change the message.
|
|
1203
|
+
const reportReason = !writable ? "View-only mailbox" : undefined;
|
|
1204
|
+
|
|
1043
1205
|
const card = (
|
|
1044
1206
|
<CardShell unread={cardUnread}>
|
|
1045
1207
|
{/* Header row: who it is from, when, and what can be done with it. Wraps: on a phone the actions drop under the sender. */}
|
|
@@ -1153,6 +1315,16 @@ function MessageDetailContent({
|
|
|
1153
1315
|
onClick={() => setMovePrompt(true)}
|
|
1154
1316
|
/>
|
|
1155
1317
|
)}
|
|
1318
|
+
{reportable && (
|
|
1319
|
+
<IconAction
|
|
1320
|
+
icon={inJunk ? <HiOutlineInboxArrowDown size={16} aria-hidden="true" /> : <HiOutlineNoSymbol size={16} aria-hidden="true" />}
|
|
1321
|
+
label={inJunk ? "Not junk" : "Report junk"}
|
|
1322
|
+
busy={messageActions.busy}
|
|
1323
|
+
disabled={messageActions.busy || reportReason !== undefined}
|
|
1324
|
+
reason={reportReason}
|
|
1325
|
+
onClick={() => void (inJunk ? messageActions.notJunk() : messageActions.reportJunk())}
|
|
1326
|
+
/>
|
|
1327
|
+
)}
|
|
1156
1328
|
{labels && labels.length > 0 && (
|
|
1157
1329
|
<LabelMenuButton
|
|
1158
1330
|
aria-label="Labels"
|
|
@@ -1170,6 +1342,24 @@ function MessageDetailContent({
|
|
|
1170
1342
|
clear={{ label: "Remove all labels" }}
|
|
1171
1343
|
/>
|
|
1172
1344
|
)}
|
|
1345
|
+
{archivable && (
|
|
1346
|
+
<MessageMoreMenu
|
|
1347
|
+
actions={menuActions}
|
|
1348
|
+
senderAddress={message.from.address}
|
|
1349
|
+
read={currentLabelsMessage.flags.read === true}
|
|
1350
|
+
flagged={currentLabelsMessage.flags.flagged === true}
|
|
1351
|
+
writable={writable}
|
|
1352
|
+
busy={messageActions.busy}
|
|
1353
|
+
inJunk={inJunk}
|
|
1354
|
+
inDeletedItems={inDeletedItems}
|
|
1355
|
+
sent={!!isSentItems}
|
|
1356
|
+
ownSender={readerOwnAddresses.includes(message.from.address.toLowerCase())}
|
|
1357
|
+
canTrustSender={writable}
|
|
1358
|
+
printReason={printReason}
|
|
1359
|
+
composing={preparingCompose}
|
|
1360
|
+
triggerClassName={ICON_BUTTON_CLASS}
|
|
1361
|
+
/>
|
|
1362
|
+
)}
|
|
1173
1363
|
</div>
|
|
1174
1364
|
</div>
|
|
1175
1365
|
|
|
@@ -1487,6 +1677,14 @@ function MessageDetailContent({
|
|
|
1487
1677
|
onMove={handleMove}
|
|
1488
1678
|
onFolderCreated={onFolderCreated}
|
|
1489
1679
|
/>
|
|
1680
|
+
{messageActions.dialog}
|
|
1681
|
+
<MessageSourceDialog
|
|
1682
|
+
open={sourceView !== null}
|
|
1683
|
+
onClose={closeSource}
|
|
1684
|
+
mode={sourceView?.mode ?? "source"}
|
|
1685
|
+
source={sourceView?.source ?? { status: "loading" }}
|
|
1686
|
+
encrypted={!!message.encrypted}
|
|
1687
|
+
/>
|
|
1490
1688
|
<Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
|
|
1491
1689
|
<p className="text-sm text-text-muted mb-4">
|
|
1492
1690
|
This asks every original recipient's mail system to delete their copy, but only if it's
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
7
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
8
|
+
|
|
9
|
+
export interface PermanentDeleteDialogProps {
|
|
10
|
+
open: boolean;
|
|
11
|
+
title: string;
|
|
12
|
+
/** The question, ending in what cannot be undone. */
|
|
13
|
+
message: string;
|
|
14
|
+
/** The destructive button's own words ("Delete permanently"). */
|
|
15
|
+
confirmLabel: string;
|
|
16
|
+
/** The delete is on the wire: the dialog stays up, and can be neither confirmed again nor dismissed. */
|
|
17
|
+
busy: boolean;
|
|
18
|
+
onConfirm: () => void;
|
|
19
|
+
onCancel: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The confirmation in front of every permanent delete. A `Modal`, so Escape and the Cancel button dismiss it and Tab stays inside it. Focus
|
|
24
|
+
* starts on the dialog itself, never on the destructive button, so pressing Enter (or the Delete key again) on a dialog the reader did not
|
|
25
|
+
* mean to open keeps their mail. It remembers nothing: every permanent delete asks.
|
|
26
|
+
*/
|
|
27
|
+
export default function PermanentDeleteDialog({ open, title, message, confirmLabel, busy, onConfirm, onCancel }: PermanentDeleteDialogProps) {
|
|
28
|
+
return (
|
|
29
|
+
<Modal open={open} onClose={() => !busy && onCancel()} title={title}>
|
|
30
|
+
<p className="text-sm mb-5">{message}</p>
|
|
31
|
+
<div className="flex gap-3 justify-end mt-5">
|
|
32
|
+
<Button type="button" variant="secondary" className="!w-auto" disabled={busy} onClick={onCancel}>
|
|
33
|
+
Cancel
|
|
34
|
+
</Button>
|
|
35
|
+
<Button
|
|
36
|
+
type="button"
|
|
37
|
+
className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
|
|
38
|
+
loading={busy}
|
|
39
|
+
disabled={busy}
|
|
40
|
+
onClick={onConfirm}
|
|
41
|
+
>
|
|
42
|
+
{confirmLabel}
|
|
43
|
+
</Button>
|
|
44
|
+
</div>
|
|
45
|
+
</Modal>
|
|
46
|
+
);
|
|
47
|
+
}
|