@rapidmx/web-client 0.9.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 (226) 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/settings/DomainDnsSetup.tsx +2 -1
  5. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  6. package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
  7. package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
  8. package/apps/shared/components/layout/AppShell.tsx +411 -288
  9. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  10. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
  11. package/apps/shared/components/layout/UserMenu.tsx +303 -185
  12. package/apps/shared/components/mail/ConversationList.tsx +269 -262
  13. package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
  14. package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
  15. package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
  16. package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1387
  17. package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
  18. package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
  19. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  20. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1736
  21. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  22. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  23. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  24. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  25. package/apps/shared/components/mail/layout/MailShell.tsx +476 -472
  26. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  27. package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
  28. package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
  29. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  30. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  31. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  32. package/apps/shared/keyboard/dispatch.ts +124 -0
  33. package/apps/shared/keyboard/format.ts +89 -0
  34. package/apps/shared/keyboard/keymap.ts +114 -0
  35. package/apps/shared/keyboard/match.ts +44 -0
  36. package/apps/shared/keyboard/parse.ts +136 -0
  37. package/apps/shared/keyboard/platform.ts +34 -0
  38. package/apps/shared/keyboard/registry.ts +65 -0
  39. package/apps/shared/keyboard/targets.ts +79 -0
  40. package/apps/shared/keyboard/useShortcut.ts +50 -0
  41. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  42. package/apps/shared/mail/folderCounts.ts +302 -0
  43. package/apps/shared/mail/listSnapshots.ts +87 -0
  44. package/apps/shared/mail/messageReadState.ts +85 -0
  45. package/apps/shared/mail/newMailNotifications.ts +183 -0
  46. package/apps/shared/mail/useMailConnection.ts +150 -0
  47. package/apps/shared/mail/useMailLiveUpdates.ts +232 -224
  48. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  49. package/apps/shared/mail/useNewMailNotifications.ts +163 -0
  50. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  51. package/apps/shared/navigation/AppRouter.tsx +300 -0
  52. package/apps/shared/navigation/appHrefs.ts +23 -0
  53. package/apps/shared/navigation/frameContext.tsx +35 -0
  54. package/apps/shared/navigation/idle.ts +45 -0
  55. package/apps/shared/navigation/routerContext.tsx +83 -0
  56. package/apps/shared/navigation/routes.ts +72 -0
  57. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  58. package/apps/shared/styles/app.css +28 -10
  59. package/apps/www/_routedPage.tsx +24 -0
  60. package/apps/www/_routes.ts +35 -0
  61. package/apps/www/calendar/index.tsx +55 -19
  62. package/apps/www/contacts/[uid].tsx +112 -107
  63. package/apps/www/contacts/index.tsx +584 -567
  64. package/apps/www/index.tsx +2280 -1917
  65. package/apps/www/messages/[uid].tsx +106 -101
  66. package/apps/www/settings/auto-reply/index.tsx +4 -1
  67. package/apps/www/settings/encryption/index.tsx +1252 -1249
  68. package/apps/www/settings/filters/[uid].tsx +4 -1
  69. package/apps/www/settings/filters/index.tsx +102 -99
  70. package/apps/www/settings/filters/new/index.tsx +138 -133
  71. package/apps/www/settings/labels/index.tsx +204 -201
  72. package/apps/www/settings/privacy/index.tsx +4 -1
  73. package/apps/www/settings/read-receipts/index.tsx +4 -1
  74. package/apps/www/settings/sharing/index.tsx +277 -274
  75. package/apps/www/settings/signatures/[uid].tsx +170 -167
  76. package/apps/www/settings/signatures/index.tsx +88 -85
  77. package/apps/www/settings/signatures/new/index.tsx +134 -129
  78. package/apps/www/tasks/index.tsx +18 -2
  79. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  80. package/dist/apps/shared/auth/accountUrl.js +8 -0
  81. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  82. package/dist/apps/shared/auth/adminAccess.js +89 -0
  83. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
  84. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  85. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  86. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  87. package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
  88. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  89. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
  90. package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
  91. package/dist/apps/shared/components/layout/AppShell.js +73 -14
  92. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  93. package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
  94. package/dist/apps/shared/components/layout/UserMenu.d.ts +19 -3
  95. package/dist/apps/shared/components/layout/UserMenu.js +52 -5
  96. package/dist/apps/shared/components/mail/ConversationList.js +4 -11
  97. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  98. package/dist/apps/shared/components/mail/ConversationThreadPane.js +52 -27
  99. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  100. package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
  101. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
  102. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
  103. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
  104. package/dist/apps/shared/components/mail/MessageDetailPane.js +83 -34
  105. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
  106. package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
  107. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
  108. package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
  109. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  110. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
  111. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +98 -22
  112. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  113. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  114. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  115. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  116. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  117. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  118. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  119. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  120. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +12 -4
  121. package/dist/apps/shared/components/mail/layout/MailShell.js +76 -96
  122. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  123. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  124. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  125. package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
  126. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  127. package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
  128. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  129. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  130. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  131. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  132. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  133. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  134. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  135. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  136. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  137. package/dist/apps/shared/keyboard/format.js +74 -0
  138. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  139. package/dist/apps/shared/keyboard/keymap.js +84 -0
  140. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  141. package/dist/apps/shared/keyboard/match.js +31 -0
  142. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  143. package/dist/apps/shared/keyboard/parse.js +107 -0
  144. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  145. package/dist/apps/shared/keyboard/platform.js +22 -0
  146. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  147. package/dist/apps/shared/keyboard/registry.js +33 -0
  148. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  149. package/dist/apps/shared/keyboard/targets.js +67 -0
  150. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  151. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  152. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  153. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  154. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  155. package/dist/apps/shared/mail/folderCounts.js +212 -0
  156. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  157. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  158. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  159. package/dist/apps/shared/mail/messageReadState.js +63 -0
  160. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  161. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  162. package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
  163. package/dist/apps/shared/mail/useMailConnection.js +96 -0
  164. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +13 -6
  165. package/dist/apps/shared/mail/useMailLiveUpdates.js +28 -26
  166. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  167. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  168. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
  169. package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
  170. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  171. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  172. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  173. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  174. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  175. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  176. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  177. package/dist/apps/shared/navigation/frameContext.js +24 -0
  178. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  179. package/dist/apps/shared/navigation/idle.js +43 -0
  180. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  181. package/dist/apps/shared/navigation/routerContext.js +56 -0
  182. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  183. package/dist/apps/shared/navigation/routes.js +37 -0
  184. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  185. package/dist/apps/shared/styles/app.css +28 -10
  186. package/dist/apps/www/_routedPage.d.ts +12 -0
  187. package/dist/apps/www/_routedPage.js +19 -0
  188. package/dist/apps/www/_routes.d.ts +11 -0
  189. package/dist/apps/www/_routes.js +29 -0
  190. package/dist/apps/www/calendar/index.d.ts +2 -2
  191. package/dist/apps/www/calendar/index.js +39 -8
  192. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  193. package/dist/apps/www/contacts/[uid].js +6 -2
  194. package/dist/apps/www/contacts/index.d.ts +2 -2
  195. package/dist/apps/www/contacts/index.js +17 -4
  196. package/dist/apps/www/index.d.ts +2 -2
  197. package/dist/apps/www/index.js +301 -35
  198. package/dist/apps/www/messages/[uid].d.ts +3 -7
  199. package/dist/apps/www/messages/[uid].js +7 -4
  200. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  201. package/dist/apps/www/settings/auto-reply/index.js +3 -1
  202. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  203. package/dist/apps/www/settings/encryption/index.js +3 -1
  204. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  205. package/dist/apps/www/settings/filters/[uid].js +3 -1
  206. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  207. package/dist/apps/www/settings/filters/index.js +3 -1
  208. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  209. package/dist/apps/www/settings/filters/new/index.js +6 -2
  210. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  211. package/dist/apps/www/settings/labels/index.js +3 -1
  212. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  213. package/dist/apps/www/settings/privacy/index.js +3 -1
  214. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  215. package/dist/apps/www/settings/read-receipts/index.js +3 -1
  216. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  217. package/dist/apps/www/settings/sharing/index.js +3 -1
  218. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  219. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  220. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  221. package/dist/apps/www/settings/signatures/index.js +3 -1
  222. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  223. package/dist/apps/www/settings/signatures/new/index.js +6 -2
  224. package/dist/apps/www/tasks/index.d.ts +2 -2
  225. package/dist/apps/www/tasks/index.js +15 -3
  226. package/package.json +2 -2
@@ -3,19 +3,26 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
6
+ import { createContext, useContext, useEffect, useMemo, useState } from "react";
7
7
  import { HiOutlineBars3 } from "react-icons/hi2";
8
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
8
  import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
10
- import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
11
9
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
10
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
13
11
  import AppShell from "../../layout/AppShell.js";
12
+ import { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
14
13
  import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
15
14
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
16
- import { useCompose } from "../compose/ComposeContext.js";
15
+ import { prefetchComposeWindow, useCompose } from "../compose/ComposeContext.js";
17
16
  import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
18
- import { NO_LIVE_UPDATES, useMailLiveUpdates } from "../../../mail/useMailLiveUpdates.js";
17
+ import { NO_LIVE_UPDATES } from "../../../mail/useMailLiveUpdates.js";
18
+ import { FOLDER_ORDER, MAILBOX_LIST_LIMIT, MailConnectionContext, useMailConnection } from "../../../mail/useMailConnection.js";
19
+ import { badgeFor, badgeLabel, countOfFolder, inboxUnreadTotal, } from "../../../mail/folderCounts.js";
20
+ import { useUnreadTitle } from "../../../mail/useUnreadTitle.js";
21
+ import NewMailToasts from "../NewMailToasts.js";
22
+ import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
23
+ import { SHORTCUTS } from "../../../keyboard/keymap.js";
24
+ import { useKeyEnvironment } from "../../../keyboard/ShortcutProvider.js";
25
+ import { useShortcut } from "../../../keyboard/useShortcut.js";
19
26
  /** The well-known mail folder types an "aggregate" pseudo-folder can merge across every accessible
20
27
  * mailbox - a fixed, small set: `outbox` is deliberately excluded (transient per-mailbox send-queue
21
28
  * state, not a "merge across mailboxes" concept), and there's no aggregate concept for `calendar`/
@@ -25,11 +32,13 @@ export const AGGREGATE_FOLDER_TYPES = ["inbox", "sent_items", "drafts", "deleted
25
32
  function isAggregateFolderType(value) {
26
33
  return !!value && AGGREGATE_FOLDER_TYPES.includes(value);
27
34
  }
35
+ const NO_TRACKER = { settle: () => undefined, revert: () => undefined };
28
36
  const MailShellContext = createContext({
29
37
  mailboxes: [],
30
38
  mailboxFolders: [],
31
39
  onFolderCreated: () => undefined,
32
40
  live: NO_LIVE_UPDATES,
41
+ trackMessageChange: () => NO_TRACKER,
33
42
  });
34
43
  /** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
35
44
  export function useMailShell() {
@@ -44,15 +53,6 @@ const FOLDER_LABELS = {
44
53
  archive: "Archive",
45
54
  deleted_items: "Deleted Items",
46
55
  };
47
- /** Well-known folders sort first, in Gmail/Outlook's conventional order; anything else (incl. `user`) sorts after, alphabetically. */
48
- const FOLDER_ORDER = ["inbox", "drafts", "outbox", "sent_items", "junk", "archive", "deleted_items"];
49
- /**
50
- * A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see
51
- * `CalendarShell`/`ContactsShell`/`TasksShell`), not Mail — `listFolders()` returns every well-known
52
- * folder for the mailbox regardless of which app owns it, so Mail's own folder tree must filter down
53
- * to just the mail ones itself, or those other apps' folders leak into this sidebar.
54
- */
55
- const MAIL_FOLDER_TYPES = new Set([...FOLDER_ORDER, "user"]);
56
56
  function folderSortKey(folder) {
57
57
  const idx = FOLDER_ORDER.indexOf(folder.type);
58
58
  return idx === -1 ? FOLDER_ORDER.length : idx;
@@ -60,18 +60,23 @@ function folderSortKey(folder) {
60
60
  function sortedFoldersOf(folders) {
61
61
  return [...folders].sort((a, b) => folderSortKey(a) - folderSortKey(b) || a.name.localeCompare(b.name));
62
62
  }
63
- /** A folder's unread count: the refreshed one when live updates have read it since load, else the one it was listed with. */
64
- function unreadOf(folder, unreadCounts) {
65
- return unreadCounts[folder.uid] ?? folder.unreadCount;
66
- }
67
- function aggregateUnreadCount(mailboxFolders, type, unreadCounts) {
68
- return mailboxFolders.reduce((total, mf) => {
63
+ /** The badge an "All Mailboxes" entry shows: the folders of that type, summed across every mailbox, under the same rules as a single folder's. */
64
+ function aggregateBadge(mailboxFolders, type, counts) {
65
+ const sum = mailboxFolders.reduce((total, mf) => {
69
66
  const folder = mf.folders.find((f) => f.type === type);
70
- return total + (folder ? unreadOf(folder, unreadCounts) : 0);
71
- }, 0);
67
+ const count = folder ? countOfFolder(folder, counts) : { unread: 0, total: 0 };
68
+ return { unread: total.unread + count.unread, total: total.total + count.total };
69
+ }, { unread: 0, total: 0 });
70
+ return badgeFor(type, sum);
72
71
  }
73
- /** Page size of the one `listMailboxes()` call this shell makes. */
74
- export const MAILBOX_LIST_LIMIT = 100;
72
+ /** A folder's badge: an accent pill with the unread count, or - for the folders that show how many they hold - plain muted text. */
73
+ function FolderBadgeChip({ badge }) {
74
+ return (_jsxs("span", { className: [
75
+ "text-xs rounded-pill py-0.5 px-1.5",
76
+ badge.kind === "unread" ? "font-bold bg-primary/15 text-primary-dark" : "font-medium text-text-muted",
77
+ ].join(" "), children: [_jsx("span", { "aria-hidden": "true", children: badge.value }), _jsxs("span", { className: "sr-only", children: [" ", badgeLabel(badge)] })] }));
78
+ }
79
+ export { MAILBOX_LIST_LIMIT };
75
80
  /**
76
81
  * A separate component (not inlined into `MailShell`'s own render) so `useCompose()` resolves against
77
82
  * `ComposeProvider` correctly: that provider is rendered *inside* the `AppShell` that `MailShell` itself
@@ -80,12 +85,25 @@ export const MAILBOX_LIST_LIMIT = 100;
80
85
  * its own descendants creates. This button, rendered as part of `AppShell`'s `children`, sits correctly
81
86
  * inside that subtree.
82
87
  *
83
- * Opens with no mailbox: a fresh message defaults to the caller's own mailbox, and the compose window's
84
- * own From field is where the sender is chosen.
88
+ * Opens with the caller's own mailbox (the shell has already listed them), so the window can start on its
89
+ * signature, draft and encryption lookups straight away instead of first asking which mailbox that is; the
90
+ * compose window's own From field is where the sender is chosen. Fetches the window's code as the pointer or
91
+ * keyboard reaches the button, so the click finds it already here.
85
92
  */
86
- function ComposeButton() {
93
+ function ComposeButton({ mailboxUid }) {
87
94
  const { openCompose } = useCompose();
88
- return (_jsx("button", { type: "button", onClick: () => openCompose({}), className: "block text-center w-full py-2.5 px-4 rounded-sm font-semibold text-sm bg-primary text-white hover:bg-primary-dark", children: "Compose" }));
95
+ const env = useKeyEnvironment();
96
+ return (_jsx("button", { type: "button", title: withHint("Compose", SHORTCUTS.mail.create, env), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.mail.create, env), onClick: () => openCompose({ mailboxUid }), onPointerEnter: prefetchComposeWindow, onFocus: prefetchComposeWindow, className: "block text-center w-full py-2.5 px-4 rounded-sm font-semibold text-sm bg-primary text-white hover:bg-primary-dark", children: "Compose" }));
97
+ }
98
+ /**
99
+ * Mail's keyboard shortcuts that don't belong to a list or a message: "New message". A component of its own for the reason `ComposeButton`
100
+ * is - `useCompose()` only resolves below `ComposeProvider`, i.e. inside the `AppShell` this shell renders - and mounted once, whatever number
101
+ * of Compose buttons (the sidebar, the folders drawer) are on screen. Opens the same window, for the same mailbox, as the button.
102
+ */
103
+ function MailShortcuts({ mailboxUid }) {
104
+ const { openCompose } = useCompose();
105
+ useShortcut(SHORTCUTS.mail.create, () => openCompose({ mailboxUid }));
106
+ return null;
89
107
  }
90
108
  /**
91
109
  * Mail's own contextual sidebar (every accessible mailbox's own folder tree, plus a merged "All Mailboxes"
@@ -103,36 +121,33 @@ function ComposeButton() {
103
121
  * aggregate section) simultaneously, matching the shared-mailbox feature's own "display as a separate set
104
122
  * of folders" requirement.
105
123
  */
106
- export default function MailShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }) {
107
- const [status, setStatus] = useState("checking");
108
- const [error, setError] = useState(null);
109
- const [mailboxes, setMailboxes] = useState([]);
110
- const [mailboxFolders, setMailboxFolders] = useState([]);
111
- const [foldersLoading, setFoldersLoading] = useState(true);
124
+ export default function MailShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }) {
125
+ // The mailboxes, their folders and the live connection come from the persistent app frame when there is one (`AppChrome` owns them, so
126
+ // they survive Mail -> Calendar -> Mail and the push socket, pop-ups, counters and tab title work in every app). A page rendered
127
+ // outside the frame - a test, a plugin page - has no frame to ask, so this shell runs the same hook for itself, and renders the pop-ups.
128
+ const navigate = useNavigate();
129
+ const hosted = useContext(MailConnectionContext);
130
+ const own = useMailConnection({ userUid, enabled: !hosted, open: navigate });
131
+ const { status, error, mailboxes, mailboxFolders, foldersLoading, onFolderCreated, live, folderCounts, notifications } = hosted ?? own;
112
132
  const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
113
133
  const [requestedFolderUid, setRequestedFolderUid] = useState(null);
114
134
  const [requestedAggregateType, setRequestedAggregateType] = useState(null);
115
135
  const [drawerOpen, setDrawerOpen] = useState(false);
136
+ // The selection lives in the URL, and the router (`AppRouter`) changes the URL without a page load when a folder link is
137
+ // clicked - so it is read from the router's location, which updates, rather than once from `window.location`. Still read
138
+ // in an effect, never during the first render, so the server render and the hydrating render agree.
139
+ const search = useLocationSearch();
116
140
  useEffect(() => {
117
- const params = new URLSearchParams(window.location.search);
141
+ const params = new URLSearchParams(search);
118
142
  setRequestedMailboxUid(params.get("mailboxUid"));
119
143
  setRequestedFolderUid(params.get("folderUid"));
120
144
  setRequestedAggregateType(params.get("aggregate"));
121
- }, []);
145
+ }, [search]);
146
+ // Choosing a folder (or mailbox) changes the URL without a page load now, so the drawer that held the choice - which used to go
147
+ // with the page - is closed here.
122
148
  useEffect(() => {
123
- if (!userUid) {
124
- return;
125
- }
126
- listMailboxes({ limit: MAILBOX_LIST_LIMIT })
127
- .then((result) => {
128
- setMailboxes(result);
129
- setStatus("ready");
130
- })
131
- .catch((err) => {
132
- setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
133
- setStatus("error");
134
- });
135
- }, [userUid]);
149
+ setDrawerOpen(false);
150
+ }, [search]);
136
151
  const aggregateFolderType = isAggregateFolderType(requestedAggregateType)
137
152
  ? requestedAggregateType
138
153
  : undefined;
@@ -140,29 +155,6 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
140
155
  ? undefined
141
156
  : (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
142
157
  mailboxes[0]?.uid;
143
- // Fans out one listFolders() call per accessible mailbox in parallel - each call catches its own
144
- // failure into an MailboxFolders.error rather than letting Promise.all reject, so one mailbox's fetch
145
- // failure renders that section's own inline Alert instead of blanking out every other mailbox's
146
- // folder tree.
147
- useEffect(() => {
148
- if (mailboxes.length === 0) {
149
- // Deliberately leaves foldersLoading as-is: this also runs once before listMailboxes() has
150
- // resolved, and clearing it here would briefly render an empty sidebar the moment mailboxes
151
- // arrive, before their folders do. A genuinely mailbox-less caller gets MailboxProvisioning.
152
- setMailboxFolders([]);
153
- return;
154
- }
155
- setFoldersLoading(true);
156
- void Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid)
157
- .then((result) => ({ mailbox, folders: result.filter((f) => MAIL_FOLDER_TYPES.has(f.type)) }))
158
- .catch((err) => ({
159
- mailbox,
160
- folders: [],
161
- error: err instanceof ApiRequestError ? err.message : "Could not load folders.",
162
- }))))
163
- .then(setMailboxFolders)
164
- .finally(() => setFoldersLoading(false));
165
- }, [mailboxes]);
166
158
  const selectedMailboxFolders = mailboxFolders.find((mf) => mf.mailbox.uid === mailboxUid)?.folders ?? [];
167
159
  const folderUid = aggregateFolderType
168
160
  ? undefined
@@ -175,23 +167,11 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
175
167
  // mailbox may still need that mailbox's own folder view opened directly to unlock/decrypt it.
176
168
  const defaultMailboxUid = mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
177
169
  const activeMailboxUid = mailboxUid ?? defaultMailboxUid;
178
- /** Files a newly created folder under its own mailbox, leaving every other mailbox's list untouched -
179
- * and ignoring a type this sidebar doesn't list at all, the same filter the fetch above applies. */
180
- const onFolderCreated = useCallback((folder) => {
181
- if (!MAIL_FOLDER_TYPES.has(folder.type)) {
182
- return;
183
- }
184
- setMailboxFolders((prev) => prev.map((entry) =>
185
- // Not one already there: another client's create event can arrive after (or twice, or alongside) our own.
186
- entry.mailbox.uid === folder.mailboxUid && !entry.folders.some((existing) => existing.uid === folder.uid)
187
- ? { ...entry, folders: [...entry.folders, folder] }
188
- : entry));
189
- }, []);
190
- // New mail without a reload: push events for every folder, plus a safety-net poll. Held in state beside `mailboxFolders`
191
- // rather than written into it, so a refreshed unread count never looks like a change of folders to the list on screen
192
- // (which reloads, and forgets its selection, whenever the folders do).
193
- const { live, unreadCounts } = useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated });
194
- const contextValue = useMemo(() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live }), [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live]);
170
+ const counts = folderCounts.counts;
171
+ const trackMessageChange = folderCounts.track;
172
+ // `(3) Acme: Mail` in the tab strip while there is unread mail in an Inbox - kept by the frame when there is one.
173
+ useUnreadTitle(inboxUnreadTotal(mailboxFolders, counts), { enabled: !hosted });
174
+ const contextValue = useMemo(() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live, trackMessageChange }), [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live, trackMessageChange]);
195
175
  // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
196
176
  // mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all. Checks
197
177
  // `mailboxes.length` directly (not `!mailboxUid`) since `mailboxUid` is legitimately undefined in
@@ -212,24 +192,24 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
212
192
  // A function, not a plain JSX constant — it's rendered twice (desktop `<aside>` + mobile
213
193
  // `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
214
194
  // unmounted).
215
- const sidebarContent = () => (_jsxs(_Fragment, { children: [_jsx("div", { className: "p-3", children: _jsx(ComposeButton, {}) }), foldersLoading ? (_jsx("div", { className: "flex-1 overflow-y-auto px-3 pb-3", children: _jsx(SkeletonList, { count: 6, className: "pt-1" }) })) : (_jsxs("nav", { className: "flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3", children: [mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5", children: "All Mailboxes" }), _jsx("div", { className: "flex flex-col gap-0.5", children: AGGREGATE_FOLDER_TYPES.map((type) => {
216
- const unread = aggregateUnreadCount(mailboxFolders, type, unreadCounts);
195
+ const sidebarContent = () => (_jsxs(_Fragment, { children: [_jsx("div", { className: "p-3", children: _jsx(ComposeButton, { mailboxUid: defaultMailboxUid }) }), foldersLoading ? (_jsx("div", { className: "flex-1 overflow-y-auto px-3 pb-3", children: _jsx(SkeletonList, { count: 6, className: "pt-1" }) })) : (_jsxs("nav", { className: "flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3", children: [mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5", children: "All Mailboxes" }), _jsx("div", { className: "flex flex-col gap-0.5", children: AGGREGATE_FOLDER_TYPES.map((type) => {
196
+ const badge = aggregateBadge(mailboxFolders, type, counts);
217
197
  return (_jsxs("a", { href: `/?aggregate=${encodeURIComponent(type)}`, className: [
218
198
  "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
219
199
  aggregateFolderType === type
220
200
  ? "bg-primary/10 text-primary-dark font-semibold"
221
201
  : "text-text hover:bg-surface-alt",
222
- ].join(" "), children: [_jsx("span", { children: FOLDER_LABELS[type] }), unread > 0 && (_jsx("span", { className: "text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted", children: unread }))] }, type));
202
+ ].join(" "), children: [_jsx("span", { className: badge?.kind === "unread" ? "font-semibold" : undefined, children: FOLDER_LABELS[type] }), badge && _jsx(FolderBadgeChip, { badge: badge })] }, type));
223
203
  }) })] })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsxs("div", { children: [_jsxs("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5 truncate", children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }), mailboxError && (_jsx("div", { className: "px-2.5 pb-1", children: _jsx(Alert, { children: mailboxError }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: sortedFoldersOf(folders).map((folder) => {
224
- const unread = unreadOf(folder, unreadCounts);
204
+ const badge = badgeFor(folder.type, countOfFolder(folder, counts));
225
205
  return (_jsxs("a", { href: `/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`, className: [
226
206
  "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
227
207
  folder.uid === folderUid
228
208
  ? "bg-primary/10 text-primary-dark font-semibold"
229
209
  : "text-text hover:bg-surface-alt",
230
- ].join(" "), children: [_jsx("span", { children: FOLDER_LABELS[folder.type] ?? folder.name }), unread > 0 && (_jsx("span", { className: "text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted", children: unread }))] }, folder.uid));
210
+ ].join(" "), children: [_jsx("span", { className: badge?.kind === "unread" ? "font-semibold" : undefined, children: FOLDER_LABELS[folder.type] ?? folder.name }), badge && _jsx(FolderBadgeChip, { badge: badge })] }, folder.uid));
231
211
  }) })] }, mailbox.uid)))] }))] }));
232
- inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent() }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", children: _jsx("div", { className: "flex flex-col", children: sidebarContent() }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
212
+ inner = (_jsxs(_Fragment, { children: [_jsx(MailShortcuts, { mailboxUid: defaultMailboxUid }), _jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent() }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", children: _jsx("div", { className: "flex flex-col", children: sidebarContent() }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] }), !hosted && (_jsx(NewMailToasts, { toasts: notifications.toasts, onDismiss: notifications.dismiss, offerDesktop: notifications.offerDesktop, onEnableDesktop: () => void notifications.enableDesktop(), onDeclineDesktop: notifications.declineDesktop }))] }));
233
213
  }
234
214
  // Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
235
215
  // resolved yet - KeyEnrollmentGate itself passes `children` through untouched until a real
@@ -251,5 +231,5 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
251
231
  canProvision: !impersonating && activeMailbox?.ownerUserUid === userUid, children: [_jsx(LocalIndexLifecycle, { mailboxUid: activeMailboxUid, mailboxKeys: activeMailbox?.keys, folders: mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? [],
252
232
  // A full page may be truncated - pruning against it would delete indexes of accessible mailboxes
253
233
  // beyond the limit, so it's only trusted as the complete list when it came back short.
254
- accessibleMailboxUids: status === "ready" && mailboxes.length < MAILBOX_LIST_LIMIT ? mailboxes.map((mb) => mb.uid) : undefined }), _jsx(AppShell, { active: "mail", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, pluginNav: pluginNav, children: inner })] }));
234
+ accessibleMailboxUids: status === "ready" && mailboxes.length < MAILBOX_LIST_LIMIT ? mailboxes.map((mb) => mb.uid) : undefined }), _jsx(AppShell, { active: "mail", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner })] }));
255
235
  }
@@ -0,0 +1,45 @@
1
+ import React from "react";
2
+ import type { Message } from "@rapidmx/react-shared/mail/mailApi.js";
3
+ /**
4
+ * How an unread message looks in every list of mail (the message list, the conversation list and its child rows, the
5
+ * thread pane's message headers, the folder badges), in one place so they can't drift apart. Modelled on Outlook:
6
+ *
7
+ * **Unread**: an accent bar down the row's left edge, a faint accent tint on the row, the sender and subject in bold and the
8
+ * date in the accent colour - plus a visually-hidden "Unread" in the row's text, so it never relies on colour alone.
9
+ *
10
+ * **Read**: normal weight, the sender and preview muted.
11
+ *
12
+ * **Hover, selected and focused** are their own states and look nothing like unread: hover is a plain grey (an accent tint on
13
+ * an unread row, so the row doesn't seem to lose its state), the open row has a stronger accent fill and an inset outline, and
14
+ * keyboard focus is a solid outline drawn inside the row.
15
+ *
16
+ * All of it is built from the theme's own tokens (`primary`, `text`, `text-muted`, `surface-alt`), so it follows light and
17
+ * dark mode and a branding stylesheet's palette.
18
+ */
19
+ /** Whether a message is unread: `flags.read` isn't `true`. A message with no `read` flag at all counts as unread. */
20
+ export declare function isUnread(message: Pick<Message, "flags"> | undefined | null): boolean;
21
+ /**
22
+ * The classes of a row's outer element (`li`, or the row's wrapping `div`): the positioning the bar needs, and its one
23
+ * background - the open row's accent fill and inset outline, else an unread row's faint tint (a little stronger on hover),
24
+ * else a read row's plain grey hover. One element owns all of it so the states can't fight over the same property.
25
+ */
26
+ export declare function rowClass(state: {
27
+ unread: boolean;
28
+ selected: boolean;
29
+ }, extra?: string): string;
30
+ /** Keyboard focus on a row's button: a solid outline drawn inside the row, unlike anything unread/selected/hover does. */
31
+ export declare const ROW_FOCUS_CLASS = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary";
32
+ /** The sender line: bold and full-strength while unread, normal and muted once read. */
33
+ export declare function senderClass(unread: boolean): string;
34
+ /** The subject line. */
35
+ export declare function subjectClass(unread: boolean): string;
36
+ /** The date beside the sender: the accent colour while unread. */
37
+ export declare function dateClass(unread: boolean): string;
38
+ /** The accent bar down a row's left edge - only for an unread row. The row's outer element must be `relative`. */
39
+ export declare function UnreadBar({ unread }: {
40
+ unread: boolean;
41
+ }): React.JSX.Element | null;
42
+ /** "Unread." in the row's text for assistive technology (and for anything that reads the DOM without styles). */
43
+ export declare function UnreadLabel({ unread }: {
44
+ unread: boolean;
45
+ }): React.JSX.Element | null;
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * How an unread message looks in every list of mail (the message list, the conversation list and its child rows, the
4
+ * thread pane's message headers, the folder badges), in one place so they can't drift apart. Modelled on Outlook:
5
+ *
6
+ * **Unread**: an accent bar down the row's left edge, a faint accent tint on the row, the sender and subject in bold and the
7
+ * date in the accent colour - plus a visually-hidden "Unread" in the row's text, so it never relies on colour alone.
8
+ *
9
+ * **Read**: normal weight, the sender and preview muted.
10
+ *
11
+ * **Hover, selected and focused** are their own states and look nothing like unread: hover is a plain grey (an accent tint on
12
+ * an unread row, so the row doesn't seem to lose its state), the open row has a stronger accent fill and an inset outline, and
13
+ * keyboard focus is a solid outline drawn inside the row.
14
+ *
15
+ * All of it is built from the theme's own tokens (`primary`, `text`, `text-muted`, `surface-alt`), so it follows light and
16
+ * dark mode and a branding stylesheet's palette.
17
+ */
18
+ /** Whether a message is unread: `flags.read` isn't `true`. A message with no `read` flag at all counts as unread. */
19
+ export function isUnread(message) {
20
+ return !!message && message.flags.read !== true;
21
+ }
22
+ /**
23
+ * The classes of a row's outer element (`li`, or the row's wrapping `div`): the positioning the bar needs, and its one
24
+ * background - the open row's accent fill and inset outline, else an unread row's faint tint (a little stronger on hover),
25
+ * else a read row's plain grey hover. One element owns all of it so the states can't fight over the same property.
26
+ */
27
+ export function rowClass(state, extra = "") {
28
+ return [
29
+ "relative border-b border-border",
30
+ state.selected
31
+ ? "bg-primary/20 ring-1 ring-inset ring-primary/50"
32
+ : state.unread
33
+ ? "bg-primary/[0.07] hover:bg-primary/10"
34
+ : "hover:bg-surface-alt",
35
+ extra,
36
+ ]
37
+ .filter(Boolean)
38
+ .join(" ");
39
+ }
40
+ /** Keyboard focus on a row's button: a solid outline drawn inside the row, unlike anything unread/selected/hover does. */
41
+ export const ROW_FOCUS_CLASS = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary";
42
+ /** The sender line: bold and full-strength while unread, normal and muted once read. */
43
+ export function senderClass(unread) {
44
+ return unread ? "font-bold text-text" : "font-normal text-text-muted";
45
+ }
46
+ /** The subject line. */
47
+ export function subjectClass(unread) {
48
+ return unread ? "font-semibold text-text" : "font-normal text-text";
49
+ }
50
+ /** The date beside the sender: the accent colour while unread. */
51
+ export function dateClass(unread) {
52
+ return unread ? "font-semibold text-primary-dark" : "font-normal text-text-muted";
53
+ }
54
+ /** The accent bar down a row's left edge - only for an unread row. The row's outer element must be `relative`. */
55
+ export function UnreadBar({ unread }) {
56
+ return unread ? _jsx("span", { "aria-hidden": "true", "data-unread-bar": true, className: "absolute inset-y-0 left-0 w-1 bg-primary" }) : null;
57
+ }
58
+ /** "Unread." in the row's text for assistive technology (and for anything that reads the DOM without styles). */
59
+ export function UnreadLabel({ unread }) {
60
+ return unread ? _jsx("span", { className: "sr-only", children: "Unread. " }) : null;
61
+ }
@@ -38,4 +38,4 @@ export declare function useSettingsShell(): SettingsShellContextValue;
38
38
  * settings sections (`SETTINGS_SECTIONS`) — full-page navigation between them, same as the top-level
39
39
  * app rail, since this framework has no client-side router.
40
40
  */
41
- export default function SettingsShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }: PropsWithChildren<SettingsShellProps>): React.JSX.Element;
41
+ export default function SettingsShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }: PropsWithChildren<SettingsShellProps>): React.JSX.Element;
@@ -11,6 +11,7 @@ import { listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
11
11
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
12
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
13
13
  import AppShell from "../../layout/AppShell.js";
14
+ import { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
14
15
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
15
16
  import { mergePluginNavItems } from "../../../plugins/pluginNav.js";
16
17
  /** Every settings section with its own sidebar entry. */
@@ -46,15 +47,24 @@ export function useSettingsShell() {
46
47
  * settings sections (`SETTINGS_SECTIONS`) — full-page navigation between them, same as the top-level
47
48
  * app rail, since this framework has no client-side router.
48
49
  */
49
- export default function SettingsShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }) {
50
+ export default function SettingsShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }) {
50
51
  const [status, setStatus] = useState("checking");
51
52
  const [error, setError] = useState(null);
52
53
  const [mailboxes, setMailboxes] = useState([]);
53
54
  const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
54
55
  const [drawerOpen, setDrawerOpen] = useState(false);
56
+ // Read from the router's location, so a mailbox change (a link, or `navigate()`) takes effect without a page load - and in an
57
+ // effect, not during render, so the server render and the hydrating render agree.
58
+ const search = useLocationSearch();
59
+ const navigate = useNavigate();
55
60
  useEffect(() => {
56
- setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
57
- }, []);
61
+ setRequestedMailboxUid(new URLSearchParams(search).get("mailboxUid"));
62
+ }, [search]);
63
+ // Choosing a folder (or mailbox) changes the URL without a page load now, so the drawer that held the choice - which used to go
64
+ // with the page - is closed here.
65
+ useEffect(() => {
66
+ setDrawerOpen(false);
67
+ }, [search]);
58
68
  useEffect(() => {
59
69
  if (!userUid) {
60
70
  return;
@@ -94,7 +104,7 @@ export default function SettingsShell({ active, userUid, authServerUrl, imperson
94
104
  // `SettingsShellProps.active`), but a plugin page's section is missing when the
95
105
  // server didn't send its nav item, so that falls back to the current path.
96
106
  const href = sections.find((s) => s.id === active)?.href ?? window.location.pathname;
97
- window.location.href = `${href}?mailboxUid=${encodeURIComponent(e.target.value)}`;
107
+ navigate(`${href}?mailboxUid=${encodeURIComponent(e.target.value)}`);
98
108
  }, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsx("nav", { "aria-label": "Settings sections", className: "flex flex-col gap-0.5", children: sections.map((section) => (_jsx("a", {
99
109
  // `inner` (this whole branch) is only ever computed once `status === "ready"`
100
110
  // and `mailboxUid` has already resolved truthy — a falsy `mailboxUid` returns
@@ -109,5 +119,5 @@ export default function SettingsShell({ active, userUid, authServerUrl, imperson
109
119
  ].join(" "), children: section.label }, section.id))) })] }));
110
120
  inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Settings", children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [_jsx("button", { type: "button", className: "md:hidden m-3 self-start w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open settings menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(SettingsShellContext.Provider, { value: contextValue, children: children })] })] }));
111
121
  }
112
- return (_jsx(AppShell, { active: "settings", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, pluginNav: pluginNav, children: inner }));
122
+ return (_jsx(AppShell, { active: "settings", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner }));
113
123
  }
@@ -21,4 +21,4 @@ export declare function useTasksShell(): TasksShellContextValue;
21
21
  * (rather than a single generic "SingleFolderShell" parameterized by type) so each app's sidebar can
22
22
  * grow its own app-specific controls later without threading extra props through a shared one.
23
23
  */
24
- export default function TasksShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }: PropsWithChildren<TasksShellProps>): React.JSX.Element;
24
+ export default function TasksShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }: PropsWithChildren<TasksShellProps>): React.JSX.Element;
@@ -11,6 +11,7 @@ import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.j
11
11
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
12
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
13
13
  import AppShell from "../../layout/AppShell.js";
14
+ import { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
14
15
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
15
16
  const TasksShellContext = createContext({ mailboxes: [] });
16
17
  /** Reads the mailbox/folder Tasks is currently showing, as resolved by the enclosing `TasksShell`. */
@@ -24,7 +25,7 @@ export function useTasksShell() {
24
25
  * (rather than a single generic "SingleFolderShell" parameterized by type) so each app's sidebar can
25
26
  * grow its own app-specific controls later without threading extra props through a shared one.
26
27
  */
27
- export default function TasksShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }) {
28
+ export default function TasksShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }) {
28
29
  const [status, setStatus] = useState("checking");
29
30
  const [error, setError] = useState(null);
30
31
  const [mailboxes, setMailboxes] = useState([]);
@@ -32,9 +33,18 @@ export default function TasksShell({ userUid, authServerUrl, impersonating, impe
32
33
  const [folderError, setFolderError] = useState(null);
33
34
  const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
34
35
  const [drawerOpen, setDrawerOpen] = useState(false);
36
+ // Read from the router's location, so a mailbox change (a link, or `navigate()`) takes effect without a page load - and in an
37
+ // effect, not during render, so the server render and the hydrating render agree.
38
+ const search = useLocationSearch();
39
+ const navigate = useNavigate();
35
40
  useEffect(() => {
36
- setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
37
- }, []);
41
+ setRequestedMailboxUid(new URLSearchParams(search).get("mailboxUid"));
42
+ }, [search]);
43
+ // Choosing a folder (or mailbox) changes the URL without a page load now, so the drawer that held the choice - which used to go
44
+ // with the page - is closed here.
45
+ useEffect(() => {
46
+ setDrawerOpen(false);
47
+ }, [search]);
38
48
  useEffect(() => {
39
49
  if (!userUid) {
40
50
  return;
@@ -84,9 +94,9 @@ export default function TasksShell({ userUid, authServerUrl, impersonating, impe
84
94
  // comment: it's rendered twice (desktop `<aside>` + mobile `Drawer`), possibly simultaneously
85
95
  // mounted, so the `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct value per instance.
86
96
  const sidebarContent = (idPrefix) => (_jsxs(_Fragment, { children: [mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold uppercase tracking-wide text-text-muted mb-1", htmlFor: `${idPrefix}-tasks-mailbox-switcher`, children: "Mailbox" }), _jsx("select", { id: `${idPrefix}-tasks-mailbox-switcher`, className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: mailboxUid, onChange: (e) => {
87
- window.location.href = `/tasks?mailboxUid=${encodeURIComponent(e.target.value)}`;
97
+ navigate(`/tasks?mailboxUid=${encodeURIComponent(e.target.value)}`);
88
98
  }, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), folderError && _jsx(Alert, { children: folderError })] }));
89
99
  inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Mailbox", children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [(mailboxes.length > 1 || folderError) && (_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open mailbox switcher", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) })), _jsx(TasksShellContext.Provider, { value: contextValue, children: children })] })] }));
90
100
  }
91
- return (_jsx(AppShell, { active: "tasks", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, pluginNav: pluginNav, children: inner }));
101
+ return (_jsx(AppShell, { active: "tasks", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner }));
92
102
  }
@@ -0,0 +1,14 @@
1
+ export interface GlobalShortcutsProps {
2
+ /** auth-server's base URL - "Go to Account" is offered only when it is set. */
3
+ authServerUrl?: string;
4
+ /** Opens the keyboard shortcuts dialog, or closes it if it is open. */
5
+ onToggleHelp: () => void;
6
+ }
7
+ /**
8
+ * The shortcuts that work in every view: the "Go to ..." navigation set and the keyboard help. Mounted once by `AppChrome`, next to the
9
+ * `ShortcutProvider`, so it works from Settings and every app alike. Renders nothing.
10
+ *
11
+ * In-app places go through the client-side router (`useNavigate()`); Account is another origin, so it is a plain navigation. Asking for the
12
+ * page you are already on does nothing - but the key is still taken, so the browser doesn't act on it either.
13
+ */
14
+ export declare function GlobalShortcuts({ authServerUrl, onToggleHelp }: GlobalShortcutsProps): null;
@@ -0,0 +1,37 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { accountUrlOf } from "../auth/accountUrl.js";
6
+ import { APP_HREFS, SETTINGS_HREF, isSettingsPath } from "../navigation/appHrefs.js";
7
+ import { useNavigate } from "../navigation/routerContext.js";
8
+ import { SHORTCUTS } from "./keymap.js";
9
+ import { useShortcut } from "./useShortcut.js";
10
+ /**
11
+ * The shortcuts that work in every view: the "Go to ..." navigation set and the keyboard help. Mounted once by `AppChrome`, next to the
12
+ * `ShortcutProvider`, so it works from Settings and every app alike. Renders nothing.
13
+ *
14
+ * In-app places go through the client-side router (`useNavigate()`); Account is another origin, so it is a plain navigation. Asking for the
15
+ * page you are already on does nothing - but the key is still taken, so the browser doesn't act on it either.
16
+ */
17
+ export function GlobalShortcuts({ authServerUrl, onToggleHelp }) {
18
+ const navigate = useNavigate();
19
+ const accountUrl = accountUrlOf(authServerUrl);
20
+ /** A handler that goes to `href` unless the address bar's path says it is already there. */
21
+ const goTo = (href, isHere) => () => {
22
+ if (!isHere(window.location.pathname)) {
23
+ navigate(href);
24
+ }
25
+ };
26
+ const isPath = (path) => (pathname) => pathname === path;
27
+ useShortcut(SHORTCUTS.global.mail, goTo(APP_HREFS.mail, isPath(APP_HREFS.mail)));
28
+ useShortcut(SHORTCUTS.global.calendar, goTo(APP_HREFS.calendar, isPath(APP_HREFS.calendar)));
29
+ useShortcut(SHORTCUTS.global.contacts, goTo(APP_HREFS.contacts, isPath(APP_HREFS.contacts)));
30
+ useShortcut(SHORTCUTS.global.tasks, goTo(APP_HREFS.tasks, isPath(APP_HREFS.tasks)));
31
+ useShortcut(SHORTCUTS.global.settings, goTo(SETTINGS_HREF, isSettingsPath));
32
+ useShortcut(SHORTCUTS.global.account, () => {
33
+ window.location.href = accountUrl;
34
+ }, { enabled: !!accountUrl });
35
+ useShortcut(SHORTCUTS.global.help, onToggleHelp);
36
+ return null;
37
+ }
@@ -0,0 +1,20 @@
1
+ import React, { PropsWithChildren } from "react";
2
+ import { KeyEnvironment } from "./platform.js";
3
+ import { Registration, ShortcutRegistry } from "./registry.js";
4
+ /**
5
+ * The web client's one keyboard layer. Mounted once, by `AppChrome` (the frame that stays mounted as the router swaps pages), so shortcuts
6
+ * work in every view - mail, calendar, contacts, tasks, settings - and a page change only changes which handlers are registered.
7
+ *
8
+ * One `keydown` listener on `document` (bubbling, so what a widget handles itself and `preventDefault()`s is left alone) hands each key to
9
+ * `dispatchKeyEvent()`. Views claim keys with `useShortcut()`. Outside a provider - an admin page, a component rendered on its own - that
10
+ * hook does nothing.
11
+ *
12
+ * The platform (macOS, the desktop client) is read after mount, never during render, so the server render and hydration agree.
13
+ */
14
+ export declare function ShortcutProvider({ children }: PropsWithChildren): React.JSX.Element;
15
+ /** The registry, or `null` outside a `ShortcutProvider`. */
16
+ export declare function useShortcutRegistry(): ShortcutRegistry | null;
17
+ /** The platform as the keyboard layer sees it - `SERVER_ENVIRONMENT` until the client has mounted, and outside a provider. */
18
+ export declare function useKeyEnvironment(): KeyEnvironment;
19
+ /** Everything registered right now, updating as views mount and unmount - what the help dialog lists. Empty outside a provider. */
20
+ export declare function useRegisteredShortcuts(): readonly Registration[];