@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
@@ -0,0 +1,9 @@
1
+ import React from "react";
2
+ import type MessageDetailPaneType from "./MessageDetailPane.js";
3
+ import type ConversationThreadPaneType from "./ConversationThreadPane.js";
4
+ /** Starts downloading the reading pane's code, for the page to call when it has nothing better to do. Never rejects. */
5
+ export declare function prefetchReadingPane(): void;
6
+ /** With nothing selected the pane is only its empty-state line, which needs none of the pane's code - so the chunk isn't
7
+ * requested until something is (or the page prefetches it). */
8
+ export declare function LazyMessageDetailPane(props: React.ComponentProps<typeof MessageDetailPaneType>): React.JSX.Element;
9
+ export declare function LazyConversationThreadPane(props: React.ComponentProps<typeof ConversationThreadPaneType>): React.JSX.Element;
@@ -0,0 +1,82 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useState } from "react";
7
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
8
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
9
+ import Skeleton from "@rapidmx/react-shared/components/feedback/Skeleton.js";
10
+ /**
11
+ * The reading pane (`MessageDetailPane`, and the thread pane that stacks it) is only rendered once a message is selected, and
12
+ * it is the heaviest piece of the inbox after the compose window - message security, the body sanitizer, the labels and move
13
+ * menus - so it is a chunk of its own, downloaded when the browser is idle after the list has loaded (`prefetchReadingPane()`)
14
+ * and, at the latest, on the first selection. Nothing about the pane changes: these wrap the same components, with a skeleton
15
+ * while the chunk loads and a reload prompt if it can't.
16
+ *
17
+ * Deliberately not `React.lazy()` + `<Suspense>`: a boundary that has shown its fallback holds the content back for up to 300 ms
18
+ * after the code arrives (React's fallback throttle), which would put a fifth of a second between a click and a pane whose
19
+ * code was already fetched. This reads the loaded component synchronously when there is one, so an already-loaded pane renders
20
+ * on the click's own frame, and it can retry a failed download.
21
+ */
22
+ function lazyComponent(load) {
23
+ let loaded;
24
+ let loading;
25
+ /** Starts (once) loading the component's code. Rejects if it can't be downloaded - and then a later call tries again. */
26
+ function start() {
27
+ loading ?? (loading = load().then((module) => (loaded = module.default), (err) => {
28
+ loading = undefined;
29
+ throw err;
30
+ }));
31
+ return loading;
32
+ }
33
+ function Lazy(props) {
34
+ const [, setLoadedNow] = useState(0);
35
+ const [failed, setFailed] = useState(false);
36
+ useEffect(() => {
37
+ if (loaded) {
38
+ return;
39
+ }
40
+ let cancelled = false;
41
+ start().then(() => !cancelled && setLoadedNow((n) => n + 1), () => !cancelled && setFailed(true));
42
+ return () => {
43
+ cancelled = true;
44
+ };
45
+ }, []);
46
+ if (loaded) {
47
+ const Component = loaded;
48
+ return _jsx(Component, { ...props });
49
+ }
50
+ return failed ? _jsx(ChunkFailed, {}) : _jsx(PaneSkeleton, {});
51
+ }
52
+ return { Lazy, start };
53
+ }
54
+ function PaneSkeleton() {
55
+ return (_jsxs("div", { role: "status", "aria-busy": "true", className: "flex-1 p-6 flex flex-col gap-3", children: [_jsx("span", { className: "sr-only", children: "Loading the message" }), _jsx(Skeleton, { width: "w-2/3", height: "h-6" }), _jsx(Skeleton, { width: "w-1/3" }), _jsx(Skeleton, { height: "h-40" })] }));
56
+ }
57
+ /** A chunk that can't be downloaded (offline, or replaced by a deploy since this page loaded) is the one failure a reading
58
+ * pane can't recover from by itself: say so, with the fix, rather than leaving a blank pane. */
59
+ function ChunkFailed() {
60
+ return (_jsx("div", { className: "flex-1 p-6", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: "This part of the page couldn\u2019t be loaded. Check your connection, then reload." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => window.location.reload(), children: "Reload" })] }) }));
61
+ }
62
+ const messageDetailPane = lazyComponent(() => import("./MessageDetailPane.js"));
63
+ const conversationThreadPane = lazyComponent(() => import("./ConversationThreadPane.js"));
64
+ /** Starts downloading the reading pane's code, for the page to call when it has nothing better to do. Never rejects. */
65
+ export function prefetchReadingPane() {
66
+ messageDetailPane.start().catch(() => undefined);
67
+ conversationThreadPane.start().catch(() => undefined);
68
+ }
69
+ /** With nothing selected the pane is only its empty-state line, which needs none of the pane's code - so the chunk isn't
70
+ * requested until something is (or the page prefetches it). */
71
+ export function LazyMessageDetailPane(props) {
72
+ if (!props.message) {
73
+ return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Select a message to read it." });
74
+ }
75
+ return _jsx(messageDetailPane.Lazy, { ...props });
76
+ }
77
+ export function LazyConversationThreadPane(props) {
78
+ if (!props.conversation) {
79
+ return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Select a conversation to read it." });
80
+ }
81
+ return _jsx(conversationThreadPane.Lazy, { ...props });
82
+ }
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import { MailAddressLike } from "@rapidmx/react-shared/mail/mailAddress.js";
3
+ export interface MailAddressProps {
4
+ /** A message's `from`, a recipient, a conversation participant. */
5
+ recipient: MailAddressLike;
6
+ className?: string;
7
+ }
8
+ /**
9
+ * A sender or recipient as one compact line for a dense row: the name, when there is one, with the real address in muted
10
+ * type after it - `Jean-Philippe <jp@example.com>` - or the bare address without a name. The address is never left out,
11
+ * and when the row is too narrow for both the name gives way first (it shrinks a thousand times faster), then the
12
+ * address's own local part - the `@domain`, which is what says who really sent it, keeps its room to the end. The full
13
+ * `Name <address>` is the tooltip and, once, the accessible text (the two visible halves are hidden from assistive
14
+ * technology), so a screen reader hears one address, not fragments.
15
+ */
16
+ export default function MailAddress({ recipient, className }: MailAddressProps): React.JSX.Element;
17
+ /** How many recipients a `RecipientLine` shows before folding the rest behind "and N more". */
18
+ export declare const RECIPIENT_LINE_LIMIT = 3;
19
+ /**
20
+ * A `To`/`Cc` line of a message header: every recipient as `Name <address>` in the text itself (never hover-only), wrapping
21
+ * onto more lines as needed. A long list shows its first `RECIPIENT_LINE_LIMIT` and folds the rest behind a button -
22
+ * "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty.
23
+ */
24
+ export declare function RecipientLine({ label, recipients }: {
25
+ label: string;
26
+ recipients: MailAddressLike[];
27
+ }): React.JSX.Element | null;
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import React, { useState } from "react";
7
+ import { formatMailAddress, splitMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
8
+ /**
9
+ * A sender or recipient as one compact line for a dense row: the name, when there is one, with the real address in muted
10
+ * type after it - `Jean-Philippe <jp@example.com>` - or the bare address without a name. The address is never left out,
11
+ * and when the row is too narrow for both the name gives way first (it shrinks a thousand times faster), then the
12
+ * address's own local part - the `@domain`, which is what says who really sent it, keeps its room to the end. The full
13
+ * `Name <address>` is the tooltip and, once, the accessible text (the two visible halves are hidden from assistive
14
+ * technology), so a screen reader hears one address, not fragments.
15
+ */
16
+ export default function MailAddress({ recipient, className }) {
17
+ const { name, address } = splitMailAddress(recipient);
18
+ const full = formatMailAddress(recipient);
19
+ const at = address.lastIndexOf("@");
20
+ const local = at > 0 ? address.slice(0, at) : address;
21
+ const domain = at > 0 ? address.slice(at) : "";
22
+ return (_jsxs("span", { className: ["flex min-w-0 items-baseline gap-1.5", className].filter(Boolean).join(" "), title: full, children: [_jsx("span", { className: "sr-only", children: full }), name && (_jsx("span", { "aria-hidden": "true", className: "truncate min-w-0", style: { flexShrink: 1000 }, children: name })), _jsxs("span", { "aria-hidden": "true", className: ["flex min-w-0 items-baseline", name ? "text-xs font-normal text-text-muted" : ""].filter(Boolean).join(" "), children: [name && _jsx("span", { children: "<" }), _jsx("span", { className: "truncate min-w-0", children: local }), domain && _jsx("span", { className: "shrink-0", children: domain }), name && _jsx("span", { children: ">" })] })] }));
23
+ }
24
+ /** How many recipients a `RecipientLine` shows before folding the rest behind "and N more". */
25
+ export const RECIPIENT_LINE_LIMIT = 3;
26
+ /**
27
+ * A `To`/`Cc` line of a message header: every recipient as `Name <address>` in the text itself (never hover-only), wrapping
28
+ * onto more lines as needed. A long list shows its first `RECIPIENT_LINE_LIMIT` and folds the rest behind a button -
29
+ * "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty.
30
+ */
31
+ export function RecipientLine({ label, recipients }) {
32
+ const [expanded, setExpanded] = useState(false);
33
+ if (recipients.length === 0) {
34
+ return null;
35
+ }
36
+ const folded = recipients.length > RECIPIENT_LINE_LIMIT;
37
+ const shown = folded && !expanded ? recipients.slice(0, RECIPIENT_LINE_LIMIT) : recipients;
38
+ return (_jsxs("p", { className: "text-sm text-text-muted break-words", children: [_jsx("span", { children: label }), " ", shown.map((recipient, index) => (_jsxs(React.Fragment, { children: [index > 0 && ", ", _jsx("span", { children: formatMailAddress(recipient) })] }, index))), folded && (_jsxs(_Fragment, { children: [" ", _jsx("button", { type: "button", "aria-expanded": expanded, onClick: () => setExpanded((open) => !open), className: "text-xs font-semibold text-primary-dark underline hover:no-underline", children: expanded ? "Show fewer" : `and ${recipients.length - RECIPIENT_LINE_LIMIT} more` })] }))] }));
39
+ }
@@ -45,6 +45,9 @@ export interface MailSelectionBarProps {
45
45
  * `version`s the first has already superseded. */
46
46
  busy: boolean;
47
47
  error: string | null;
48
+ /** The keyboard acts on this selection (Delete, Ctrl+Q, Ctrl+U, Insert - registered by the page): Mark read, Mark unread, Flag and
49
+ * Delete name their shortcut in the tooltip and `aria-keyshortcuts`. */
50
+ shortcuts?: boolean;
48
51
  }
49
52
  /**
50
53
  * The header that replaces the list's own toolbar while select mode is on: how many rows are selected,
@@ -56,4 +59,4 @@ export interface MailSelectionBarProps {
56
59
  * target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
57
60
  * of actions doesn't shift around between folders.
58
61
  */
59
- export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, error, }: MailSelectionBarProps): React.JSX.Element;
62
+ export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, error, shortcuts, }: MailSelectionBarProps): React.JSX.Element;
@@ -7,6 +7,9 @@ import { useState } from "react";
7
7
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
8
8
  import LabelMenuButton from "./labelMenu.js";
9
9
  import MoveToFolderDialog, { MOVE_TARGET_TYPES } from "./MoveToFolderDialog.js";
10
+ import { ariaKeyShortcuts, withHint } from "../../keyboard/format.js";
11
+ import { SHORTCUTS } from "../../keyboard/keymap.js";
12
+ import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
10
13
  function actionClassName() {
11
14
  return "px-2 py-1 rounded-md text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent";
12
15
  }
@@ -20,7 +23,12 @@ function actionClassName() {
20
23
  * target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
21
24
  * of actions doesn't shift around between folders.
22
25
  */
23
- export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, error, }) {
26
+ export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, error, shortcuts, }) {
27
+ const env = useKeyEnvironment();
28
+ /** `title` and `aria-keyshortcuts` for an action the keyboard also does; `reason` (why it is disabled) wins the tooltip. */
29
+ const hint = (label, shortcut, reason) => shortcuts
30
+ ? { title: reason ?? withHint(label, shortcut, env), "aria-keyshortcuts": ariaKeyShortcuts(shortcut, env) }
31
+ : { title: reason };
24
32
  const selectedCount = totals?.selected ?? selected.length;
25
33
  const listedCount = totals?.listed ?? listed.length;
26
34
  // Counted on the rows that were ticked, but *emptied* on the messages: a ticked conversation whose
@@ -42,7 +50,7 @@ export default function MailSelectionBar({ selected, listed, totals, onSelectAll
42
50
  // partially applied, and is left exactly as it is unless the reader touches that row.
43
51
  const appliedToAll = labels.filter((label) => !none && selected.every((m) => m.labelUids?.includes(label.uid)));
44
52
  const appliedToSome = labels.filter((label) => selected.some((m) => m.labelUids?.includes(label.uid)) && !appliedToAll.includes(label));
45
- return (_jsxs("div", { className: "border-b border-border", children: [_jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: mailboxUid, folders: folders, currentFolderUid: currentFolderUid, count: selected.length, onMove: onMoveTo, onFolderCreated: onFolderCreated }), _jsxs("div", { className: "flex items-center gap-2 px-3 py-1.5 bg-surface-alt", children: [_jsxs("span", { "aria-live": "polite", className: "text-sm font-semibold text-text", children: [selectedCount, totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : "", " selected"] }), _jsx("button", { type: "button", onClick: onSelectAll, disabled: allSelected || listedCount === 0, className: actionClassName(), children: "Select all" }), _jsx("button", { type: "button", onClick: onClearSelection, disabled: selectedCount === 0, className: actionClassName(), children: "Clear" }), _jsx("button", { type: "button", onClick: onCancel, className: `${actionClassName()} ml-auto`, children: "Cancel" })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-0.5 px-1.5 py-1", children: [_jsx("button", { type: "button", onClick: () => onSetRead(true), disabled: none || busy, className: actionClassName(), children: "Mark read" }), _jsx("button", { type: "button", onClick: () => onSetRead(false), disabled: none || busy, className: actionClassName(), children: "Mark unread" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(true), disabled: none || busy, className: actionClassName(), children: "Flag" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(false), disabled: none || busy, className: actionClassName(), children: "Unflag" }), _jsx("button", { type: "button", onClick: onArchive, disabled: none || busy || !!archiveReason, title: archiveReason, className: actionClassName(), children: "Archive" }), _jsx(LabelMenuButton, { "aria-label": "Apply label", label: "Apply label", className: "text-sm", labels: labels, mailboxUid: mailboxUid, onLabelCreated: onLabelCreated, applied: appliedToAll.map((label) => label.uid), partial: appliedToSome.map((label) => label.uid), onCommit: onApplyLabels, busy: busy, disabled: none || busy, note: selected.length === 1
53
+ return (_jsxs("div", { className: "border-b border-border", children: [_jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: mailboxUid, folders: folders, currentFolderUid: currentFolderUid, count: selected.length, onMove: onMoveTo, onFolderCreated: onFolderCreated }), _jsxs("div", { className: "flex items-center gap-2 px-3 py-1.5 bg-surface-alt", children: [_jsxs("span", { "aria-live": "polite", className: "text-sm font-semibold text-text", children: [selectedCount, totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : "", " selected"] }), _jsx("button", { type: "button", onClick: onSelectAll, disabled: allSelected || listedCount === 0, className: actionClassName(), children: "Select all" }), _jsx("button", { type: "button", onClick: onClearSelection, disabled: selectedCount === 0, className: actionClassName(), children: "Clear" }), _jsx("button", { type: "button", onClick: onCancel, className: `${actionClassName()} ml-auto`, children: "Cancel" })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-0.5 px-1.5 py-1", children: [_jsx("button", { type: "button", onClick: () => onSetRead(true), disabled: none || busy, className: actionClassName(), ...hint("Mark read", SHORTCUTS.mail.markRead), children: "Mark read" }), _jsx("button", { type: "button", onClick: () => onSetRead(false), disabled: none || busy, className: actionClassName(), ...hint("Mark unread", SHORTCUTS.mail.markUnread), children: "Mark unread" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(true), disabled: none || busy, className: actionClassName(), ...hint("Flag", SHORTCUTS.mail.flag), children: "Flag" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(false), disabled: none || busy, className: actionClassName(), children: "Unflag" }), _jsx("button", { type: "button", onClick: onArchive, disabled: none || busy || !!archiveReason, title: archiveReason, className: actionClassName(), children: "Archive" }), _jsx(LabelMenuButton, { "aria-label": "Apply label", label: "Apply label", className: "text-sm", labels: labels, mailboxUid: mailboxUid, onLabelCreated: onLabelCreated, applied: appliedToAll.map((label) => label.uid), partial: appliedToSome.map((label) => label.uid), onCommit: onApplyLabels, busy: busy, disabled: none || busy, note: selected.length === 1
46
54
  ? "Ticked labels are applied, unticked ones removed."
47
- : "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.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }), _jsx("button", { type: "button", onClick: () => setMovePrompt(true), disabled: none || busy, className: actionClassName(), children: "Move to" }), _jsx("button", { type: "button", onClick: onReportJunk, disabled: none || busy || !!junkReason, title: junkReason, className: actionClassName(), children: "Report junk" }), _jsx("button", { type: "button", onClick: onDelete, disabled: none || busy || !!deleteReason, title: deleteReason, className: actionClassName(), children: "Delete" })] }), error && (_jsx("div", { className: "px-3 pb-2", children: _jsx(Alert, { children: error }) }))] }));
55
+ : "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.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }), _jsx("button", { type: "button", onClick: () => setMovePrompt(true), disabled: none || busy, className: actionClassName(), children: "Move to" }), _jsx("button", { type: "button", onClick: onReportJunk, disabled: none || busy || !!junkReason, title: junkReason, className: actionClassName(), children: "Report junk" }), _jsx("button", { type: "button", onClick: onDelete, disabled: none || busy || !!deleteReason, className: actionClassName(), ...hint("Delete", SHORTCUTS.mail.delete, deleteReason), children: "Delete" })] }), error && (_jsx("div", { className: "px-3 pb-2", children: _jsx(Alert, { children: error }) }))] }));
48
56
  }
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
  import { Attachment, Folder, Message } from "@rapidmx/react-shared/mail/mailApi.js";
3
3
  import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
4
- import { MessageSecurityResult } from "@rapidmx/react-shared/crypto/messageSecurity.js";
4
+ import type { MessageSecurityResult } from "@rapidmx/react-shared/crypto/messageSecurity.js";
5
5
  /** The detail line under a `verified_at_first_open` badge. */
6
6
  export declare function verifiedAtFirstOpenMessage(result: MessageSecurityResult): string;
7
7
  export interface SenderNameCheck {
@@ -90,6 +90,12 @@ export interface MessageDetailPaneProps {
90
90
  * subject becomes a heading under the thread's own, since a document has one `h1` and the thread's is
91
91
  * the conversation. Everything else - the badges, the actions, the body - is identical. */
92
92
  inThread?: boolean;
93
+ /**
94
+ * Registers this pane's keyboard shortcuts - Reply, Reply all, Forward, Archive, Move to - so they act on this message. Only the message
95
+ * the keyboard is acting on should set it: the one pane beside the list, or the opened message of a thread (each expanded message of a
96
+ * thread is a pane, and two of them must not both claim Ctrl+R). Also puts the shortcut in those buttons' tooltips.
97
+ */
98
+ shortcuts?: boolean;
93
99
  }
94
100
  /**
95
101
  * A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
@@ -11,7 +11,6 @@ import { buildForwardQuote, buildReplyQuote, buildReplyRecipients, buildReplyThr
11
11
  import { sanitizeMessageBodyHtml } from "@rapidmx/react-shared/mail/messageBodySanitizer.js";
12
12
  import MoveToFolderDialog from "./MoveToFolderDialog.js";
13
13
  import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
14
- import { evaluateMessageSecurity, evaluateMessageSecurityWithSeal, } from "@rapidmx/react-shared/crypto/messageSecurity.js";
15
14
  import { extractAddresses } from "@rapidmx/react-shared/crypto/mime.js";
16
15
  import { SignerKeyConflictError, signingKeyFingerprints, trustSigner } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
17
16
  import { isLikelyMailingList } from "@rapidmx/react-shared/crypto/composeSecurity.js";
@@ -20,10 +19,16 @@ import { currentVerificationSeal, getVaultGeneration, sendVerificationSeal } fro
20
19
  import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
21
20
  import KeyChangeReview from "../contacts/KeyChangeReview.js";
22
21
  import { KEY_CHANGE_STALE_MESSAGE, sameFingerprint } from "../contacts/contactKeys.js";
23
- import { useCompose } from "./compose/ComposeContext.js";
24
- import { loadOriginalMessage } from "./compose/quotedBody.js";
22
+ import { prefetchComposeWindow, useCompose } from "./compose/ComposeContext.js";
23
+ import { loadOriginalMessage, prefetchOriginalMessage } from "./compose/quotedBody.js";
25
24
  import { formatRecipient } from "./compose/recipients.js";
25
+ import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
26
+ import { RecipientLine } from "./MailAddress.js";
26
27
  import { useMailShell } from "./layout/MailShell.js";
28
+ import { ariaKeyShortcuts, withHint } from "../../keyboard/format.js";
29
+ import { SHORTCUTS } from "../../keyboard/keymap.js";
30
+ import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
31
+ import { useShortcut } from "../../keyboard/useShortcut.js";
27
32
  import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
28
33
  import { moveLocalEntity } from "../../search/localIndexRpcClient.js";
29
34
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
@@ -129,8 +134,9 @@ function downloadMimeAttachment(attachment) {
129
134
  * A plain `<button>` rather than react-shared's `Button`, whose padding and minimum width are sized for a
130
135
  * text label. Ordinary DOM order, so the keyboard reaches these in the order they are read.
131
136
  */
132
- function IconAction({ icon, label, onClick, disabled, busy, }) {
133
- return (_jsx("button", { type: "button", "aria-label": label, title: label, "aria-busy": busy || undefined, disabled: disabled, onClick: onClick, className: "inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent", children: icon }));
137
+ function IconAction({ icon, label, onClick, disabled, busy, onPrefetch, shortcut, }) {
138
+ const env = useKeyEnvironment();
139
+ return (_jsx("button", { type: "button", "aria-label": label, title: shortcut ? withHint(label, shortcut, env) : label, "aria-keyshortcuts": shortcut ? ariaKeyShortcuts(shortcut, env) : undefined, "aria-busy": busy || undefined, disabled: disabled, onClick: onClick, onPointerEnter: onPrefetch, onFocus: onPrefetch, className: "inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent", children: icon }));
134
140
  }
135
141
  function SecurityIndicator({ security }) {
136
142
  const { label, className } = SECURITY_INDICATOR[security.state];
@@ -193,7 +199,7 @@ export default function MessageDetailPane(props) {
193
199
  function needsRawSecurityEvaluation(message) {
194
200
  return !!message.encrypted || message.hasAttachments;
195
201
  }
196
- function MessageDetailContent({ message: messageProp, attachments, backHref, isSentItems, onRecalled, isOutbox, draftsFolderUid, onScheduledSendCanceled, folders, onMoved, onFolderCreated, onReceiptHandled, onArchived, labels, onLabelsChanged, onLabelCreated, inThread, }) {
202
+ function MessageDetailContent({ message: messageProp, attachments, backHref, isSentItems, onRecalled, isOutbox, draftsFolderUid, onScheduledSendCanceled, folders, onMoved, onFolderCreated, onReceiptHandled, onArchived, labels, onLabelsChanged, onLabelCreated, inThread, shortcuts, }) {
197
203
  // A copy re-read from the server after an Outbox action was refused (409/403) or a send lease ran out - see
198
204
  // `reloadMessage()`. A prop copy newer by `version` wins; an equal-version reload is kept, since claiming a send
199
205
  // lease need not bump `version`.
@@ -237,7 +243,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
237
243
  const [keyChangeNotice, setKeyChangeNotice] = useState(null);
238
244
  // "Now", for deciding whether a scheduled send's lease is still live - advanced when the lease runs out.
239
245
  const [nowMs, setNowMs] = useState(() => Date.now());
240
- const { mailboxes } = useMailShell();
246
+ const { mailboxes, trackMessageChange } = useMailShell();
241
247
  const { requestUnlock } = useUnlockPrompt();
242
248
  // Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
243
249
  // effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
@@ -291,6 +297,9 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
291
297
  if (cancelled) {
292
298
  return;
293
299
  }
300
+ // The S/MIME code (PKI.js and the ASN.1/X.509 libraries) loads here, only for a message that is signed or
301
+ // encrypted - it is over half a megabyte, and the pane opens plain messages without it.
302
+ const messageSecurity = await import("@rapidmx/react-shared/crypto/messageSecurity.js");
294
303
  const [primaryAddress, ...aliasAddresses] = readerAddressesKey ? readerAddressesKey.split(" ") : [];
295
304
  const ownAddresses = readerAddressesKey.toLowerCase().split(" ");
296
305
  const ownKeys = ownAddresses.includes(senderAddress.toLowerCase()) ? (readerMailbox.keys ?? []) : [];
@@ -308,7 +317,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
308
317
  ...(sealContext.keyState?.previous ?? []),
309
318
  ...ownKeys,
310
319
  ];
311
- result = await evaluateMessageSecurityWithSeal(rawMime, unlocked, pinned, primaryAddress, {
320
+ result = await messageSecurity.evaluateMessageSecurityWithSeal(rawMime, unlocked, pinned, primaryAddress, {
312
321
  mailboxUid: message.mailboxUid,
313
322
  messageUid: message.uid,
314
323
  ...currentVerificationSeal(message),
@@ -316,14 +325,14 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
316
325
  signerKeys,
317
326
  }).catch(() =>
318
327
  // Only a lock while sealing throws: evaluate as locked (the lock itself re-evaluates too).
319
- evaluateMessageSecurity(rawMime, undefined, pinned, primaryAddress));
328
+ messageSecurity.evaluateMessageSecurity(rawMime, undefined, pinned, primaryAddress));
320
329
  if (result.sealToWrite) {
321
330
  // Best effort and in the background: never awaited, never shown.
322
331
  void sendVerificationSeal(message.uid, result.sealToWrite);
323
332
  }
324
333
  }
325
334
  else {
326
- result = await evaluateMessageSecurity(rawMime, unlocked, pinned, primaryAddress);
335
+ result = await messageSecurity.evaluateMessageSecurity(rawMime, unlocked, pinned, primaryAddress);
327
336
  }
328
337
  // Only one reader address can be checked per evaluation: a message sent to one of this mailbox's
329
338
  // aliases isn't "not addressed to you", so each alias is tried before saying so.
@@ -331,7 +340,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
331
340
  if (!result.notAddressedToReader) {
332
341
  break;
333
342
  }
334
- const viaAlias = await evaluateMessageSecurity(rawMime, unlocked, pinned, alias);
343
+ const viaAlias = await messageSecurity.evaluateMessageSecurity(rawMime, unlocked, pinned, alias);
335
344
  result = { ...result, notAddressedToReader: viaAlias.notAddressedToReader };
336
345
  }
337
346
  const unpinned = contactPins.loaded && pins.length === 0;
@@ -440,46 +449,72 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
440
449
  const mailbox = readerMailbox ?? (await getMailbox(message.mailboxUid).catch(() => undefined));
441
450
  return mailbox ? [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])] : [];
442
451
  }
452
+ /** Starts fetching what Reply/Reply All/Forward will need (the compose window's code, the original's body) as the
453
+ * pointer or keyboard reaches one of their buttons, so the click itself finds it already on its way or here. */
454
+ function prefetchReply() {
455
+ prefetchComposeWindow();
456
+ prefetchOriginalMessage(message);
457
+ }
458
+ /**
459
+ * Opens the compose window at once from what is already in memory - the message's own subject and sender, and the
460
+ * mailbox it was read in - and fetches what needs the network meanwhile: the original's body to quote, and for Reply All
461
+ * the recipients its headers name. Both reach the window through `OpenComposeInput.pending` when they arrive, so the
462
+ * window is on screen on the click's own frame rather than after two or three sequential requests.
463
+ */
443
464
  async function handleReplyOrForward(kind) {
444
465
  setPreparingCompose(true);
445
466
  try {
446
- const [original, own] = await Promise.all([
447
- loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
448
- kind === "forward" ? [] : ownAddresses(),
449
- ]);
450
467
  const encrypt = !!message.encrypted;
451
468
  // What makes the new message part of this one's thread rather than a conversation of its own:
452
469
  // the server composes the MIME from the recipients, subject and HTML alone, so nothing else
453
470
  // recovers what is being replied to. A forward carries it for the same reason - it continues
454
471
  // the thread it came from, which is where its recipient will file the reply to it.
455
472
  const threading = buildReplyThreading(message);
473
+ const format = (list) => list.map((r) => formatRecipient(r)).join(", ");
474
+ // Known without a request when the shell has already listed the mailbox; otherwise fetched with the body, and
475
+ // the window's recipients are corrected by `late` below if that changes who they are.
476
+ const knownOwn = readerAddressesKey ? readerAddressesKey.split(" ") : undefined;
477
+ // Never rejects: loadOriginalMessage() and ownAddresses() don't, and the rest is pure.
478
+ const late = (async () => {
479
+ const [original, own] = await Promise.all([
480
+ loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
481
+ kind === "forward" ? [] : (knownOwn ?? ownAddresses()),
482
+ ]);
483
+ if (kind === "forward") {
484
+ return { quotedHtml: buildForwardQuote(message, original.body) };
485
+ }
486
+ // The message's own recipients are only the envelope recipient of a delivered message, so Reply All
487
+ // answers whoever its headers actually name, with anything the record holds that they don't.
488
+ const known = new Set((original.recipients ?? []).map((r) => r.address.trim().toLowerCase()));
489
+ const recipients = buildReplyRecipients({ ...message, recipients: [...(original.recipients ?? []), ...message.recipients.filter((r) => !known.has(r.address.trim().toLowerCase()))] }, own, kind === "replyAll");
490
+ return { quotedHtml: buildReplyQuote(message, original.body), to: format(recipients.to), cc: format(recipients.cc) };
491
+ })();
456
492
  if (kind === "forward") {
457
493
  openCompose({
458
494
  mailboxUid: message.mailboxUid,
459
495
  subject: forwardSubject(message.subject),
460
- quotedHtml: buildForwardQuote(message, original.body),
461
496
  signatureContext: "reply_forward",
462
497
  encrypt,
463
498
  threading,
499
+ pending: late,
464
500
  });
465
- return;
466
501
  }
467
- // The message's own recipients are only the envelope recipient of a delivered message, so Reply All
468
- // answers whoever its headers actually name, with anything the record holds that they don't.
469
- const known = new Set((original.recipients ?? []).map((r) => r.address.trim().toLowerCase()));
470
- const recipients = buildReplyRecipients({ ...message, recipients: [...(original.recipients ?? []), ...message.recipients.filter((r) => !known.has(r.address.trim().toLowerCase()))] }, own, kind === "replyAll");
471
- const format = (list) => list.map((r) => formatRecipient(r)).join(", ");
472
- openCompose({
473
- mailboxUid: message.mailboxUid,
474
- to: format(recipients.to),
475
- cc: recipients.cc.length > 0 ? format(recipients.cc) : undefined,
476
- subject: replySubject(message.subject),
477
- quotedHtml: buildReplyQuote(message, original.body),
478
- signatureContext: "reply_forward",
479
- suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
480
- encrypt,
481
- threading,
482
- });
502
+ else {
503
+ const first = buildReplyRecipients(message, knownOwn ?? [], kind === "replyAll");
504
+ openCompose({
505
+ mailboxUid: message.mailboxUid,
506
+ to: format(first.to),
507
+ cc: first.cc.length > 0 ? format(first.cc) : undefined,
508
+ subject: replySubject(message.subject),
509
+ signatureContext: "reply_forward",
510
+ suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
511
+ encrypt,
512
+ threading,
513
+ pending: late,
514
+ });
515
+ }
516
+ // Held until the quote is in, so a second click can't open a second window for the same message meanwhile.
517
+ await late;
483
518
  }
484
519
  finally {
485
520
  setPreparingCompose(false);
@@ -515,6 +550,8 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
515
550
  const updated = await cancelScheduledSend(message, draftsFolderUid);
516
551
  // Keeps a `folder:`-scoped Tier 2 local search from still finding it in its old folder.
517
552
  void moveLocalEntity(updated.mailboxUid, updated.uid, updated.folderUid);
553
+ // Out of Outbox, into Drafts: both folders' badges.
554
+ trackMessageChange(message, updated).settle();
518
555
  onScheduledSendCanceled?.(updated);
519
556
  }
520
557
  catch (err) {
@@ -539,6 +576,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
539
576
  try {
540
577
  const updated = await archiveMessage(message.uid);
541
578
  void moveLocalEntity(updated.mailboxUid, updated.uid, updated.folderUid);
579
+ trackMessageChange(message, updated).settle();
542
580
  onArchived?.(updated);
543
581
  }
544
582
  catch (err) {
@@ -583,8 +621,21 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
583
621
  */
584
622
  async function handleMove(folderUid) {
585
623
  const updated = await moveMessage(message, folderUid);
624
+ // Move to, Delete and Report junk are all this: the source folder's badge goes down, the target's up.
625
+ trackMessageChange(message, updated).settle();
586
626
  onMoved?.(updated);
587
627
  }
628
+ // Keyboard shortcuts for the message this pane shows. They call the same handlers as the buttons below, and exist only where the
629
+ // button does (Archive not for Drafts/Outbox, Move to only with folders to move to). A shortcut pressed while its action is already
630
+ // running is consumed and does nothing, exactly as the disabled button would: a browser must not act on Ctrl+R meanwhile.
631
+ const archivable = !inOutbox && message.folderUid !== draftsFolderUid;
632
+ const movable = !!folders && folders.length > 0;
633
+ const keyboard = !!shortcuts;
634
+ useShortcut(SHORTCUTS.mail.reply, () => void (!preparingCompose && handleReplyOrForward("reply")), { enabled: keyboard });
635
+ useShortcut(SHORTCUTS.mail.replyAll, () => void (!preparingCompose && handleReplyOrForward("replyAll")), { enabled: keyboard });
636
+ useShortcut(SHORTCUTS.mail.forward, () => void (!preparingCompose && handleReplyOrForward("forward")), { enabled: keyboard });
637
+ useShortcut(SHORTCUTS.mail.archive, () => void (!archiving && handleArchive()), { enabled: keyboard && archivable });
638
+ useShortcut(SHORTCUTS.mail.move, () => setMovePrompt(true), { enabled: keyboard && movable });
588
639
  // Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
589
640
  // is loaded — same real invariant as every other handler above.
590
641
  async function handleReceipt(type, action) {
@@ -627,8 +678,8 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
627
678
  const senderAddress = (signatureShown ? extractAddresses(security.protectedHeaders?.from)[0] : undefined) ?? message.from.address;
628
679
  const senderName = message.from.displayName;
629
680
  const senderNameCheck = checkSenderName(senderName, senderAddress);
630
- const showSenderAddress = !!senderName && (signatureShown || senderNameCheck.looksLikeAddress);
631
- const senderLabel = showSenderAddress ? `${senderName} <${senderAddress}>` : senderName || senderAddress;
681
+ // Always `Name <address>` (or the bare address): a name alone hides who a message is really from.
682
+ const senderLabel = formatMailAddress({ displayName: senderName, address: senderAddress });
632
683
  // Offered only for a valid signature from a certificate nobody pinned for this sender - never to replace a pin.
633
684
  // A recorded signing-key conflict for this sender is resolved from the contact, never by trusting another key.
634
685
  const pendingConflict = senderUnpinned && senderKeyState?.conflict !== undefined;
@@ -668,7 +719,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
668
719
  source: recordedConflict?.source,
669
720
  }, certificate: keyChanged.signerCertificate, canReject: recordedConflict !== undefined, canResolve: canUpdateMailbox, onResolved: (action) => refreshAfterKeyChange(action === "reject" ? KEPT_CURRENT_SIGNING_KEY_MESSAGE : null), onPinnedKeyChanged: () => refreshAfterKeyChange(KEY_CHANGE_STALE_MESSAGE) })] })), security?.state === "signature_failed" && !keyChanged && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: security.signatureFailureReason
670
721
  ? SIGNATURE_FAILURE_MESSAGE[security.signatureFailureReason]
671
- : GENERIC_SIGNATURE_FAILURE_MESSAGE })), verifiedOrSealed && !security.protectedHeaders && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The signature covers this message\u2019s content and attachments only. Its Subject, To and Cc weren\u2019t signed, so they could have been changed after it was sent." })), security?.notAddressedToReader && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The recipients this message was signed or encrypted for don\u2019t include this mailbox - it may have been forwarded or re-sent to you unchanged, or you were Bcc\u2019d." })), _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["From ", senderLabel, " \u00B7 ", new Date(message.receivedDate).toLocaleString()] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsxs("p", { className: "text-sm text-text-muted", children: ["To ", message.recipients.map((r) => r.displayName || r.address).join(", ")] }), _jsxs("div", { className: "flex flex-wrap gap-1 mt-3", children: [_jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true" }), label: "Reply", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply") }), _jsx(IconAction, { icon: _jsxs("span", { className: "inline-flex items-center", "aria-hidden": "true", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16 }), _jsx(HiOutlineArrowUturnLeft, { size: 16, className: "-ml-2.5" })] }), label: "Reply All", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll") }), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnRight, { size: 16, "aria-hidden": "true" }), label: "Forward", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward") }), !inOutbox && message.folderUid !== draftsFolderUid && (_jsx(IconAction, { icon: _jsx(HiOutlineArchiveBox, { size: 16, "aria-hidden": "true" }), label: "Archive", busy: archiving, disabled: archiving, onClick: handleArchive })), folders && folders.length > 0 && (_jsx(IconAction, { icon: _jsx(HiOutlineFolderArrowDown, { size: 16, "aria-hidden": "true" }), label: "Move to", onClick: () => setMovePrompt(true) })), labels && labels.length > 0 && (_jsx(LabelMenuButton, { "aria-label": "Labels", label: "Labels", className: "border border-border py-1.5", labels: labels, mailboxUid: message.mailboxUid, onLabelCreated: onLabelCreated, applied: currentLabelsMessage.labelUids ?? [], onCommit: (labelUids) => void handleApplyLabels(labelUids), busy: savingLabels, note: "Ticked labels are applied to this message and unticked ones removed.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }))] }), archiveError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: archiveError }) })), labelsError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: labelsError }) })), labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (_jsx("div", { className: "flex flex-wrap items-center gap-1.5 mt-2", children: currentLabelsMessage
722
+ : GENERIC_SIGNATURE_FAILURE_MESSAGE })), verifiedOrSealed && !security.protectedHeaders && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The signature covers this message\u2019s content and attachments only. Its Subject, To and Cc weren\u2019t signed, so they could have been changed after it was sent." })), security?.notAddressedToReader && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The recipients this message was signed or encrypted for don\u2019t include this mailbox - it may have been forwarded or re-sent to you unchanged, or you were Bcc\u2019d." })), _jsxs("p", { className: "text-sm text-text-muted mt-1 break-words", children: ["From ", _jsx("span", { className: "font-medium text-text", children: senderLabel }), " \u00B7", " ", new Date(message.receivedDate).toLocaleString()] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc") }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc") }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc") }), _jsxs("div", { className: "flex flex-wrap gap-1 mt-3", children: [_jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true" }), label: "Reply", shortcut: keyboard ? SHORTCUTS.mail.reply : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("reply") }), _jsx(IconAction, { icon: _jsxs("span", { className: "inline-flex items-center", "aria-hidden": "true", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16 }), _jsx(HiOutlineArrowUturnLeft, { size: 16, className: "-ml-2.5" })] }), label: "Reply All", shortcut: keyboard ? SHORTCUTS.mail.replyAll : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("replyAll") }), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnRight, { size: 16, "aria-hidden": "true" }), label: "Forward", shortcut: keyboard ? SHORTCUTS.mail.forward : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("forward") }), archivable && (_jsx(IconAction, { icon: _jsx(HiOutlineArchiveBox, { size: 16, "aria-hidden": "true" }), label: "Archive", shortcut: keyboard ? SHORTCUTS.mail.archive : undefined, busy: archiving, disabled: archiving, onClick: handleArchive })), movable && (_jsx(IconAction, { icon: _jsx(HiOutlineFolderArrowDown, { size: 16, "aria-hidden": "true" }), label: "Move to", shortcut: keyboard ? SHORTCUTS.mail.move : undefined, onClick: () => setMovePrompt(true) })), labels && labels.length > 0 && (_jsx(LabelMenuButton, { "aria-label": "Labels", label: "Labels", className: "border border-border py-1.5", labels: labels, mailboxUid: message.mailboxUid, onLabelCreated: onLabelCreated, applied: currentLabelsMessage.labelUids ?? [], onCommit: (labelUids) => void handleApplyLabels(labelUids), busy: savingLabels, note: "Ticked labels are applied to this message and unticked ones removed.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }))] }), archiveError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: archiveError }) })), labelsError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: labelsError }) })), labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (_jsx("div", { className: "flex flex-wrap items-center gap-1.5 mt-2", children: currentLabelsMessage
672
723
  .labelUids.map((uid) => labels.find((l) => l.uid === uid))
673
724
  .filter((l) => !!l)
674
725
  .map((l) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt", children: [_jsx("span", { className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }, l.uid))) })), ["delivery", "read"]
@@ -0,0 +1,19 @@
1
+ import React from "react";
2
+ import type { NewMailNotice } from "../../mail/newMailNotifications.js";
3
+ export interface NewMailToastsProps {
4
+ /** The pop-ups to show, oldest first. */
5
+ toasts: NewMailNotice[];
6
+ onDismiss: (uid: string) => void;
7
+ /** Show the "Turn on desktop notifications" offer in the first pop-up. */
8
+ offerDesktop: boolean;
9
+ onEnableDesktop: () => void;
10
+ onDeclineDesktop: () => void;
11
+ }
12
+ /**
13
+ * The new-mail pop-ups, stacked at the top right (`fixed`, above everything). The container is always rendered - a live region
14
+ * has to exist before its content changes to be announced - and is a polite `status` region, so an arrival is read out without
15
+ * interrupting. Each pop-up shows the sender's name and address, the subject and a short preview, as text; clicking it opens the
16
+ * message; it has a dismiss button and goes by itself after a few seconds (see `ToastItem`). Motion is CSS-only and switched off
17
+ * by `prefers-reduced-motion` (`.rr-toast-in` in `app.css`).
18
+ */
19
+ export default function NewMailToasts({ toasts, onDismiss, offerDesktop, onEnableDesktop, onDeclineDesktop }: NewMailToastsProps): React.JSX.Element;
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { HiOutlineXMark } from "react-icons/hi2";
8
+ import { TOAST_DURATION_MS } from "../../mail/useNewMailNotifications.js";
9
+ /** Whether the tab is out of view - a pop-up's clock stops meanwhile, so it is still there when the reader comes back. */
10
+ function useTabHidden() {
11
+ const [hidden, setHidden] = useState(false);
12
+ useEffect(() => {
13
+ const update = () => setHidden(document.visibilityState === "hidden");
14
+ update();
15
+ document.addEventListener("visibilitychange", update);
16
+ return () => document.removeEventListener("visibilitychange", update);
17
+ }, []);
18
+ return hidden;
19
+ }
20
+ /** One pop-up. Its 8 seconds only run while it is neither hovered nor holding keyboard focus, and the tab is in view. */
21
+ function ToastItem({ toast, tabHidden, onDismiss, offer }) {
22
+ const [hovered, setHovered] = useState(false);
23
+ const [focused, setFocused] = useState(false);
24
+ const remainingRef = useRef(TOAST_DURATION_MS);
25
+ const paused = tabHidden || hovered || focused;
26
+ useEffect(() => {
27
+ if (paused) {
28
+ return;
29
+ }
30
+ const startedAt = Date.now();
31
+ const timer = setTimeout(() => onDismiss(toast.uid), remainingRef.current);
32
+ return () => {
33
+ clearTimeout(timer);
34
+ remainingRef.current = Math.max(0, remainingRef.current - (Date.now() - startedAt));
35
+ };
36
+ }, [paused, toast.uid, onDismiss]);
37
+ const sender = toast.senderName || toast.senderAddress;
38
+ return (_jsxs("div", { "data-testid": "new-mail-toast", onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), onFocus: () => setFocused(true), onBlur: (event) => {
39
+ // Focus moving between this pop-up's own controls is not leaving it.
40
+ if (!event.currentTarget.contains(event.relatedTarget)) {
41
+ setFocused(false);
42
+ }
43
+ }, className: "rr-toast-in pointer-events-auto relative rounded-md border border-border border-l-4 border-l-primary bg-surface text-text shadow-modal", children: [_jsxs("a", { href: toast.href, onClick: () => onDismiss(toast.uid), className: "block rounded-md p-3 pr-10 hover:bg-surface-alt focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary", children: [_jsx("span", { className: "block text-xs font-semibold uppercase tracking-wide text-primary-dark", children: "New message" }), _jsxs("span", { className: "mt-0.5 flex min-w-0 items-baseline gap-1.5 text-sm", children: [_jsx("span", { className: "truncate font-bold", children: sender }), toast.senderName && _jsxs("span", { className: "truncate text-xs text-text-muted", children: ["<", toast.senderAddress, ">"] })] }), _jsx("span", { className: "block truncate text-sm font-semibold", children: toast.subject }), toast.preview && _jsx("span", { className: "mt-0.5 block text-xs text-text-muted [overflow-wrap:anywhere]", children: toast.preview })] }), _jsx("button", { type: "button", onClick: () => onDismiss(toast.uid), "aria-label": `Dismiss notification: ${toast.subject}`, className: "absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text focus-visible:outline-2 focus-visible:outline-primary", children: _jsx(HiOutlineXMark, { size: 16, "aria-hidden": "true" }) }), offer && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 border-t border-border px-3 py-2 text-xs text-text-muted", children: [_jsx("span", { className: "basis-full", children: "Get a desktop notification when mail arrives while this tab is in the background." }), _jsx("button", { type: "button", onClick: offer.onEnable, className: "rounded-sm bg-primary px-2.5 py-1 text-xs font-semibold text-text-on-primary hover:bg-primary-dark", children: "Turn on desktop notifications" }), _jsx("button", { type: "button", onClick: offer.onDecline, className: "rounded-sm px-2 py-1 text-xs font-medium hover:bg-surface-alt hover:text-text", children: "Not now" })] }))] }));
44
+ }
45
+ /**
46
+ * The new-mail pop-ups, stacked at the top right (`fixed`, above everything). The container is always rendered - a live region
47
+ * has to exist before its content changes to be announced - and is a polite `status` region, so an arrival is read out without
48
+ * interrupting. Each pop-up shows the sender's name and address, the subject and a short preview, as text; clicking it opens the
49
+ * message; it has a dismiss button and goes by itself after a few seconds (see `ToastItem`). Motion is CSS-only and switched off
50
+ * by `prefers-reduced-motion` (`.rr-toast-in` in `app.css`).
51
+ */
52
+ export default function NewMailToasts({ toasts, onDismiss, offerDesktop, onEnableDesktop, onDeclineDesktop }) {
53
+ const tabHidden = useTabHidden();
54
+ return (_jsx("div", { role: "status", "aria-live": "polite", "aria-label": "New mail", "data-testid": "new-mail-toasts", className: "pointer-events-none fixed right-4 top-4 z-50 flex w-[22rem] max-w-[calc(100vw-2rem)] flex-col gap-2", children: toasts.map((toast, index) => (_jsx(ToastItem, { toast: toast, tabHidden: tabHidden, onDismiss: onDismiss, offer: offerDesktop && index === 0 ? { onEnable: onEnableDesktop, onDecline: onDeclineDesktop } : undefined }, toast.uid))) }));
55
+ }