@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
@@ -5,11 +5,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { listAttachments, setMessageRead } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import { listAttachments } from "@rapidmx/react-shared/mail/mailApi.js";
9
9
  import { listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
10
10
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
11
  import MailAddress from "./MailAddress.js";
12
12
  import MessageDetailPane from "./MessageDetailPane.js";
13
+ import { useMailShell } from "./layout/MailShell.js";
14
+ import { setReadState } from "../../mail/messageReadState.js";
15
+ import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, senderClass } from "./unreadStyle.js";
13
16
  /** One request's worth of the thread. The server's own default for `listConversationMessages()`. */
14
17
  export const THREAD_PAGE_SIZE = 100;
15
18
  /**
@@ -73,6 +76,15 @@ async function loadThread(mailboxUid, conversationId) {
73
76
  messages.reverse();
74
77
  return { messages, truncated: more };
75
78
  }
79
+ /** Whether the browser is showing a focus ring on `element` - it was focused from the keyboard (or by a key press's handler), not by a click. */
80
+ function hasFocusRing(element) {
81
+ try {
82
+ return element.matches(":focus-visible");
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
76
88
  /**
77
89
  * The reading pane for the conversation list: the whole thread, **newest at the top**, opened at the message
78
90
  * the reader picked. Every message from that one through to the newest is expanded - which in this order is
@@ -90,7 +102,8 @@ async function loadThread(mailboxUid, conversationId) {
90
102
  * single-message pane, and each acts on the message it belongs to. A collapsed message mounts none of
91
103
  * that - mounting a body iframe per message up front would be wasteful in a long thread.
92
104
  */
93
- export default function ConversationThreadPane({ conversation, mailboxUid, selectedUid, folders, labels, onMessagePatched, onMessageRemoved, onLabelCreated, onFolderCreated, }) {
105
+ export default function ConversationThreadPane({ conversation, mailboxUid, selectedUid, folders, labels, onMessagePatched, onMessageRemoved, onLabelCreated, onFolderCreated, shortcuts, }) {
106
+ const { trackMessageChange } = useMailShell();
94
107
  const [messages, setMessages] = useState([]);
95
108
  const [attachmentsByUid, setAttachmentsByUid] = useState({});
96
109
  const [expandedUids, setExpandedUids] = useState(new Set());
@@ -192,8 +205,14 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
192
205
  scroller.scrollTop += top;
193
206
  }
194
207
  }
195
- // `preventScroll` so focusing doesn't scroll it somewhere else again.
196
- headerRefs.current[pendingFocusUid].focus({ preventScroll: true });
208
+ // A key press (j, k, the arrows) that opened this thread left the focus on its list row on purpose, so Enter goes on to open the
209
+ // message's page; taking the focus here would make Enter toggle this header instead. A click leaves no focus ring, and hands
210
+ // the focus to the thread as before.
211
+ const active = document.activeElement;
212
+ if (!(active instanceof HTMLElement && active.hasAttribute("data-row-open") && hasFocusRing(active))) {
213
+ // `preventScroll` so focusing doesn't scroll it somewhere else again.
214
+ headerRefs.current[pendingFocusUid].focus({ preventScroll: true });
215
+ }
197
216
  setPendingFocusUid(null);
198
217
  }, [pendingFocusUid, expandedUids]);
199
218
  // Keeps the message whose header was just clicked where it was on screen. Expanding one above the
@@ -230,20 +249,25 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
230
249
  attachmentsRequestedRef.current.delete(uid);
231
250
  });
232
251
  }
233
- if (!message.flags.read && !markReadRequestedRef.current.has(uid)) {
252
+ if (!markReadRequestedRef.current.has(uid)) {
253
+ // Asked once per opened conversation and per expanding of the message: a failure is not retried from here, because
254
+ // undoing the optimistic change changes `messages`, which would run this effect again and ask again, for ever. A message
255
+ // that is already read is asked about too (there is nothing to send), so marking it unread while it is open - the
256
+ // keyboard's Ctrl+U - doesn't make this run again and read it straight back.
234
257
  markReadRequestedRef.current.add(uid);
235
- // `message` is this render's copy, so the request carries its current `version`.
236
- setMessageRead(message, true)
237
- .then((updated) => {
238
- if (generation !== generationRef.current)
239
- return;
240
- // `message` is the unread copy this pane just replaced - what tells the list's own
241
- // conversation row that its unread count has gone down by one.
242
- patchMessage(updated, message);
243
- })
244
- .catch(() => {
245
- // Best-effort, as in `useMarkMessageRead`.
246
- markReadRequestedRef.current.delete(uid);
258
+ if (message.flags.read === true) {
259
+ continue;
260
+ }
261
+ // `message` is this render's copy, so the request carries its current `version`. The row, the conversation row's
262
+ // unread count and the folder badge all change at once; `previous` is what tells the list's conversation row
263
+ // that its count moved (see `setReadState()`).
264
+ void setReadState(message, true, {
265
+ patch: (updated, previous) => {
266
+ if (generation === generationRef.current) {
267
+ patchMessage(updated, previous);
268
+ }
269
+ },
270
+ track: trackMessageChange,
247
271
  });
248
272
  }
249
273
  }
@@ -260,6 +284,8 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
260
284
  onMessageRemoved(updated);
261
285
  }
262
286
  function toggleExpanded(uid) {
287
+ // Opening a message again asks to mark it read again - that is how one whose request failed is retried.
288
+ markReadRequestedRef.current.delete(uid);
263
289
  // The row this button lives in has rendered, so its ref is set.
264
290
  anchorRef.current = { uid, top: rowRefs.current[uid].getBoundingClientRect().top };
265
291
  setExpandedUids((prev) => {
@@ -273,6 +299,9 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
273
299
  return next;
274
300
  });
275
301
  }
302
+ // The keyboard acts on the message that was opened - or the newest, when the opened one isn't in this thread - never on all the expanded
303
+ // ones at once (each is a `MessageDetailPane`, and two of them must not both answer Ctrl+R).
304
+ const keyboardUid = messages.some((message) => message.uid === selectedUid) ? selectedUid : messages[0]?.uid;
276
305
  function folderTypeOf(message) {
277
306
  return folders.find((folder) => folder.uid === message.folderUid)?.type;
278
307
  }
@@ -289,19 +318,15 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
289
318
  const uid = message.uid;
290
319
  const expanded = expandedUids.has(uid);
291
320
  const bodyId = `thread-message-${uid}`;
321
+ const messageUnread = isUnread(message);
292
322
  return (_jsxs("li", { ref: (node) => {
293
323
  rowRefs.current[uid] = node;
294
- },
295
- // An expanded message is as tall as the list it scrolls in - `min-h-full`
296
- // against the `<ul>`'s own resolved height - so its `MessageDetailPane` below
297
- // has a full pane of height to fill and its body reaches the bottom of the
298
- // window whatever that window's size is. `min-` rather than `h-`: a message
299
- // whose header alone is taller than the pane still gets the room it needs.
300
- className: ["border-b border-border", expanded ? "flex flex-col min-h-full" : ""].join(" "), children: [_jsx("h2", { className: "shrink-0", children: _jsxs("button", { type: "button", ref: (node) => {
324
+ }, "data-unread": messageUnread ? "true" : undefined, className: ["relative border-b border-border", expanded ? "flex flex-col min-h-full" : ""].join(" "), children: [_jsx(UnreadBar, { unread: messageUnread }), _jsx("h2", { className: "shrink-0", children: _jsxs("button", { type: "button", ref: (node) => {
301
325
  headerRefs.current[uid] = node;
302
326
  }, onClick: () => toggleExpanded(uid), "aria-expanded": expanded, "aria-controls": bodyId, className: [
303
- "w-full text-left px-4 py-3 hover:bg-surface-alt",
304
- message.flags.read ? "" : "font-semibold",
305
- ].join(" "), children: [_jsxs("span", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleString() })] }), !expanded && (_jsx("span", { className: "block text-xs text-text-muted truncate font-normal", children: message.bodyPreview }))] }) }), _jsx("div", { id: bodyId, hidden: !expanded, className: expanded ? "flex-1 min-h-0 flex" : undefined, children: expanded && (_jsx(MessageDetailPane, { inThread: true, message: message, attachments: attachmentsByUid[uid] ?? [], isSentItems: folderTypeOf(message) === "sent_items", isOutbox: folderTypeOf(message) === "outbox", draftsFolderUid: folders.find((folder) => folder.type === "drafts")?.uid, folders: folders, onMoved: removeMessage, onFolderCreated: onFolderCreated, onRecalled: patchMessage, onReceiptHandled: patchMessage, onScheduledSendCanceled: removeMessage, onArchived: removeMessage, labels: labels, onLabelsChanged: patchMessage, onLabelCreated: onLabelCreated })) })] }, uid));
327
+ "w-full text-left px-4 py-3",
328
+ messageUnread ? "bg-primary/[0.07] hover:bg-primary/10" : "hover:bg-surface-alt",
329
+ ROW_FOCUS_CLASS,
330
+ ].join(" "), children: [_jsx(UnreadLabel, { unread: messageUnread }), _jsxs("span", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(messageUnread) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(messageUnread)].join(" "), children: new Date(message.receivedDate).toLocaleString() })] }), !expanded && (_jsx("span", { className: "block text-xs text-text-muted truncate font-normal", children: message.bodyPreview }))] }) }), _jsx("div", { id: bodyId, hidden: !expanded, className: expanded ? "flex-1 min-h-0 flex" : undefined, children: expanded && (_jsx(MessageDetailPane, { inThread: true, shortcuts: !!shortcuts && uid === keyboardUid, message: message, attachments: attachmentsByUid[uid] ?? [], isSentItems: folderTypeOf(message) === "sent_items", isOutbox: folderTypeOf(message) === "outbox", draftsFolderUid: folders.find((folder) => folder.type === "drafts")?.uid, folders: folders, onMoved: removeMessage, onFolderCreated: onFolderCreated, onRecalled: patchMessage, onReceiptHandled: patchMessage, onScheduledSendCanceled: removeMessage, onArchived: removeMessage, labels: labels, onLabelsChanged: patchMessage, onLabelCreated: onLabelCreated })) })] }, uid));
306
331
  }) })] }));
307
332
  }
@@ -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
+ }
@@ -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,12 +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";
26
25
  import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
27
26
  import { RecipientLine } from "./MailAddress.js";
28
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";
29
32
  import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
30
33
  import { moveLocalEntity } from "../../search/localIndexRpcClient.js";
31
34
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
@@ -131,8 +134,9 @@ function downloadMimeAttachment(attachment) {
131
134
  * A plain `<button>` rather than react-shared's `Button`, whose padding and minimum width are sized for a
132
135
  * text label. Ordinary DOM order, so the keyboard reaches these in the order they are read.
133
136
  */
134
- function IconAction({ icon, label, onClick, disabled, busy, }) {
135
- 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 }));
136
140
  }
137
141
  function SecurityIndicator({ security }) {
138
142
  const { label, className } = SECURITY_INDICATOR[security.state];
@@ -195,7 +199,7 @@ export default function MessageDetailPane(props) {
195
199
  function needsRawSecurityEvaluation(message) {
196
200
  return !!message.encrypted || message.hasAttachments;
197
201
  }
198
- 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, }) {
199
203
  // A copy re-read from the server after an Outbox action was refused (409/403) or a send lease ran out - see
200
204
  // `reloadMessage()`. A prop copy newer by `version` wins; an equal-version reload is kept, since claiming a send
201
205
  // lease need not bump `version`.
@@ -239,7 +243,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
239
243
  const [keyChangeNotice, setKeyChangeNotice] = useState(null);
240
244
  // "Now", for deciding whether a scheduled send's lease is still live - advanced when the lease runs out.
241
245
  const [nowMs, setNowMs] = useState(() => Date.now());
242
- const { mailboxes } = useMailShell();
246
+ const { mailboxes, trackMessageChange } = useMailShell();
243
247
  const { requestUnlock } = useUnlockPrompt();
244
248
  // Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
245
249
  // effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
@@ -293,6 +297,9 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
293
297
  if (cancelled) {
294
298
  return;
295
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");
296
303
  const [primaryAddress, ...aliasAddresses] = readerAddressesKey ? readerAddressesKey.split(" ") : [];
297
304
  const ownAddresses = readerAddressesKey.toLowerCase().split(" ");
298
305
  const ownKeys = ownAddresses.includes(senderAddress.toLowerCase()) ? (readerMailbox.keys ?? []) : [];
@@ -310,7 +317,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
310
317
  ...(sealContext.keyState?.previous ?? []),
311
318
  ...ownKeys,
312
319
  ];
313
- result = await evaluateMessageSecurityWithSeal(rawMime, unlocked, pinned, primaryAddress, {
320
+ result = await messageSecurity.evaluateMessageSecurityWithSeal(rawMime, unlocked, pinned, primaryAddress, {
314
321
  mailboxUid: message.mailboxUid,
315
322
  messageUid: message.uid,
316
323
  ...currentVerificationSeal(message),
@@ -318,14 +325,14 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
318
325
  signerKeys,
319
326
  }).catch(() =>
320
327
  // Only a lock while sealing throws: evaluate as locked (the lock itself re-evaluates too).
321
- evaluateMessageSecurity(rawMime, undefined, pinned, primaryAddress));
328
+ messageSecurity.evaluateMessageSecurity(rawMime, undefined, pinned, primaryAddress));
322
329
  if (result.sealToWrite) {
323
330
  // Best effort and in the background: never awaited, never shown.
324
331
  void sendVerificationSeal(message.uid, result.sealToWrite);
325
332
  }
326
333
  }
327
334
  else {
328
- result = await evaluateMessageSecurity(rawMime, unlocked, pinned, primaryAddress);
335
+ result = await messageSecurity.evaluateMessageSecurity(rawMime, unlocked, pinned, primaryAddress);
329
336
  }
330
337
  // Only one reader address can be checked per evaluation: a message sent to one of this mailbox's
331
338
  // aliases isn't "not addressed to you", so each alias is tried before saying so.
@@ -333,7 +340,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
333
340
  if (!result.notAddressedToReader) {
334
341
  break;
335
342
  }
336
- const viaAlias = await evaluateMessageSecurity(rawMime, unlocked, pinned, alias);
343
+ const viaAlias = await messageSecurity.evaluateMessageSecurity(rawMime, unlocked, pinned, alias);
337
344
  result = { ...result, notAddressedToReader: viaAlias.notAddressedToReader };
338
345
  }
339
346
  const unpinned = contactPins.loaded && pins.length === 0;
@@ -442,46 +449,72 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
442
449
  const mailbox = readerMailbox ?? (await getMailbox(message.mailboxUid).catch(() => undefined));
443
450
  return mailbox ? [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])] : [];
444
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
+ */
445
464
  async function handleReplyOrForward(kind) {
446
465
  setPreparingCompose(true);
447
466
  try {
448
- const [original, own] = await Promise.all([
449
- loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
450
- kind === "forward" ? [] : ownAddresses(),
451
- ]);
452
467
  const encrypt = !!message.encrypted;
453
468
  // What makes the new message part of this one's thread rather than a conversation of its own:
454
469
  // the server composes the MIME from the recipients, subject and HTML alone, so nothing else
455
470
  // recovers what is being replied to. A forward carries it for the same reason - it continues
456
471
  // the thread it came from, which is where its recipient will file the reply to it.
457
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
+ })();
458
492
  if (kind === "forward") {
459
493
  openCompose({
460
494
  mailboxUid: message.mailboxUid,
461
495
  subject: forwardSubject(message.subject),
462
- quotedHtml: buildForwardQuote(message, original.body),
463
496
  signatureContext: "reply_forward",
464
497
  encrypt,
465
498
  threading,
499
+ pending: late,
466
500
  });
467
- return;
468
501
  }
469
- // The message's own recipients are only the envelope recipient of a delivered message, so Reply All
470
- // answers whoever its headers actually name, with anything the record holds that they don't.
471
- const known = new Set((original.recipients ?? []).map((r) => r.address.trim().toLowerCase()));
472
- const recipients = buildReplyRecipients({ ...message, recipients: [...(original.recipients ?? []), ...message.recipients.filter((r) => !known.has(r.address.trim().toLowerCase()))] }, own, kind === "replyAll");
473
- const format = (list) => list.map((r) => formatRecipient(r)).join(", ");
474
- openCompose({
475
- mailboxUid: message.mailboxUid,
476
- to: format(recipients.to),
477
- cc: recipients.cc.length > 0 ? format(recipients.cc) : undefined,
478
- subject: replySubject(message.subject),
479
- quotedHtml: buildReplyQuote(message, original.body),
480
- signatureContext: "reply_forward",
481
- suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
482
- encrypt,
483
- threading,
484
- });
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;
485
518
  }
486
519
  finally {
487
520
  setPreparingCompose(false);
@@ -517,6 +550,8 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
517
550
  const updated = await cancelScheduledSend(message, draftsFolderUid);
518
551
  // Keeps a `folder:`-scoped Tier 2 local search from still finding it in its old folder.
519
552
  void moveLocalEntity(updated.mailboxUid, updated.uid, updated.folderUid);
553
+ // Out of Outbox, into Drafts: both folders' badges.
554
+ trackMessageChange(message, updated).settle();
520
555
  onScheduledSendCanceled?.(updated);
521
556
  }
522
557
  catch (err) {
@@ -541,6 +576,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
541
576
  try {
542
577
  const updated = await archiveMessage(message.uid);
543
578
  void moveLocalEntity(updated.mailboxUid, updated.uid, updated.folderUid);
579
+ trackMessageChange(message, updated).settle();
544
580
  onArchived?.(updated);
545
581
  }
546
582
  catch (err) {
@@ -585,8 +621,21 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
585
621
  */
586
622
  async function handleMove(folderUid) {
587
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();
588
626
  onMoved?.(updated);
589
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 });
590
639
  // Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
591
640
  // is loaded — same real invariant as every other handler above.
592
641
  async function handleReceipt(type, action) {
@@ -670,7 +719,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
670
719
  source: recordedConflict?.source,
671
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
672
721
  ? SIGNATURE_FAILURE_MESSAGE[security.signatureFailureReason]
673
- : 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", 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
674
723
  .labelUids.map((uid) => labels.find((l) => l.uid === uid))
675
724
  .filter((l) => !!l)
676
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;