@rapidmx/web-client 0.6.0 → 0.8.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 (79) hide show
  1. package/apps/shared/components/admin/settings/BrandingForm.tsx +398 -385
  2. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
  3. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -152
  4. package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
  5. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
  6. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
  7. package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
  8. package/apps/shared/components/layout/AppShell.tsx +288 -288
  9. package/apps/shared/components/mail/ConversationList.tsx +225 -33
  10. package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
  11. package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
  12. package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
  13. package/apps/shared/components/mail/MenuButton.tsx +384 -0
  14. package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
  15. package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
  16. package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
  17. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
  18. package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
  19. package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
  20. package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
  21. package/apps/shared/components/mail/compose/recipients.ts +104 -0
  22. package/apps/shared/components/mail/labelMenu.tsx +278 -0
  23. package/apps/shared/components/mail/layout/MailShell.tsx +445 -420
  24. package/apps/shared/components/mail/listPreferences.ts +228 -0
  25. package/apps/shared/components/settings/layout/SettingsShell.tsx +0 -1
  26. package/apps/shared/styles/app.css +28 -5
  27. package/apps/www/index.tsx +1854 -1314
  28. package/apps/www/messages/[uid].tsx +5 -4
  29. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +7 -3
  30. package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
  31. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +4 -1
  32. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
  33. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +9 -2
  34. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +20 -4
  35. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +8 -1
  36. package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
  37. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +4 -1
  38. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
  39. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
  40. package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
  41. package/dist/apps/shared/components/mail/ConversationList.d.ts +42 -6
  42. package/dist/apps/shared/components/mail/ConversationList.js +84 -10
  43. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +52 -13
  44. package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
  45. package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
  46. package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
  47. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
  48. package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
  49. package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
  50. package/dist/apps/shared/components/mail/MenuButton.js +211 -0
  51. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +27 -19
  52. package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
  53. package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
  54. package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
  55. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +16 -1
  56. package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
  57. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
  58. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
  59. package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
  60. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +10 -1
  61. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
  62. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
  63. package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
  64. package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
  65. package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
  66. package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
  67. package/dist/apps/shared/components/mail/labelMenu.js +134 -0
  68. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +8 -0
  69. package/dist/apps/shared/components/mail/layout/MailShell.js +15 -3
  70. package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
  71. package/dist/apps/shared/components/mail/listPreferences.js +186 -0
  72. package/dist/apps/shared/components/settings/layout/SettingsShell.js +0 -1
  73. package/dist/apps/shared/styles/app.css +28 -5
  74. package/dist/apps/www/index.js +506 -112
  75. package/dist/apps/www/messages/[uid].js +2 -3
  76. package/package.json +2 -2
  77. package/apps/www/settings/focused-inbox/index.tsx +0 -150
  78. package/dist/apps/www/settings/focused-inbox/index.d.ts +0 -4
  79. package/dist/apps/www/settings/focused-inbox/index.js +0 -71
@@ -0,0 +1,265 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useState } from "react";
6
+ import { HiOutlineAdjustmentsHorizontal, HiOutlineBarsArrowDown, HiOutlineStop } from "react-icons/hi2";
7
+ import { MessageListFilter, MessageListSort, MessageSortOrder } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
9
+ import MenuButton, { MenuSectionSpec } from "./MenuButton.js";
10
+ import { NewLabelDialog, labelSections, useLabelDraft } from "./labelMenu.js";
11
+ import {
12
+ MAIL_LIST_CLASSIFICATION_FILTERS,
13
+ MAIL_LIST_FILTERS,
14
+ MAIL_LIST_SORTS,
15
+ SORT_ORDER_LABELS,
16
+ defaultSortOrder,
17
+ } from "./listPreferences.js";
18
+
19
+ export interface MailListToolbarProps {
20
+ sortBy: MessageListSort;
21
+ sortOrder: MessageSortOrder;
22
+ filter: MessageListFilter;
23
+ /** The labels the list is currently narrowed to - any of them, not all. */
24
+ labelUids: string[];
25
+ /** Every label this mailbox has, for the Filter menu's own Labels submenu. */
26
+ labels: Label[];
27
+ onLabelUidsChange: (labelUids: string[]) => void;
28
+ /** The mailbox a label created from the Filter menu belongs to. */
29
+ mailboxUid: string;
30
+ /** Handed a label just created from the Filter menu, for the caller to add to `labels`. */
31
+ onLabelCreated: (label: Label) => void;
32
+ showAsConversations: boolean;
33
+ onSortChange: (sortBy: MessageListSort, sortOrder: MessageSortOrder) => void;
34
+ onFilterChange: (filter: MessageListFilter) => void;
35
+ onShowAsConversationsChange: (showAsConversations: boolean) => void;
36
+ selectMode: boolean;
37
+ onSelectModeChange: (selectMode: boolean) => void;
38
+ /** Offers the Focused/Other half of the filter vocabulary - an Inbox-only concept. */
39
+ offerClassificationFilters: boolean;
40
+ /** Search results are ranked by relevance across folders rather than listed from one, so the named
41
+ * filters don't apply to them. */
42
+ filterDisabled?: boolean;
43
+ filterDisabledReason?: string;
44
+ /** Greys out the sort keys and order while something else decides the order entirely: a search (ranked
45
+ * by relevance) or an aggregate view (one page merged from each mailbox). The Sort button itself stays
46
+ * open, since "Show as conversations" lives in it. */
47
+ sortKeysDisabled?: boolean;
48
+ /** Says why, under the sort keys. */
49
+ sortKeysNote?: string;
50
+ /** Individual sort keys the current arrangement can't order by, each with the reason shown beside it -
51
+ * the conversation list's own case, where the rows are thread summaries carrying no value for some of
52
+ * these (see `CONVERSATION_SORT_UNAVAILABLE`). The rest stay pickable. */
53
+ unavailableSortKeys?: Partial<Record<MessageListSort, string>>;
54
+ /** Select mode acts on the messages of one mailbox - so it is offered over both the message list and
55
+ * the conversation list (where a ticked row means every message of that conversation in this folder),
56
+ * but not over an aggregate view, whose rows come from several mailboxes whose folders a single Move
57
+ * couldn't name, nor over a list that is still loading or has no rows to tick. */
58
+ selectDisabled?: boolean;
59
+ selectDisabledReason?: string;
60
+ }
61
+
62
+ /** The label for the filter currently in force, or `undefined` for "All" (which isn't worth naming on the
63
+ * button). */
64
+ function filterLabel(filter: MessageListFilter): string | undefined {
65
+ return [...MAIL_LIST_FILTERS, ...MAIL_LIST_CLASSIFICATION_FILTERS].find((entry) => entry.value === filter && entry.value !== "all")
66
+ ?.label;
67
+ }
68
+
69
+ function sortLabel(sortBy: MessageListSort): string {
70
+ // Every `MessageListSort` has an entry, so this never falls through.
71
+ return MAIL_LIST_SORTS.find((entry) => entry.value === sortBy)!.label;
72
+ }
73
+
74
+ /**
75
+ * The mail list's Outlook-style toolbar: a Filter menu, a Sort menu (which also carries "Show as
76
+ * conversations", Outlook's own home for it - it arranges the list rather than acting on a message, and
77
+ * keeping it there leaves the toolbar three controls wide, which is what fits a 400px phone) and a Select
78
+ * toggle that turns the list into a multi-select with bulk actions.
79
+ *
80
+ * Both menus drive server-side parameters (`listMessages()`'s `sortBy`/`sortOrder`/`filter`), so they sort
81
+ * and filter the whole folder rather than the page that happens to be loaded.
82
+ */
83
+ export default function MailListToolbar({
84
+ sortBy,
85
+ sortOrder,
86
+ filter,
87
+ labelUids,
88
+ labels,
89
+ onLabelUidsChange,
90
+ mailboxUid,
91
+ onLabelCreated,
92
+ showAsConversations,
93
+ onSortChange,
94
+ onFilterChange,
95
+ onShowAsConversationsChange,
96
+ selectMode,
97
+ onSelectModeChange,
98
+ offerClassificationFilters,
99
+ filterDisabled,
100
+ filterDisabledReason,
101
+ sortKeysDisabled,
102
+ sortKeysNote,
103
+ unavailableSortKeys,
104
+ selectDisabled,
105
+ selectDisabledReason,
106
+ }: MailListToolbarProps) {
107
+ const activeFilter = filterLabel(filter);
108
+ const orderLabels = SORT_ORDER_LABELS[sortBy];
109
+ // The label picks are drafted while the Filter menu is open and applied in one go, so narrowing to
110
+ // three labels is one refetch rather than three.
111
+ const [filterMenuOpen, setFilterMenuOpen] = useState(false);
112
+ const [creatingLabel, setCreatingLabel] = useState(false);
113
+ const labelDraft = useLabelDraft(labelUids, [], filterMenuOpen);
114
+ const chosenLabelName = labels.find((l) => l.uid === labelUids[0])?.name;
115
+ // What the button says it is set to: the named filter, the labels, or both.
116
+ const activeParts = [
117
+ ...(activeFilter ? [activeFilter] : []),
118
+ ...(labelUids.length === 1 ? [chosenLabelName ?? "1 label"] : labelUids.length > 1 ? [`${labelUids.length} labels`] : []),
119
+ ];
120
+ const filterButtonLabel = activeParts.length > 0 ? `Filter: ${activeParts.join(", ")}` : "Filter";
121
+
122
+ const filterSections: MenuSectionSpec[] = [
123
+ {
124
+ key: "filter",
125
+ label: "Filter",
126
+ items: MAIL_LIST_FILTERS.map((entry) => ({
127
+ key: entry.value,
128
+ label: entry.label,
129
+ role: "menuitemradio" as const,
130
+ checked: filter === entry.value,
131
+ onSelect: () => onFilterChange(entry.value),
132
+ })),
133
+ },
134
+ ];
135
+ if (offerClassificationFilters) {
136
+ filterSections.push({
137
+ key: "classification",
138
+ label: "Focused Inbox",
139
+ note: "One filter at a time: picking Focused or Other replaces the filter above.",
140
+ items: MAIL_LIST_CLASSIFICATION_FILTERS.map((entry) => ({
141
+ key: entry.value,
142
+ label: entry.label,
143
+ role: "menuitemradio" as const,
144
+ checked: filter === entry.value,
145
+ onSelect: () => onFilterChange(entry.value),
146
+ })),
147
+ });
148
+ }
149
+
150
+ filterSections.push({
151
+ key: "labels",
152
+ items: [
153
+ {
154
+ key: "labels",
155
+ label: labelUids.length > 0 ? `Labels (${labelUids.length})` : "Labels",
156
+ description: "Show only messages with the labels you pick",
157
+ // A submenu rather than another group: a mailbox can have far more labels than the rest of
158
+ // this menu has rows, and they are picked several at a time rather than one instead of another.
159
+ submenu: labelSections({
160
+ labels,
161
+ state: labelDraft,
162
+ note: "Shows messages with any of the ticked labels.",
163
+ emptyNote: "This mailbox has no labels yet.",
164
+ commit: { label: "Apply labels", onSelect: () => onLabelUidsChange(labelDraft.draft), disabled: !labelDraft.dirty },
165
+ onCreate: () => setCreatingLabel(true),
166
+ // Clearing the label filter is worth doing in one step, so this applies straight away.
167
+ clear: { label: "Clear labels", keepOpen: false, disabled: labelUids.length === 0, onSelect: () => onLabelUidsChange([]) },
168
+ }),
169
+ },
170
+ ],
171
+ });
172
+
173
+ const sortSections: MenuSectionSpec[] = [
174
+ {
175
+ key: "sortBy",
176
+ label: "Sort by",
177
+ note: sortKeysNote,
178
+ items: MAIL_LIST_SORTS.map((entry) => ({
179
+ key: entry.value,
180
+ label: entry.label,
181
+ // The reason a key is unavailable is shown on the row itself rather than folded into the
182
+ // group's note, so it sits beside the one key it explains.
183
+ description: unavailableSortKeys?.[entry.value],
184
+ role: "menuitemradio" as const,
185
+ checked: sortBy === entry.value,
186
+ disabled: sortKeysDisabled || entry.value in (unavailableSortKeys ?? {}),
187
+ // Switching field resets the direction to the one that reads naturally for it, rather than
188
+ // carrying Date's newest-first over to Subject as a surprising Z-A.
189
+ onSelect: () => onSortChange(entry.value, defaultSortOrder(entry.value)),
190
+ })),
191
+ },
192
+ {
193
+ key: "sortOrder",
194
+ label: "Order",
195
+ items: (["desc", "asc"] as const).map((order) => ({
196
+ key: order,
197
+ label: orderLabels[order],
198
+ role: "menuitemradio" as const,
199
+ checked: sortOrder === order,
200
+ disabled: sortKeysDisabled,
201
+ onSelect: () => onSortChange(sortBy, order),
202
+ })),
203
+ },
204
+ {
205
+ key: "arrange",
206
+ items: [
207
+ {
208
+ key: "conversations",
209
+ label: "Show as conversations",
210
+ description: "Group replies into one row",
211
+ role: "menuitemcheckbox" as const,
212
+ checked: showAsConversations,
213
+ onSelect: () => onShowAsConversationsChange(!showAsConversations),
214
+ },
215
+ ],
216
+ },
217
+ ];
218
+
219
+ return (
220
+ <div className="flex items-center gap-0.5 px-1.5 py-1 border-b border-border">
221
+ <NewLabelDialog
222
+ open={creatingLabel}
223
+ onClose={() => setCreatingLabel(false)}
224
+ mailboxUid={mailboxUid}
225
+ onCreated={onLabelCreated}
226
+ />
227
+ <MenuButton
228
+ aria-label={filterButtonLabel}
229
+ label={filterButtonLabel}
230
+ icon={<HiOutlineAdjustmentsHorizontal size={16} aria-hidden="true" className="shrink-0 text-text-muted" />}
231
+ onOpenChange={setFilterMenuOpen}
232
+ sections={filterSections}
233
+ disabled={filterDisabled}
234
+ title={filterDisabled ? filterDisabledReason : undefined}
235
+ />
236
+ <MenuButton
237
+ aria-label={`Sort: ${sortLabel(sortBy)}`}
238
+ label={`Sort: ${sortLabel(sortBy)}`}
239
+ icon={<HiOutlineBarsArrowDown size={16} aria-hidden="true" className="shrink-0 text-text-muted" />}
240
+ sections={sortSections}
241
+ />
242
+ <button
243
+ type="button"
244
+ aria-pressed={selectMode}
245
+ disabled={selectDisabled}
246
+ // Icon-only, so the tooltip is the only thing naming it on screen - replaced by the reason
247
+ // while it is unavailable, which is the more useful thing to read at that moment.
248
+ title={selectDisabled ? selectDisabledReason : "Select"}
249
+ onClick={() => onSelectModeChange(!selectMode)}
250
+ aria-label="Select"
251
+ className={[
252
+ // Icon-only, like Outlook's own "select items" command: the glyph carries it and the
253
+ // name lives in `aria-label`/`title`. The pressed state is a filled, outlined box
254
+ // rather than only a colour change, so it stays legible next to a disabled one.
255
+ "ml-auto shrink-0 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent",
256
+ selectMode ? "bg-primary/10 text-primary-dark" : "text-text-muted",
257
+ ].join(" ")}
258
+ >
259
+ {/* `hi2`'s plain outlined rounded square - the closest thing it has to Outlook's own
260
+ "select items" glyph, and the only unfilled box in it. */}
261
+ <HiOutlineStop size={18} aria-hidden="true" className="shrink-0" />
262
+ </button>
263
+ </div>
264
+ );
265
+ }
@@ -0,0 +1,222 @@
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 Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
9
+ import LabelMenuButton from "./labelMenu.js";
10
+ import MoveToFolderDialog, { MOVE_TARGET_TYPES } from "./MoveToFolderDialog.js";
11
+
12
+ export interface MailSelectionBarProps {
13
+ selected: Message[];
14
+ /** Every message currently listed - what "Select all" selects and what `allSelected` is measured against. */
15
+ listed: Message[];
16
+ /** What the bar *counts*, when the rows being ticked aren't messages themselves: the conversation list
17
+ * ticks conversations, each standing for several messages, so "3 conversations selected" is what the
18
+ * reader picked while `selected` stays the messages every action below acts on. Left out by the flat
19
+ * message list, which counts `selected`/`listed` directly. */
20
+ totals?: { selected: number; listed: number; noun: string };
21
+ onSelectAll: () => void;
22
+ onClearSelection: () => void;
23
+ /** Leaves select mode entirely (and clears the selection). */
24
+ onCancel: () => void;
25
+ folders: Folder[];
26
+ currentFolderUid?: string;
27
+ /** Every label this mailbox has, for the Apply label action. */
28
+ labels: Label[];
29
+ /** The mailbox a label created from Apply label belongs to. */
30
+ mailboxUid: string;
31
+ onLabelCreated: (label: Label) => void;
32
+ /** A folder created from the Move to prompt, for the caller's own folder list and the sidebar. */
33
+ onFolderCreated: (folder: Folder) => void;
34
+ /** Sets the selection's labels: every message ends up with `labelUids`, plus whichever of
35
+ * `keepPartial` it already had (those rows were left partially applied, so each message keeps what
36
+ * it has). */
37
+ onApplyLabels: (labelUids: string[], keepPartial: string[]) => void;
38
+ onSetRead: (read: boolean) => void;
39
+ onSetFlagged: (flagged: boolean) => void;
40
+ onArchive: () => void;
41
+ /** Moves the selection into `folderUid`, resolving once the bulk update has settled. It resolves
42
+ * whether or not that update was rejected: a bulk update is applied element by element, so a failure
43
+ * is explained in this bar (along with the reload it triggers) rather than in the prompt, which would
44
+ * be claiming the move simply didn't happen. */
45
+ onMoveTo: (folderUid: string) => Promise<void>;
46
+ onReportJunk: () => void;
47
+ onDelete: () => void;
48
+ /** A bulk action is in flight - every action is held until it settles, since the next one would send
49
+ * `version`s the first has already superseded. */
50
+ busy: boolean;
51
+ error: string | null;
52
+ }
53
+
54
+ function actionClassName(): string {
55
+ return "px-2 py-1 rounded-md text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent";
56
+ }
57
+
58
+ /**
59
+ * The header that replaces the list's own toolbar while select mode is on: how many rows are selected,
60
+ * select-all/clear, and the bulk actions Outlook offers for a multi-selection - mark read/unread, flag,
61
+ * archive, move, report junk and delete.
62
+ *
63
+ * "Delete" means *move to Deleted Items*, never a permanent erase: there is no bulk permanent-delete API,
64
+ * and the collection-level DELETE truncates the folder rather than removing a selection. An action whose
65
+ * target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
66
+ * of actions doesn't shift around between folders.
67
+ */
68
+ export default function MailSelectionBar({
69
+ selected,
70
+ listed,
71
+ totals,
72
+ onSelectAll,
73
+ onClearSelection,
74
+ onCancel,
75
+ folders,
76
+ currentFolderUid,
77
+ labels,
78
+ mailboxUid,
79
+ onLabelCreated,
80
+ onFolderCreated,
81
+ onApplyLabels,
82
+ onSetRead,
83
+ onSetFlagged,
84
+ onArchive,
85
+ onMoveTo,
86
+ onReportJunk,
87
+ onDelete,
88
+ busy,
89
+ error,
90
+ }: MailSelectionBarProps) {
91
+ const selectedCount = totals?.selected ?? selected.length;
92
+ const listedCount = totals?.listed ?? listed.length;
93
+ // Counted on the rows that were ticked, but *emptied* on the messages: a ticked conversation whose
94
+ // messages are still being fetched has nothing for an action to act on yet.
95
+ const none = selectedCount === 0 || selected.length === 0;
96
+ const allSelected = listedCount > 0 && selectedCount === listedCount;
97
+ const currentType = folders.find((folder) => folder.uid === currentFolderUid)?.type;
98
+ const moveTargets = folders.filter((folder) => MOVE_TARGET_TYPES.has(folder.type) && folder.uid !== currentFolderUid);
99
+ // The same prompt the reading pane's own Move to opens, so one list of destinations and one way to
100
+ // create a folder serve both.
101
+ const [movePrompt, setMovePrompt] = useState(false);
102
+
103
+ // None of the three needs its folder to exist first - the caller creates or lazily provisions it (see
104
+ // `resolveFolderOfType()` and `bulkArchive()` in `apps/www/index.tsx`) - so the only thing that disables
105
+ // them is already being in the folder they would move to.
106
+ const archiveReason = currentType === "archive" ? "These messages are already in Archive" : undefined;
107
+ const junkReason = currentType === "junk" ? "These messages are already in Junk" : undefined;
108
+ const deleteReason = currentType === "deleted_items" ? "These messages are already in Deleted Items" : undefined;
109
+
110
+ // A label every selected message already carries starts ticked; one only some of them carry starts
111
+ // partially applied, and is left exactly as it is unless the reader touches that row.
112
+ const appliedToAll = labels.filter((label) => !none && selected.every((m) => m.labelUids?.includes(label.uid)));
113
+ const appliedToSome = labels.filter(
114
+ (label) => selected.some((m) => m.labelUids?.includes(label.uid)) && !appliedToAll.includes(label),
115
+ );
116
+
117
+
118
+ return (
119
+ <div className="border-b border-border">
120
+ <MoveToFolderDialog
121
+ open={movePrompt}
122
+ onClose={() => setMovePrompt(false)}
123
+ mailboxUid={mailboxUid}
124
+ folders={folders}
125
+ currentFolderUid={currentFolderUid}
126
+ count={selected.length}
127
+ onMove={onMoveTo}
128
+ onFolderCreated={onFolderCreated}
129
+ />
130
+ <div className="flex items-center gap-2 px-3 py-1.5 bg-surface-alt">
131
+ <span aria-live="polite" className="text-sm font-semibold text-text">
132
+ {selectedCount}
133
+ {totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : ""} selected
134
+ </span>
135
+ <button type="button" onClick={onSelectAll} disabled={allSelected || listedCount === 0} className={actionClassName()}>
136
+ Select all
137
+ </button>
138
+ <button type="button" onClick={onClearSelection} disabled={selectedCount === 0} className={actionClassName()}>
139
+ Clear
140
+ </button>
141
+ <button type="button" onClick={onCancel} className={`${actionClassName()} ml-auto`}>
142
+ Cancel
143
+ </button>
144
+ </div>
145
+ <div className="flex flex-wrap items-center gap-0.5 px-1.5 py-1">
146
+ <button type="button" onClick={() => onSetRead(true)} disabled={none || busy} className={actionClassName()}>
147
+ Mark read
148
+ </button>
149
+ <button type="button" onClick={() => onSetRead(false)} disabled={none || busy} className={actionClassName()}>
150
+ Mark unread
151
+ </button>
152
+ <button type="button" onClick={() => onSetFlagged(true)} disabled={none || busy} className={actionClassName()}>
153
+ Flag
154
+ </button>
155
+ <button type="button" onClick={() => onSetFlagged(false)} disabled={none || busy} className={actionClassName()}>
156
+ Unflag
157
+ </button>
158
+ <button
159
+ type="button"
160
+ onClick={onArchive}
161
+ disabled={none || busy || !!archiveReason}
162
+ title={archiveReason}
163
+ className={actionClassName()}
164
+ >
165
+ Archive
166
+ </button>
167
+ <LabelMenuButton
168
+ aria-label="Apply label"
169
+ label="Apply label"
170
+ className="text-sm"
171
+ labels={labels}
172
+ mailboxUid={mailboxUid}
173
+ onLabelCreated={onLabelCreated}
174
+ applied={appliedToAll.map((label) => label.uid)}
175
+ partial={appliedToSome.map((label) => label.uid)}
176
+ onCommit={onApplyLabels}
177
+ busy={busy}
178
+ disabled={none || busy}
179
+ note={
180
+ selected.length === 1
181
+ ? "Ticked labels are applied, unticked ones removed."
182
+ : "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."
183
+ }
184
+ emptyNote="This mailbox has no labels yet."
185
+ commit={{ label: "Apply" }}
186
+ clear={{ label: "Remove all labels" }}
187
+ />
188
+ <button
189
+ type="button"
190
+ onClick={() => setMovePrompt(true)}
191
+ disabled={none || busy}
192
+ className={actionClassName()}
193
+ >
194
+ Move to
195
+ </button>
196
+ <button
197
+ type="button"
198
+ onClick={onReportJunk}
199
+ disabled={none || busy || !!junkReason}
200
+ title={junkReason}
201
+ className={actionClassName()}
202
+ >
203
+ Report junk
204
+ </button>
205
+ <button
206
+ type="button"
207
+ onClick={onDelete}
208
+ disabled={none || busy || !!deleteReason}
209
+ title={deleteReason}
210
+ className={actionClassName()}
211
+ >
212
+ Delete
213
+ </button>
214
+ </div>
215
+ {error && (
216
+ <div className="px-3 pb-2">
217
+ <Alert>{error}</Alert>
218
+ </div>
219
+ )}
220
+ </div>
221
+ );
222
+ }