@rapidmx/web-client 0.8.0 → 0.10.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 (247) hide show
  1. package/README.md +192 -88
  2. package/apps/shared/auth/accountUrl.ts +9 -0
  3. package/apps/shared/auth/adminAccess.ts +99 -0
  4. package/apps/shared/components/admin/elevation.ts +61 -0
  5. package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
  6. package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
  7. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +87 -24
  8. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  9. package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
  10. package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
  11. package/apps/shared/components/layout/AppShell.tsx +411 -288
  12. package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
  13. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  14. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -126
  15. package/apps/shared/components/layout/UserMenu.tsx +303 -150
  16. package/apps/shared/components/mail/ConversationList.tsx +269 -253
  17. package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
  18. package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
  19. package/apps/shared/components/mail/MailAddress.tsx +88 -0
  20. package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1383
  22. package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
  23. package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
  24. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  25. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1700
  26. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  27. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  28. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
  29. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  30. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  31. package/apps/shared/components/mail/layout/MailShell.tsx +476 -445
  32. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
  34. package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
  35. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  36. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  37. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  38. package/apps/shared/keyboard/dispatch.ts +124 -0
  39. package/apps/shared/keyboard/format.ts +89 -0
  40. package/apps/shared/keyboard/keymap.ts +114 -0
  41. package/apps/shared/keyboard/match.ts +44 -0
  42. package/apps/shared/keyboard/parse.ts +136 -0
  43. package/apps/shared/keyboard/platform.ts +34 -0
  44. package/apps/shared/keyboard/registry.ts +65 -0
  45. package/apps/shared/keyboard/targets.ts +79 -0
  46. package/apps/shared/keyboard/useShortcut.ts +50 -0
  47. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  48. package/apps/shared/mail/folderCounts.ts +302 -0
  49. package/apps/shared/mail/listSnapshots.ts +87 -0
  50. package/apps/shared/mail/mergeFirstPage.ts +47 -0
  51. package/apps/shared/mail/messageReadState.ts +85 -0
  52. package/apps/shared/mail/newMailNotifications.ts +183 -0
  53. package/apps/shared/mail/useMailConnection.ts +150 -0
  54. package/apps/shared/mail/useMailLiveUpdates.ts +232 -0
  55. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  56. package/apps/shared/mail/useNewMailNotifications.ts +163 -0
  57. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  58. package/apps/shared/navigation/AppRouter.tsx +300 -0
  59. package/apps/shared/navigation/appHrefs.ts +23 -0
  60. package/apps/shared/navigation/frameContext.tsx +35 -0
  61. package/apps/shared/navigation/idle.ts +45 -0
  62. package/apps/shared/navigation/routerContext.tsx +83 -0
  63. package/apps/shared/navigation/routes.ts +72 -0
  64. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  65. package/apps/shared/styles/app.css +28 -10
  66. package/apps/www/_routedPage.tsx +24 -0
  67. package/apps/www/_routes.ts +35 -0
  68. package/apps/www/calendar/index.tsx +55 -19
  69. package/apps/www/contacts/[uid].tsx +112 -107
  70. package/apps/www/contacts/index.tsx +584 -567
  71. package/apps/www/index.tsx +2280 -1854
  72. package/apps/www/messages/[uid].tsx +106 -101
  73. package/apps/www/settings/auto-reply/index.tsx +4 -1
  74. package/apps/www/settings/encryption/index.tsx +1252 -1249
  75. package/apps/www/settings/filters/[uid].tsx +4 -1
  76. package/apps/www/settings/filters/index.tsx +102 -99
  77. package/apps/www/settings/filters/new/index.tsx +138 -133
  78. package/apps/www/settings/labels/index.tsx +204 -201
  79. package/apps/www/settings/privacy/index.tsx +4 -1
  80. package/apps/www/settings/read-receipts/index.tsx +4 -1
  81. package/apps/www/settings/sharing/index.tsx +277 -274
  82. package/apps/www/settings/signatures/[uid].tsx +170 -167
  83. package/apps/www/settings/signatures/index.tsx +88 -85
  84. package/apps/www/settings/signatures/new/index.tsx +134 -129
  85. package/apps/www/tasks/index.tsx +18 -2
  86. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  87. package/dist/apps/shared/auth/accountUrl.js +8 -0
  88. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  89. package/dist/apps/shared/auth/adminAccess.js +89 -0
  90. package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
  91. package/dist/apps/shared/components/admin/elevation.js +56 -0
  92. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
  93. package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
  94. package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
  95. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
  96. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  97. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  98. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  99. package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
  100. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  101. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
  102. package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
  103. package/dist/apps/shared/components/layout/AppShell.js +73 -14
  104. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
  105. package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
  106. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  107. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
  108. package/dist/apps/shared/components/layout/MailboxProvisioning.js +45 -8
  109. package/dist/apps/shared/components/layout/UserMenu.d.ts +28 -9
  110. package/dist/apps/shared/components/layout/UserMenu.js +87 -14
  111. package/dist/apps/shared/components/mail/ConversationList.js +9 -13
  112. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  113. package/dist/apps/shared/components/mail/ConversationThreadPane.js +53 -28
  114. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  115. package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
  116. package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
  117. package/dist/apps/shared/components/mail/MailAddress.js +39 -0
  118. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
  119. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
  120. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
  121. package/dist/apps/shared/components/mail/MessageDetailPane.js +87 -36
  122. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
  123. package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
  124. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
  125. package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
  126. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  127. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
  128. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +122 -27
  129. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  130. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  131. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  132. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  133. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
  134. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
  135. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  136. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  137. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  138. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  139. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +19 -4
  140. package/dist/apps/shared/components/mail/layout/MailShell.js +85 -85
  141. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  142. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  143. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  144. package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
  145. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  146. package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
  147. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  148. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  149. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  150. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  151. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  152. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  153. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  154. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  155. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  156. package/dist/apps/shared/keyboard/format.js +74 -0
  157. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  158. package/dist/apps/shared/keyboard/keymap.js +84 -0
  159. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  160. package/dist/apps/shared/keyboard/match.js +31 -0
  161. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  162. package/dist/apps/shared/keyboard/parse.js +107 -0
  163. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  164. package/dist/apps/shared/keyboard/platform.js +22 -0
  165. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  166. package/dist/apps/shared/keyboard/registry.js +33 -0
  167. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  168. package/dist/apps/shared/keyboard/targets.js +67 -0
  169. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  170. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  171. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  172. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  173. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  174. package/dist/apps/shared/mail/folderCounts.js +212 -0
  175. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  176. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  177. package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
  178. package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
  179. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  180. package/dist/apps/shared/mail/messageReadState.js +63 -0
  181. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  182. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  183. package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
  184. package/dist/apps/shared/mail/useMailConnection.js +96 -0
  185. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +55 -0
  186. package/dist/apps/shared/mail/useMailLiveUpdates.js +182 -0
  187. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  188. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  189. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
  190. package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
  191. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  192. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  193. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  194. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  195. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  196. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  197. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  198. package/dist/apps/shared/navigation/frameContext.js +24 -0
  199. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  200. package/dist/apps/shared/navigation/idle.js +43 -0
  201. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  202. package/dist/apps/shared/navigation/routerContext.js +56 -0
  203. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  204. package/dist/apps/shared/navigation/routes.js +37 -0
  205. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  206. package/dist/apps/shared/styles/app.css +28 -10
  207. package/dist/apps/www/_routedPage.d.ts +12 -0
  208. package/dist/apps/www/_routedPage.js +19 -0
  209. package/dist/apps/www/_routes.d.ts +11 -0
  210. package/dist/apps/www/_routes.js +29 -0
  211. package/dist/apps/www/calendar/index.d.ts +2 -2
  212. package/dist/apps/www/calendar/index.js +39 -8
  213. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  214. package/dist/apps/www/contacts/[uid].js +6 -2
  215. package/dist/apps/www/contacts/index.d.ts +2 -2
  216. package/dist/apps/www/contacts/index.js +17 -4
  217. package/dist/apps/www/index.d.ts +2 -2
  218. package/dist/apps/www/index.js +361 -34
  219. package/dist/apps/www/messages/[uid].d.ts +3 -7
  220. package/dist/apps/www/messages/[uid].js +7 -4
  221. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  222. package/dist/apps/www/settings/auto-reply/index.js +3 -1
  223. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  224. package/dist/apps/www/settings/encryption/index.js +3 -1
  225. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  226. package/dist/apps/www/settings/filters/[uid].js +3 -1
  227. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  228. package/dist/apps/www/settings/filters/index.js +3 -1
  229. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  230. package/dist/apps/www/settings/filters/new/index.js +6 -2
  231. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  232. package/dist/apps/www/settings/labels/index.js +3 -1
  233. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  234. package/dist/apps/www/settings/privacy/index.js +3 -1
  235. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  236. package/dist/apps/www/settings/read-receipts/index.js +3 -1
  237. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  238. package/dist/apps/www/settings/sharing/index.js +3 -1
  239. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  240. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  241. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  242. package/dist/apps/www/settings/signatures/index.js +3 -1
  243. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  244. package/dist/apps/www/settings/signatures/new/index.js +6 -2
  245. package/dist/apps/www/tasks/index.d.ts +2 -2
  246. package/dist/apps/www/tasks/index.js +15 -3
  247. package/package.json +2 -2
@@ -1,222 +1,235 @@
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
- }
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
+ import { ariaKeyShortcuts, withHint } from "../../keyboard/format.js";
12
+ import { SHORTCUTS, ShortcutDef } from "../../keyboard/keymap.js";
13
+ import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
14
+
15
+ export interface MailSelectionBarProps {
16
+ selected: Message[];
17
+ /** Every message currently listed - what "Select all" selects and what `allSelected` is measured against. */
18
+ listed: Message[];
19
+ /** What the bar *counts*, when the rows being ticked aren't messages themselves: the conversation list
20
+ * ticks conversations, each standing for several messages, so "3 conversations selected" is what the
21
+ * reader picked while `selected` stays the messages every action below acts on. Left out by the flat
22
+ * message list, which counts `selected`/`listed` directly. */
23
+ totals?: { selected: number; listed: number; noun: string };
24
+ onSelectAll: () => void;
25
+ onClearSelection: () => void;
26
+ /** Leaves select mode entirely (and clears the selection). */
27
+ onCancel: () => void;
28
+ folders: Folder[];
29
+ currentFolderUid?: string;
30
+ /** Every label this mailbox has, for the Apply label action. */
31
+ labels: Label[];
32
+ /** The mailbox a label created from Apply label belongs to. */
33
+ mailboxUid: string;
34
+ onLabelCreated: (label: Label) => void;
35
+ /** A folder created from the Move to prompt, for the caller's own folder list and the sidebar. */
36
+ onFolderCreated: (folder: Folder) => void;
37
+ /** Sets the selection's labels: every message ends up with `labelUids`, plus whichever of
38
+ * `keepPartial` it already had (those rows were left partially applied, so each message keeps what
39
+ * it has). */
40
+ onApplyLabels: (labelUids: string[], keepPartial: string[]) => void;
41
+ onSetRead: (read: boolean) => void;
42
+ onSetFlagged: (flagged: boolean) => void;
43
+ onArchive: () => void;
44
+ /** Moves the selection into `folderUid`, resolving once the bulk update has settled. It resolves
45
+ * whether or not that update was rejected: a bulk update is applied element by element, so a failure
46
+ * is explained in this bar (along with the reload it triggers) rather than in the prompt, which would
47
+ * be claiming the move simply didn't happen. */
48
+ onMoveTo: (folderUid: string) => Promise<void>;
49
+ onReportJunk: () => void;
50
+ onDelete: () => void;
51
+ /** A bulk action is in flight - every action is held until it settles, since the next one would send
52
+ * `version`s the first has already superseded. */
53
+ busy: boolean;
54
+ error: string | null;
55
+ /** The keyboard acts on this selection (Delete, Ctrl+Q, Ctrl+U, Insert - registered by the page): Mark read, Mark unread, Flag and
56
+ * Delete name their shortcut in the tooltip and `aria-keyshortcuts`. */
57
+ shortcuts?: boolean;
58
+ }
59
+
60
+ function actionClassName(): string {
61
+ return "px-2 py-1 rounded-md text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent";
62
+ }
63
+
64
+ /**
65
+ * The header that replaces the list's own toolbar while select mode is on: how many rows are selected,
66
+ * select-all/clear, and the bulk actions Outlook offers for a multi-selection - mark read/unread, flag,
67
+ * archive, move, report junk and delete.
68
+ *
69
+ * "Delete" means *move to Deleted Items*, never a permanent erase: there is no bulk permanent-delete API,
70
+ * and the collection-level DELETE truncates the folder rather than removing a selection. An action whose
71
+ * target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
72
+ * of actions doesn't shift around between folders.
73
+ */
74
+ export default function MailSelectionBar({
75
+ selected,
76
+ listed,
77
+ totals,
78
+ onSelectAll,
79
+ onClearSelection,
80
+ onCancel,
81
+ folders,
82
+ currentFolderUid,
83
+ labels,
84
+ mailboxUid,
85
+ onLabelCreated,
86
+ onFolderCreated,
87
+ onApplyLabels,
88
+ onSetRead,
89
+ onSetFlagged,
90
+ onArchive,
91
+ onMoveTo,
92
+ onReportJunk,
93
+ onDelete,
94
+ busy,
95
+ error,
96
+ shortcuts,
97
+ }: MailSelectionBarProps) {
98
+ const env = useKeyEnvironment();
99
+ /** `title` and `aria-keyshortcuts` for an action the keyboard also does; `reason` (why it is disabled) wins the tooltip. */
100
+ const hint = (label: string, shortcut: ShortcutDef, reason?: string) =>
101
+ shortcuts
102
+ ? { title: reason ?? withHint(label, shortcut, env), "aria-keyshortcuts": ariaKeyShortcuts(shortcut, env) }
103
+ : { title: reason };
104
+ const selectedCount = totals?.selected ?? selected.length;
105
+ const listedCount = totals?.listed ?? listed.length;
106
+ // Counted on the rows that were ticked, but *emptied* on the messages: a ticked conversation whose
107
+ // messages are still being fetched has nothing for an action to act on yet.
108
+ const none = selectedCount === 0 || selected.length === 0;
109
+ const allSelected = listedCount > 0 && selectedCount === listedCount;
110
+ const currentType = folders.find((folder) => folder.uid === currentFolderUid)?.type;
111
+ const moveTargets = folders.filter((folder) => MOVE_TARGET_TYPES.has(folder.type) && folder.uid !== currentFolderUid);
112
+ // The same prompt the reading pane's own Move to opens, so one list of destinations and one way to
113
+ // create a folder serve both.
114
+ const [movePrompt, setMovePrompt] = useState(false);
115
+
116
+ // None of the three needs its folder to exist first - the caller creates or lazily provisions it (see
117
+ // `resolveFolderOfType()` and `bulkArchive()` in `apps/www/index.tsx`) - so the only thing that disables
118
+ // them is already being in the folder they would move to.
119
+ const archiveReason = currentType === "archive" ? "These messages are already in Archive" : undefined;
120
+ const junkReason = currentType === "junk" ? "These messages are already in Junk" : undefined;
121
+ const deleteReason = currentType === "deleted_items" ? "These messages are already in Deleted Items" : undefined;
122
+
123
+ // A label every selected message already carries starts ticked; one only some of them carry starts
124
+ // partially applied, and is left exactly as it is unless the reader touches that row.
125
+ const appliedToAll = labels.filter((label) => !none && selected.every((m) => m.labelUids?.includes(label.uid)));
126
+ const appliedToSome = labels.filter(
127
+ (label) => selected.some((m) => m.labelUids?.includes(label.uid)) && !appliedToAll.includes(label),
128
+ );
129
+
130
+
131
+ return (
132
+ <div className="border-b border-border">
133
+ <MoveToFolderDialog
134
+ open={movePrompt}
135
+ onClose={() => setMovePrompt(false)}
136
+ mailboxUid={mailboxUid}
137
+ folders={folders}
138
+ currentFolderUid={currentFolderUid}
139
+ count={selected.length}
140
+ onMove={onMoveTo}
141
+ onFolderCreated={onFolderCreated}
142
+ />
143
+ <div className="flex items-center gap-2 px-3 py-1.5 bg-surface-alt">
144
+ <span aria-live="polite" className="text-sm font-semibold text-text">
145
+ {selectedCount}
146
+ {totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : ""} selected
147
+ </span>
148
+ <button type="button" onClick={onSelectAll} disabled={allSelected || listedCount === 0} className={actionClassName()}>
149
+ Select all
150
+ </button>
151
+ <button type="button" onClick={onClearSelection} disabled={selectedCount === 0} className={actionClassName()}>
152
+ Clear
153
+ </button>
154
+ <button type="button" onClick={onCancel} className={`${actionClassName()} ml-auto`}>
155
+ Cancel
156
+ </button>
157
+ </div>
158
+ <div className="flex flex-wrap items-center gap-0.5 px-1.5 py-1">
159
+ <button type="button" onClick={() => onSetRead(true)} disabled={none || busy} className={actionClassName()} {...hint("Mark read", SHORTCUTS.mail.markRead)}>
160
+ Mark read
161
+ </button>
162
+ <button type="button" onClick={() => onSetRead(false)} disabled={none || busy} className={actionClassName()} {...hint("Mark unread", SHORTCUTS.mail.markUnread)}>
163
+ Mark unread
164
+ </button>
165
+ <button type="button" onClick={() => onSetFlagged(true)} disabled={none || busy} className={actionClassName()} {...hint("Flag", SHORTCUTS.mail.flag)}>
166
+ Flag
167
+ </button>
168
+ <button type="button" onClick={() => onSetFlagged(false)} disabled={none || busy} className={actionClassName()}>
169
+ Unflag
170
+ </button>
171
+ <button
172
+ type="button"
173
+ onClick={onArchive}
174
+ disabled={none || busy || !!archiveReason}
175
+ title={archiveReason}
176
+ className={actionClassName()}
177
+ >
178
+ Archive
179
+ </button>
180
+ <LabelMenuButton
181
+ aria-label="Apply label"
182
+ label="Apply label"
183
+ className="text-sm"
184
+ labels={labels}
185
+ mailboxUid={mailboxUid}
186
+ onLabelCreated={onLabelCreated}
187
+ applied={appliedToAll.map((label) => label.uid)}
188
+ partial={appliedToSome.map((label) => label.uid)}
189
+ onCommit={onApplyLabels}
190
+ busy={busy}
191
+ disabled={none || busy}
192
+ note={
193
+ selected.length === 1
194
+ ? "Ticked labels are applied, unticked ones removed."
195
+ : "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."
196
+ }
197
+ emptyNote="This mailbox has no labels yet."
198
+ commit={{ label: "Apply" }}
199
+ clear={{ label: "Remove all labels" }}
200
+ />
201
+ <button
202
+ type="button"
203
+ onClick={() => setMovePrompt(true)}
204
+ disabled={none || busy}
205
+ className={actionClassName()}
206
+ >
207
+ Move to
208
+ </button>
209
+ <button
210
+ type="button"
211
+ onClick={onReportJunk}
212
+ disabled={none || busy || !!junkReason}
213
+ title={junkReason}
214
+ className={actionClassName()}
215
+ >
216
+ Report junk
217
+ </button>
218
+ <button
219
+ type="button"
220
+ onClick={onDelete}
221
+ disabled={none || busy || !!deleteReason}
222
+ className={actionClassName()}
223
+ {...hint("Delete", SHORTCUTS.mail.delete, deleteReason)}
224
+ >
225
+ Delete
226
+ </button>
227
+ </div>
228
+ {error && (
229
+ <div className="px-3 pb-2">
230
+ <Alert>{error}</Alert>
231
+ </div>
232
+ )}
233
+ </div>
234
+ );
235
+ }