@rapidmx/web-client 0.18.0 → 0.20.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.
Files changed (131) hide show
  1. package/README.md +381 -381
  2. package/apps/admin/branding/index.tsx +39 -39
  3. package/apps/admin/data-requests/index.tsx +482 -482
  4. package/apps/admin/domains/[uid].tsx +166 -166
  5. package/apps/admin/escrow-scopes/[uid].tsx +350 -350
  6. package/apps/admin/index.tsx +129 -129
  7. package/apps/admin/mailboxes/[uid].tsx +271 -271
  8. package/apps/admin/mailboxes/new/index.tsx +30 -30
  9. package/apps/admin/plugins/index.tsx +15 -15
  10. package/apps/admin/retention-policy/index.tsx +39 -39
  11. package/apps/admin/signing-certificates/index.tsx +343 -343
  12. package/apps/escrow/audit-log/index.tsx +196 -196
  13. package/apps/escrow/matters/[uid].tsx +621 -621
  14. package/apps/shared/auth/adminAccess.ts +99 -99
  15. package/apps/shared/components/admin/diagnostics/HostCard.tsx +2 -0
  16. package/apps/shared/components/admin/diagnostics/PressureTiles.tsx +108 -0
  17. package/apps/shared/components/admin/diagnostics/PvcTable.tsx +20 -6
  18. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +21 -2
  19. package/apps/shared/components/admin/layout/AdminShell.tsx +384 -384
  20. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -238
  21. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
  22. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -194
  23. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  24. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  25. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  26. package/apps/shared/components/admin/settings/PluginsManager.tsx +2093 -1545
  27. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  28. package/apps/shared/components/admin/settings/pluginPreferences.ts +34 -0
  29. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  30. package/apps/shared/components/admin/setup/SetupWizard.tsx +446 -446
  31. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  32. package/apps/shared/components/calendar/EventModal.tsx +206 -206
  33. package/apps/shared/components/calendar/MonthView.tsx +185 -185
  34. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
  35. package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
  36. package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
  37. package/apps/shared/components/calendar/allDay.ts +124 -124
  38. package/apps/shared/components/contacts/ContactForm.tsx +383 -383
  39. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -103
  40. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  41. package/apps/shared/components/layout/AppShell.tsx +461 -461
  42. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  43. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
  44. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  45. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  46. package/apps/shared/components/layout/UserMenu.tsx +439 -439
  47. package/apps/shared/components/mail/ConversationList.tsx +332 -323
  48. package/apps/shared/components/mail/ConversationThreadPane.tsx +613 -613
  49. package/apps/shared/components/mail/MailSelectionBar.tsx +240 -240
  50. package/apps/shared/components/mail/MenuButton.tsx +404 -404
  51. package/apps/shared/components/mail/MessageDetailPane.tsx +1757 -1757
  52. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
  53. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  54. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1681
  55. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  56. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  57. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  58. package/apps/shared/components/mail/listPreferences.ts +3 -3
  59. package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -258
  60. package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -79
  61. package/apps/shared/components/mail/reading/messageExport.ts +59 -59
  62. package/apps/shared/components/mail/reading/printMessage.ts +99 -99
  63. package/apps/shared/components/mail/reading/useMessageActions.ts +443 -443
  64. package/apps/shared/components/mail/verificationSeals.ts +125 -125
  65. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  66. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  67. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  68. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  69. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  70. package/apps/shared/keyboard/dispatch.ts +124 -124
  71. package/apps/shared/keyboard/format.ts +89 -89
  72. package/apps/shared/keyboard/keymap.ts +114 -114
  73. package/apps/shared/keyboard/registry.ts +65 -65
  74. package/apps/shared/keyboard/targets.ts +79 -79
  75. package/apps/shared/mail/folderOfType.ts +49 -49
  76. package/apps/shared/mail/folderTree.ts +143 -143
  77. package/apps/shared/mail/listAllPages.ts +39 -39
  78. package/apps/shared/mail/newMailNotifications.ts +171 -171
  79. package/apps/shared/mail/outbox/sendJob.ts +445 -445
  80. package/apps/shared/mail/outbox/sendOutcomes.ts +155 -155
  81. package/apps/shared/mail/reportNotices.ts +66 -66
  82. package/apps/shared/mail/senderBlocking.ts +141 -141
  83. package/apps/shared/mail/useMailConnection.ts +205 -205
  84. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  85. package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -60
  86. package/apps/shared/mail/useMarkMessageRead.ts +47 -47
  87. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  88. package/apps/shared/notifications/store.ts +560 -560
  89. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
  90. package/apps/shared/search/localIndexBuilder.ts +481 -481
  91. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  92. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  93. package/apps/shared/signing/enrollmentView.ts +251 -251
  94. package/apps/shared/signing/useNow.ts +19 -19
  95. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
  96. package/apps/shared/styles/app.css +396 -396
  97. package/apps/www/calendar/index.tsx +581 -581
  98. package/apps/www/contacts/[uid].tsx +112 -112
  99. package/apps/www/index.tsx +3012 -2962
  100. package/apps/www/messages/[uid].tsx +139 -139
  101. package/apps/www/settings/auto-reply/index.tsx +136 -136
  102. package/apps/www/settings/blocked-senders/index.tsx +303 -303
  103. package/apps/www/settings/encryption/index.tsx +1290 -1290
  104. package/apps/www/settings/filters/[uid].tsx +179 -179
  105. package/apps/www/settings/filters/index.tsx +105 -105
  106. package/apps/www/settings/filters/new/index.tsx +165 -165
  107. package/apps/www/settings/labels/index.tsx +207 -207
  108. package/apps/www/settings/privacy/index.tsx +495 -495
  109. package/apps/www/settings/profile/index.tsx +251 -251
  110. package/apps/www/settings/read-receipts/index.tsx +150 -150
  111. package/apps/www/settings/sharing/index.tsx +259 -259
  112. package/apps/www/settings/signatures/[uid].tsx +175 -175
  113. package/apps/www/settings/signatures/index.tsx +91 -91
  114. package/apps/www/settings/signatures/new/index.tsx +138 -138
  115. package/apps/www/tasks/index.tsx +654 -654
  116. package/dist/apps/shared/components/admin/diagnostics/HostCard.js +2 -1
  117. package/dist/apps/shared/components/admin/diagnostics/PressureTiles.d.ts +22 -0
  118. package/dist/apps/shared/components/admin/diagnostics/PressureTiles.js +52 -0
  119. package/dist/apps/shared/components/admin/diagnostics/PvcTable.js +9 -4
  120. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +35 -2
  121. package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
  122. package/dist/apps/shared/components/admin/settings/PluginsManager.js +328 -48
  123. package/dist/apps/shared/components/admin/settings/pluginPreferences.d.ts +2 -0
  124. package/dist/apps/shared/components/admin/settings/pluginPreferences.js +35 -0
  125. package/dist/apps/shared/components/mail/ConversationList.d.ts +10 -3
  126. package/dist/apps/shared/components/mail/ConversationList.js +9 -5
  127. package/dist/apps/shared/components/mail/listPreferences.d.ts +1 -1
  128. package/dist/apps/shared/components/mail/reading/printMessage.js +11 -11
  129. package/dist/apps/shared/styles/app.css +396 -396
  130. package/dist/apps/www/index.js +51 -10
  131. package/package.json +2 -2
@@ -1,240 +1,240 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useState } from "react";
6
- import { Folder, Message } from "@rapidmx/react-shared/mail/mailApi.js";
7
- import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
8
- import LabelMenuButton from "./labelMenu.js";
9
- import MoveToFolderDialog, { MOVE_TARGET_TYPES } from "./MoveToFolderDialog.js";
10
- import { ariaKeyShortcuts, withHint } from "../../keyboard/format.js";
11
- import { SHORTCUTS, ShortcutDef } from "../../keyboard/keymap.js";
12
- import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
13
-
14
- export interface MailSelectionBarProps {
15
- selected: Message[];
16
- /** Every message currently listed - what "Select all" selects and what `allSelected` is measured against. */
17
- listed: Message[];
18
- /** What the bar *counts*, when the rows being ticked aren't messages themselves: the conversation list
19
- * ticks conversations, each standing for several messages, so "3 conversations selected" is what the
20
- * reader picked while `selected` stays the messages every action below acts on. Left out by the flat
21
- * message list, which counts `selected`/`listed` directly. */
22
- totals?: { selected: number; listed: number; noun: string };
23
- onSelectAll: () => void;
24
- onClearSelection: () => void;
25
- /** Leaves select mode entirely (and clears the selection). */
26
- onCancel: () => void;
27
- folders: Folder[];
28
- currentFolderUid?: string;
29
- /** Every label this mailbox has, for the Apply label action. */
30
- labels: Label[];
31
- /** The mailbox a label created from Apply label belongs to. */
32
- mailboxUid: string;
33
- onLabelCreated: (label: Label) => void;
34
- /** A folder created from the Move to prompt, for the caller's own folder list and the sidebar. */
35
- onFolderCreated: (folder: Folder) => void;
36
- /** Sets the selection's labels: every message ends up with `labelUids`, plus whichever of
37
- * `keepPartial` it already had (those rows were left partially applied, so each message keeps what
38
- * it has). */
39
- onApplyLabels: (labelUids: string[], keepPartial: string[]) => void;
40
- onSetRead: (read: boolean) => void;
41
- onSetFlagged: (flagged: boolean) => void;
42
- onArchive: () => void;
43
- /** Moves the selection into `folderUid`, resolving once the bulk update has settled. It resolves
44
- * whether or not that update was rejected: a bulk update is applied element by element, so a failure
45
- * is explained by a pop-up (see `notifyApiError()`), raised by the page along with the reload it triggers, rather than in the
46
- * prompt, which would be claiming the move simply didn't happen. */
47
- onMoveTo: (folderUid: string) => Promise<void>;
48
- onReportJunk: () => void;
49
- /** Delete: moves the selection to Deleted Items - or, when `deletesPermanently`, permanently deletes it (the page asks first). */
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;
55
- /** A bulk action is in flight - every action is held until it settles, since the next one would send
56
- * `version`s the first has already superseded. */
57
- busy: boolean;
58
- /** The keyboard acts on this selection (Delete, Ctrl+Q, Ctrl+U, Insert - registered by the page): Mark read, Mark unread, Flag and
59
- * Delete name their shortcut in the tooltip and `aria-keyshortcuts`. */
60
- shortcuts?: boolean;
61
- /** Why Move to is unavailable, when the selection is spread over mailboxes and a folder can only belong to one (search results over several
62
- * mailboxes). Shown as the button's tooltip. */
63
- moveDisabledReason?: string;
64
- /** The same for Apply label: a label belongs to one mailbox, and `labels` are one mailbox's. */
65
- labelsDisabledReason?: string;
66
- }
67
-
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`;
70
- }
71
-
72
- /**
73
- * The header that replaces the list's own toolbar while select mode is on: how many rows are selected,
74
- * select-all/clear, and the bulk actions Outlook offers for a multi-selection - mark read/unread, flag,
75
- * archive, move, report junk and delete.
76
- *
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
80
- * of actions doesn't shift around between folders.
81
- */
82
- export default function MailSelectionBar({
83
- selected,
84
- listed,
85
- totals,
86
- onSelectAll,
87
- onClearSelection,
88
- onCancel,
89
- folders,
90
- currentFolderUid,
91
- labels,
92
- mailboxUid,
93
- onLabelCreated,
94
- onFolderCreated,
95
- onApplyLabels,
96
- onSetRead,
97
- onSetFlagged,
98
- onArchive,
99
- onMoveTo,
100
- onReportJunk,
101
- onDelete,
102
- deletesPermanently = false,
103
- busy,
104
- shortcuts,
105
- moveDisabledReason,
106
- labelsDisabledReason,
107
- }: MailSelectionBarProps) {
108
- const env = useKeyEnvironment();
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) } : {};
112
- const selectedCount = totals?.selected ?? selected.length;
113
- const listedCount = totals?.listed ?? listed.length;
114
- // Counted on the rows that were ticked, but *emptied* on the messages: a ticked conversation whose
115
- // messages are still being fetched has nothing for an action to act on yet.
116
- const none = selectedCount === 0 || selected.length === 0;
117
- const allSelected = listedCount > 0 && selectedCount === listedCount;
118
- const currentType = folders.find((folder) => folder.uid === currentFolderUid)?.type;
119
- const moveTargets = folders.filter((folder) => MOVE_TARGET_TYPES.has(folder.type) && folder.uid !== currentFolderUid);
120
- // The same prompt the reading pane's own Move to opens, so one list of destinations and one way to
121
- // create a folder serve both.
122
- const [movePrompt, setMovePrompt] = useState(false);
123
-
124
- // None of the three needs its folder to exist first - the caller creates or lazily provisions it (see
125
- // `resolveFolderOfType()` and `bulkArchive()` in `apps/www/index.tsx`) - so the only thing that disables
126
- // them is already being in the folder they would move to.
127
- const archiveReason = currentType === "archive" ? "These messages are already in Archive" : undefined;
128
- const junkReason = currentType === "junk" ? "These messages are already in Junk" : undefined;
129
- const deleteLabel = deletesPermanently ? "Delete permanently" : "Delete";
130
-
131
- // A label every selected message already carries starts ticked; one only some of them carry starts
132
- // partially applied, and is left exactly as it is unless the reader touches that row.
133
- const appliedToAll = labels.filter((label) => !none && selected.every((m) => m.labelUids?.includes(label.uid)));
134
- const appliedToSome = labels.filter(
135
- (label) => selected.some((m) => m.labelUids?.includes(label.uid)) && !appliedToAll.includes(label),
136
- );
137
-
138
-
139
- return (
140
- <div className="border-b border-border">
141
- <MoveToFolderDialog
142
- open={movePrompt}
143
- onClose={() => setMovePrompt(false)}
144
- mailboxUid={mailboxUid}
145
- folders={folders}
146
- currentFolderUid={currentFolderUid}
147
- count={selected.length}
148
- onMove={onMoveTo}
149
- onFolderCreated={onFolderCreated}
150
- />
151
- <div className="flex items-center gap-2 px-3 py-1.5 bg-surface-alt">
152
- <span aria-live="polite" className="text-sm font-semibold text-text">
153
- {selectedCount}
154
- {totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : ""} selected
155
- </span>
156
- <button type="button" onClick={onSelectAll} disabled={allSelected || listedCount === 0} className={actionClassName()}>
157
- Select all
158
- </button>
159
- <button type="button" onClick={onClearSelection} disabled={selectedCount === 0} className={actionClassName()}>
160
- Clear
161
- </button>
162
- <button type="button" onClick={onCancel} className={`${actionClassName()} ml-auto`}>
163
- Cancel
164
- </button>
165
- </div>
166
- <div className="flex flex-wrap items-center gap-0.5 px-1.5 py-1">
167
- <button type="button" onClick={() => onSetRead(true)} disabled={none || busy} className={actionClassName()} {...hint("Mark read", SHORTCUTS.mail.markRead)}>
168
- Mark read
169
- </button>
170
- <button type="button" onClick={() => onSetRead(false)} disabled={none || busy} className={actionClassName()} {...hint("Mark unread", SHORTCUTS.mail.markUnread)}>
171
- Mark unread
172
- </button>
173
- <button type="button" onClick={() => onSetFlagged(true)} disabled={none || busy} className={actionClassName()} {...hint("Flag", SHORTCUTS.mail.flag)}>
174
- Flag
175
- </button>
176
- <button type="button" onClick={() => onSetFlagged(false)} disabled={none || busy} className={actionClassName()}>
177
- Unflag
178
- </button>
179
- <button
180
- type="button"
181
- onClick={onArchive}
182
- disabled={none || busy || !!archiveReason}
183
- title={archiveReason}
184
- className={actionClassName()}
185
- >
186
- Archive
187
- </button>
188
- <LabelMenuButton
189
- aria-label="Apply label"
190
- label="Apply label"
191
- className="text-sm"
192
- labels={labels}
193
- mailboxUid={mailboxUid}
194
- onLabelCreated={onLabelCreated}
195
- applied={appliedToAll.map((label) => label.uid)}
196
- partial={appliedToSome.map((label) => label.uid)}
197
- onCommit={onApplyLabels}
198
- busy={busy}
199
- disabled={none || busy || !!labelsDisabledReason}
200
- title={labelsDisabledReason}
201
- note={
202
- selected.length === 1
203
- ? "Ticked labels are applied, unticked ones removed."
204
- : "Ticked labels are applied to every selected message and unticked ones removed from all of them; a dash means only some have that label, and leaving it alone keeps it that way."
205
- }
206
- emptyNote="This mailbox has no labels yet."
207
- commit={{ label: "Apply" }}
208
- clear={{ label: "Remove all labels" }}
209
- />
210
- <button
211
- type="button"
212
- onClick={() => setMovePrompt(true)}
213
- disabled={none || busy || !!moveDisabledReason}
214
- title={moveDisabledReason}
215
- className={actionClassName()}
216
- >
217
- Move to
218
- </button>
219
- <button
220
- type="button"
221
- onClick={onReportJunk}
222
- disabled={none || busy || !!junkReason}
223
- title={junkReason}
224
- className={actionClassName()}
225
- >
226
- Report junk
227
- </button>
228
- <button
229
- type="button"
230
- onClick={onDelete}
231
- disabled={none || busy}
232
- className={actionClassName(deletesPermanently)}
233
- {...hint(deleteLabel, SHORTCUTS.mail.delete)}
234
- >
235
- {deleteLabel}
236
- </button>
237
- </div>
238
- </div>
239
- );
240
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useState } from "react";
6
+ import { Folder, Message } from "@rapidmx/react-shared/mail/mailApi.js";
7
+ import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
8
+ import LabelMenuButton from "./labelMenu.js";
9
+ import MoveToFolderDialog, { MOVE_TARGET_TYPES } from "./MoveToFolderDialog.js";
10
+ import { ariaKeyShortcuts, withHint } from "../../keyboard/format.js";
11
+ import { SHORTCUTS, ShortcutDef } from "../../keyboard/keymap.js";
12
+ import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
13
+
14
+ export interface MailSelectionBarProps {
15
+ selected: Message[];
16
+ /** Every message currently listed - what "Select all" selects and what `allSelected` is measured against. */
17
+ listed: Message[];
18
+ /** What the bar *counts*, when the rows being ticked aren't messages themselves: the conversation list
19
+ * ticks conversations, each standing for several messages, so "3 conversations selected" is what the
20
+ * reader picked while `selected` stays the messages every action below acts on. Left out by the flat
21
+ * message list, which counts `selected`/`listed` directly. */
22
+ totals?: { selected: number; listed: number; noun: string };
23
+ onSelectAll: () => void;
24
+ onClearSelection: () => void;
25
+ /** Leaves select mode entirely (and clears the selection). */
26
+ onCancel: () => void;
27
+ folders: Folder[];
28
+ currentFolderUid?: string;
29
+ /** Every label this mailbox has, for the Apply label action. */
30
+ labels: Label[];
31
+ /** The mailbox a label created from Apply label belongs to. */
32
+ mailboxUid: string;
33
+ onLabelCreated: (label: Label) => void;
34
+ /** A folder created from the Move to prompt, for the caller's own folder list and the sidebar. */
35
+ onFolderCreated: (folder: Folder) => void;
36
+ /** Sets the selection's labels: every message ends up with `labelUids`, plus whichever of
37
+ * `keepPartial` it already had (those rows were left partially applied, so each message keeps what
38
+ * it has). */
39
+ onApplyLabels: (labelUids: string[], keepPartial: string[]) => void;
40
+ onSetRead: (read: boolean) => void;
41
+ onSetFlagged: (flagged: boolean) => void;
42
+ onArchive: () => void;
43
+ /** Moves the selection into `folderUid`, resolving once the bulk update has settled. It resolves
44
+ * whether or not that update was rejected: a bulk update is applied element by element, so a failure
45
+ * is explained by a pop-up (see `notifyApiError()`), raised by the page along with the reload it triggers, rather than in the
46
+ * prompt, which would be claiming the move simply didn't happen. */
47
+ onMoveTo: (folderUid: string) => Promise<void>;
48
+ onReportJunk: () => void;
49
+ /** Delete: moves the selection to Deleted Items - or, when `deletesPermanently`, permanently deletes it (the page asks first). */
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;
55
+ /** A bulk action is in flight - every action is held until it settles, since the next one would send
56
+ * `version`s the first has already superseded. */
57
+ busy: boolean;
58
+ /** The keyboard acts on this selection (Delete, Ctrl+Q, Ctrl+U, Insert - registered by the page): Mark read, Mark unread, Flag and
59
+ * Delete name their shortcut in the tooltip and `aria-keyshortcuts`. */
60
+ shortcuts?: boolean;
61
+ /** Why Move to is unavailable, when the selection is spread over mailboxes and a folder can only belong to one (search results over several
62
+ * mailboxes). Shown as the button's tooltip. */
63
+ moveDisabledReason?: string;
64
+ /** The same for Apply label: a label belongs to one mailbox, and `labels` are one mailbox's. */
65
+ labelsDisabledReason?: string;
66
+ }
67
+
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`;
70
+ }
71
+
72
+ /**
73
+ * The header that replaces the list's own toolbar while select mode is on: how many rows are selected,
74
+ * select-all/clear, and the bulk actions Outlook offers for a multi-selection - mark read/unread, flag,
75
+ * archive, move, report junk and delete.
76
+ *
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
80
+ * of actions doesn't shift around between folders.
81
+ */
82
+ export default function MailSelectionBar({
83
+ selected,
84
+ listed,
85
+ totals,
86
+ onSelectAll,
87
+ onClearSelection,
88
+ onCancel,
89
+ folders,
90
+ currentFolderUid,
91
+ labels,
92
+ mailboxUid,
93
+ onLabelCreated,
94
+ onFolderCreated,
95
+ onApplyLabels,
96
+ onSetRead,
97
+ onSetFlagged,
98
+ onArchive,
99
+ onMoveTo,
100
+ onReportJunk,
101
+ onDelete,
102
+ deletesPermanently = false,
103
+ busy,
104
+ shortcuts,
105
+ moveDisabledReason,
106
+ labelsDisabledReason,
107
+ }: MailSelectionBarProps) {
108
+ const env = useKeyEnvironment();
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) } : {};
112
+ const selectedCount = totals?.selected ?? selected.length;
113
+ const listedCount = totals?.listed ?? listed.length;
114
+ // Counted on the rows that were ticked, but *emptied* on the messages: a ticked conversation whose
115
+ // messages are still being fetched has nothing for an action to act on yet.
116
+ const none = selectedCount === 0 || selected.length === 0;
117
+ const allSelected = listedCount > 0 && selectedCount === listedCount;
118
+ const currentType = folders.find((folder) => folder.uid === currentFolderUid)?.type;
119
+ const moveTargets = folders.filter((folder) => MOVE_TARGET_TYPES.has(folder.type) && folder.uid !== currentFolderUid);
120
+ // The same prompt the reading pane's own Move to opens, so one list of destinations and one way to
121
+ // create a folder serve both.
122
+ const [movePrompt, setMovePrompt] = useState(false);
123
+
124
+ // None of the three needs its folder to exist first - the caller creates or lazily provisions it (see
125
+ // `resolveFolderOfType()` and `bulkArchive()` in `apps/www/index.tsx`) - so the only thing that disables
126
+ // them is already being in the folder they would move to.
127
+ const archiveReason = currentType === "archive" ? "These messages are already in Archive" : undefined;
128
+ const junkReason = currentType === "junk" ? "These messages are already in Junk" : undefined;
129
+ const deleteLabel = deletesPermanently ? "Delete permanently" : "Delete";
130
+
131
+ // A label every selected message already carries starts ticked; one only some of them carry starts
132
+ // partially applied, and is left exactly as it is unless the reader touches that row.
133
+ const appliedToAll = labels.filter((label) => !none && selected.every((m) => m.labelUids?.includes(label.uid)));
134
+ const appliedToSome = labels.filter(
135
+ (label) => selected.some((m) => m.labelUids?.includes(label.uid)) && !appliedToAll.includes(label),
136
+ );
137
+
138
+
139
+ return (
140
+ <div className="border-b border-border">
141
+ <MoveToFolderDialog
142
+ open={movePrompt}
143
+ onClose={() => setMovePrompt(false)}
144
+ mailboxUid={mailboxUid}
145
+ folders={folders}
146
+ currentFolderUid={currentFolderUid}
147
+ count={selected.length}
148
+ onMove={onMoveTo}
149
+ onFolderCreated={onFolderCreated}
150
+ />
151
+ <div className="flex items-center gap-2 px-3 py-1.5 bg-surface-alt">
152
+ <span aria-live="polite" className="text-sm font-semibold text-text">
153
+ {selectedCount}
154
+ {totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : ""} selected
155
+ </span>
156
+ <button type="button" onClick={onSelectAll} disabled={allSelected || listedCount === 0} className={actionClassName()}>
157
+ Select all
158
+ </button>
159
+ <button type="button" onClick={onClearSelection} disabled={selectedCount === 0} className={actionClassName()}>
160
+ Clear
161
+ </button>
162
+ <button type="button" onClick={onCancel} className={`${actionClassName()} ml-auto`}>
163
+ Cancel
164
+ </button>
165
+ </div>
166
+ <div className="flex flex-wrap items-center gap-0.5 px-1.5 py-1">
167
+ <button type="button" onClick={() => onSetRead(true)} disabled={none || busy} className={actionClassName()} {...hint("Mark read", SHORTCUTS.mail.markRead)}>
168
+ Mark read
169
+ </button>
170
+ <button type="button" onClick={() => onSetRead(false)} disabled={none || busy} className={actionClassName()} {...hint("Mark unread", SHORTCUTS.mail.markUnread)}>
171
+ Mark unread
172
+ </button>
173
+ <button type="button" onClick={() => onSetFlagged(true)} disabled={none || busy} className={actionClassName()} {...hint("Flag", SHORTCUTS.mail.flag)}>
174
+ Flag
175
+ </button>
176
+ <button type="button" onClick={() => onSetFlagged(false)} disabled={none || busy} className={actionClassName()}>
177
+ Unflag
178
+ </button>
179
+ <button
180
+ type="button"
181
+ onClick={onArchive}
182
+ disabled={none || busy || !!archiveReason}
183
+ title={archiveReason}
184
+ className={actionClassName()}
185
+ >
186
+ Archive
187
+ </button>
188
+ <LabelMenuButton
189
+ aria-label="Apply label"
190
+ label="Apply label"
191
+ className="text-sm"
192
+ labels={labels}
193
+ mailboxUid={mailboxUid}
194
+ onLabelCreated={onLabelCreated}
195
+ applied={appliedToAll.map((label) => label.uid)}
196
+ partial={appliedToSome.map((label) => label.uid)}
197
+ onCommit={onApplyLabels}
198
+ busy={busy}
199
+ disabled={none || busy || !!labelsDisabledReason}
200
+ title={labelsDisabledReason}
201
+ note={
202
+ selected.length === 1
203
+ ? "Ticked labels are applied, unticked ones removed."
204
+ : "Ticked labels are applied to every selected message and unticked ones removed from all of them; a dash means only some have that label, and leaving it alone keeps it that way."
205
+ }
206
+ emptyNote="This mailbox has no labels yet."
207
+ commit={{ label: "Apply" }}
208
+ clear={{ label: "Remove all labels" }}
209
+ />
210
+ <button
211
+ type="button"
212
+ onClick={() => setMovePrompt(true)}
213
+ disabled={none || busy || !!moveDisabledReason}
214
+ title={moveDisabledReason}
215
+ className={actionClassName()}
216
+ >
217
+ Move to
218
+ </button>
219
+ <button
220
+ type="button"
221
+ onClick={onReportJunk}
222
+ disabled={none || busy || !!junkReason}
223
+ title={junkReason}
224
+ className={actionClassName()}
225
+ >
226
+ Report junk
227
+ </button>
228
+ <button
229
+ type="button"
230
+ onClick={onDelete}
231
+ disabled={none || busy}
232
+ className={actionClassName(deletesPermanently)}
233
+ {...hint(deleteLabel, SHORTCUTS.mail.delete)}
234
+ >
235
+ {deleteLabel}
236
+ </button>
237
+ </div>
238
+ </div>
239
+ );
240
+ }