@rapidmx/web-client 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +192 -88
  2. package/apps/shared/auth/accountUrl.ts +9 -0
  3. package/apps/shared/auth/adminAccess.ts +99 -0
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +2 -1
  5. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  6. package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
  7. package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
  8. package/apps/shared/components/layout/AppShell.tsx +411 -288
  9. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  10. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
  11. package/apps/shared/components/layout/UserMenu.tsx +303 -185
  12. package/apps/shared/components/mail/ConversationList.tsx +269 -262
  13. package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
  14. package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
  15. package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
  16. package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1387
  17. package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
  18. package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
  19. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  20. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1736
  21. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  22. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  23. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  24. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  25. package/apps/shared/components/mail/layout/MailShell.tsx +476 -472
  26. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  27. package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
  28. package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
  29. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  30. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  31. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  32. package/apps/shared/keyboard/dispatch.ts +124 -0
  33. package/apps/shared/keyboard/format.ts +89 -0
  34. package/apps/shared/keyboard/keymap.ts +114 -0
  35. package/apps/shared/keyboard/match.ts +44 -0
  36. package/apps/shared/keyboard/parse.ts +136 -0
  37. package/apps/shared/keyboard/platform.ts +34 -0
  38. package/apps/shared/keyboard/registry.ts +65 -0
  39. package/apps/shared/keyboard/targets.ts +79 -0
  40. package/apps/shared/keyboard/useShortcut.ts +50 -0
  41. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  42. package/apps/shared/mail/folderCounts.ts +302 -0
  43. package/apps/shared/mail/listSnapshots.ts +87 -0
  44. package/apps/shared/mail/messageReadState.ts +85 -0
  45. package/apps/shared/mail/newMailNotifications.ts +183 -0
  46. package/apps/shared/mail/useMailConnection.ts +150 -0
  47. package/apps/shared/mail/useMailLiveUpdates.ts +232 -224
  48. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  49. package/apps/shared/mail/useNewMailNotifications.ts +163 -0
  50. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  51. package/apps/shared/navigation/AppRouter.tsx +300 -0
  52. package/apps/shared/navigation/appHrefs.ts +23 -0
  53. package/apps/shared/navigation/frameContext.tsx +35 -0
  54. package/apps/shared/navigation/idle.ts +45 -0
  55. package/apps/shared/navigation/routerContext.tsx +83 -0
  56. package/apps/shared/navigation/routes.ts +72 -0
  57. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  58. package/apps/shared/styles/app.css +28 -10
  59. package/apps/www/_routedPage.tsx +24 -0
  60. package/apps/www/_routes.ts +35 -0
  61. package/apps/www/calendar/index.tsx +55 -19
  62. package/apps/www/contacts/[uid].tsx +112 -107
  63. package/apps/www/contacts/index.tsx +584 -567
  64. package/apps/www/index.tsx +2280 -1917
  65. package/apps/www/messages/[uid].tsx +106 -101
  66. package/apps/www/settings/auto-reply/index.tsx +4 -1
  67. package/apps/www/settings/encryption/index.tsx +1252 -1249
  68. package/apps/www/settings/filters/[uid].tsx +4 -1
  69. package/apps/www/settings/filters/index.tsx +102 -99
  70. package/apps/www/settings/filters/new/index.tsx +138 -133
  71. package/apps/www/settings/labels/index.tsx +204 -201
  72. package/apps/www/settings/privacy/index.tsx +4 -1
  73. package/apps/www/settings/read-receipts/index.tsx +4 -1
  74. package/apps/www/settings/sharing/index.tsx +277 -274
  75. package/apps/www/settings/signatures/[uid].tsx +170 -167
  76. package/apps/www/settings/signatures/index.tsx +88 -85
  77. package/apps/www/settings/signatures/new/index.tsx +134 -129
  78. package/apps/www/tasks/index.tsx +18 -2
  79. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  80. package/dist/apps/shared/auth/accountUrl.js +8 -0
  81. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  82. package/dist/apps/shared/auth/adminAccess.js +89 -0
  83. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
  84. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  85. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  86. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  87. package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
  88. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  89. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
  90. package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
  91. package/dist/apps/shared/components/layout/AppShell.js +73 -14
  92. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  93. package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
  94. package/dist/apps/shared/components/layout/UserMenu.d.ts +19 -3
  95. package/dist/apps/shared/components/layout/UserMenu.js +52 -5
  96. package/dist/apps/shared/components/mail/ConversationList.js +4 -11
  97. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  98. package/dist/apps/shared/components/mail/ConversationThreadPane.js +52 -27
  99. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  100. package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
  101. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
  102. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
  103. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
  104. package/dist/apps/shared/components/mail/MessageDetailPane.js +83 -34
  105. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
  106. package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
  107. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
  108. package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
  109. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  110. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
  111. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +98 -22
  112. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  113. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  114. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  115. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  116. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  117. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  118. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  119. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  120. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +12 -4
  121. package/dist/apps/shared/components/mail/layout/MailShell.js +76 -96
  122. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  123. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  124. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  125. package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
  126. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  127. package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
  128. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  129. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  130. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  131. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  132. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  133. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  134. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  135. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  136. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  137. package/dist/apps/shared/keyboard/format.js +74 -0
  138. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  139. package/dist/apps/shared/keyboard/keymap.js +84 -0
  140. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  141. package/dist/apps/shared/keyboard/match.js +31 -0
  142. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  143. package/dist/apps/shared/keyboard/parse.js +107 -0
  144. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  145. package/dist/apps/shared/keyboard/platform.js +22 -0
  146. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  147. package/dist/apps/shared/keyboard/registry.js +33 -0
  148. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  149. package/dist/apps/shared/keyboard/targets.js +67 -0
  150. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  151. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  152. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  153. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  154. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  155. package/dist/apps/shared/mail/folderCounts.js +212 -0
  156. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  157. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  158. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  159. package/dist/apps/shared/mail/messageReadState.js +63 -0
  160. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  161. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  162. package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
  163. package/dist/apps/shared/mail/useMailConnection.js +96 -0
  164. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +13 -6
  165. package/dist/apps/shared/mail/useMailLiveUpdates.js +28 -26
  166. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  167. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  168. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
  169. package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
  170. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  171. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  172. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  173. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  174. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  175. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  176. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  177. package/dist/apps/shared/navigation/frameContext.js +24 -0
  178. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  179. package/dist/apps/shared/navigation/idle.js +43 -0
  180. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  181. package/dist/apps/shared/navigation/routerContext.js +56 -0
  182. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  183. package/dist/apps/shared/navigation/routes.js +37 -0
  184. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  185. package/dist/apps/shared/styles/app.css +28 -10
  186. package/dist/apps/www/_routedPage.d.ts +12 -0
  187. package/dist/apps/www/_routedPage.js +19 -0
  188. package/dist/apps/www/_routes.d.ts +11 -0
  189. package/dist/apps/www/_routes.js +29 -0
  190. package/dist/apps/www/calendar/index.d.ts +2 -2
  191. package/dist/apps/www/calendar/index.js +39 -8
  192. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  193. package/dist/apps/www/contacts/[uid].js +6 -2
  194. package/dist/apps/www/contacts/index.d.ts +2 -2
  195. package/dist/apps/www/contacts/index.js +17 -4
  196. package/dist/apps/www/index.d.ts +2 -2
  197. package/dist/apps/www/index.js +301 -35
  198. package/dist/apps/www/messages/[uid].d.ts +3 -7
  199. package/dist/apps/www/messages/[uid].js +7 -4
  200. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  201. package/dist/apps/www/settings/auto-reply/index.js +3 -1
  202. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  203. package/dist/apps/www/settings/encryption/index.js +3 -1
  204. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  205. package/dist/apps/www/settings/filters/[uid].js +3 -1
  206. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  207. package/dist/apps/www/settings/filters/index.js +3 -1
  208. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  209. package/dist/apps/www/settings/filters/new/index.js +6 -2
  210. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  211. package/dist/apps/www/settings/labels/index.js +3 -1
  212. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  213. package/dist/apps/www/settings/privacy/index.js +3 -1
  214. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  215. package/dist/apps/www/settings/read-receipts/index.js +3 -1
  216. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  217. package/dist/apps/www/settings/sharing/index.js +3 -1
  218. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  219. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  220. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  221. package/dist/apps/www/settings/signatures/index.js +3 -1
  222. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  223. package/dist/apps/www/settings/signatures/new/index.js +6 -2
  224. package/dist/apps/www/tasks/index.d.ts +2 -2
  225. package/dist/apps/www/tasks/index.js +15 -3
  226. package/package.json +2 -2
@@ -3,33 +3,41 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useCallback, useEffect, useRef, useState } from "react";
6
+ import { routedPage } from "./_routedPage.js";
7
+ import { useNavigate } from "../shared/navigation/AppRouter.js";
8
+ import { whenIdle } from "../shared/navigation/idle.js";
9
+ import { prefetchComposeWindow } from "../shared/components/mail/compose/ComposeContext.js";
10
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
7
11
  import { HiOutlineFlag, HiOutlineLockClosed, HiOutlinePaperClip } from "react-icons/hi2";
8
12
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
- import { archiveMessage, bulkUpdateMessages, createFolder, getMessage, getMessageRawContent, listMessages, moveMessages, setMessagesFlagged, setMessagesRead, } from "@rapidmx/react-shared/mail/mailApi.js";
13
+ import { archiveMessage, bulkUpdateMessages, createFolder, getMessage, getMessageRawContent, listMessages, moveMessages, setMessagesFlagged, } from "@rapidmx/react-shared/mail/mailApi.js";
10
14
  import { listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
11
15
  import { listConversationMessages, listConversations, } from "@rapidmx/react-shared/mail/conversationsApi.js";
12
16
  import { search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
13
17
  import { parseSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
14
18
  import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
15
- import { searchEncryptedCandidates } from "@rapidmx/react-shared/search/searchTier3.js";
16
19
  import { searchLocalIndex } from "../shared/search/searchTier2.js";
17
20
  import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
18
- import { evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
19
- import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
21
+ import { useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
20
22
  import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
21
23
  import MailShell, { useMailShell, } from "../shared/components/mail/layout/MailShell.js";
22
24
  import MailAddress from "../shared/components/mail/MailAddress.js";
23
25
  import { mergeFirstPage } from "../shared/mail/mergeFirstPage.js";
24
- import MessageDetailPane from "../shared/components/mail/MessageDetailPane.js";
26
+ import { listSnapshotKey, readListSnapshot, saveListScroll, writeListSnapshot } from "../shared/mail/listSnapshots.js";
27
+ import { setReadStateMany } from "../shared/mail/messageReadState.js";
28
+ import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
29
+ import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "../shared/components/mail/unreadStyle.js";
30
+ import { LazyConversationThreadPane, LazyMessageDetailPane, prefetchReadingPane } from "../shared/components/mail/LazyReadingPane.js";
25
31
  import ConversationList from "../shared/components/mail/ConversationList.js";
26
- import ConversationThreadPane from "../shared/components/mail/ConversationThreadPane.js";
27
32
  import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
28
33
  import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
29
34
  import { CONVERSATION_SORT_NOTE, CONVERSATION_SORT_UNAVAILABLE, MAIL_LIST_CLASSIFICATION_FILTERS, getMailListPreferences, setMailListPreferences, sortConversations, } from "../shared/components/mail/listPreferences.js";
30
35
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
31
- import Skeleton from "@rapidmx/react-shared/components/feedback/Skeleton.js";
36
+ import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
32
37
  import { useUnlockPrompt } from "../shared/components/layout/UnlockPromptProvider.js";
38
+ import { SHORTCUTS } from "../shared/keyboard/keymap.js";
39
+ import { isActivatable } from "../shared/keyboard/targets.js";
40
+ import { useShortcut } from "../shared/keyboard/useShortcut.js";
33
41
  const MESSAGE_PAGE_SIZE = 50;
34
42
  const SEARCH_DEBOUNCE_MS = 300;
35
43
  const LIST_PREVIEW_MAX_LENGTH = 160;
@@ -70,6 +78,9 @@ async function decryptEncryptedRows(messages, unlocked) {
70
78
  const entries = await Promise.all(encrypted.map(async (message) => {
71
79
  try {
72
80
  const rawMime = await getMessageRawContent(message.uid);
81
+ // Loaded here, on first use: the S/MIME code (PKI.js, ASN.1, X.509) is over half a megabyte and an inbox with
82
+ // no encrypted mail in it never needs it.
83
+ const { evaluateMessageSecurity } = await import("@rapidmx/react-shared/crypto/messageSecurity.js");
73
84
  const security = await evaluateMessageSecurity(rawMime, unlocked);
74
85
  if (!security.subject && !security.html) {
75
86
  return null;
@@ -222,6 +233,8 @@ function tier3Windows(parsed, coverage, searchAllMail) {
222
233
  /** Runs Tier 3 over each window and merges the candidates (a uid can't match in two disjoint windows, but a
223
234
  * message whose date sits exactly on a boundary may come back from both). */
224
235
  async function searchTier3Windows(windows, unlocked, mailboxUid) {
236
+ // Loaded on first use, with the S/MIME code it decrypts through (see `decryptEncryptedRows()`).
237
+ const { searchEncryptedCandidates } = await import("@rapidmx/react-shared/search/searchTier3.js");
225
238
  const pages = await Promise.all(windows.map((window) => searchEncryptedCandidates(window, unlocked, TIER3_CANDIDATE_LIMIT, { mailboxUid })));
226
239
  const merged = new Map();
227
240
  for (const result of pages.flat()) {
@@ -334,16 +347,28 @@ async function fetchAggregateMessages(mailboxFolders, type, filter) {
334
347
  }));
335
348
  return mergeInboxMessages(perMailbox);
336
349
  }
337
- export default function InboxPage(props) {
350
+ function InboxPage(props) {
338
351
  return (_jsx(MailShell, { ...props, children: _jsx(InboxContent, { userUid: props.userUid }) }));
339
352
  }
340
353
  function InboxContent({ userUid }) {
341
- const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, live } = useMailShell();
354
+ const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, live, trackMessageChange } = useMailShell();
342
355
  const isMobile = useIsMobile();
356
+ const navigate = useNavigate();
343
357
  const { requestUnlock } = useUnlockPrompt();
344
358
  const [messages, setMessages] = useState([]);
345
359
  const [conversations, setConversations] = useState([]);
346
360
  const [loading, setLoading] = useState(true);
361
+ // Once a list is on screen and the browser has nothing better to do, fetch the code a click on a message (the reading pane)
362
+ // or on Compose/Reply (the compose window, with its editor) would otherwise wait for.
363
+ useEffect(() => {
364
+ if (loading) {
365
+ return;
366
+ }
367
+ return whenIdle(() => {
368
+ prefetchReadingPane();
369
+ prefetchComposeWindow();
370
+ });
371
+ }, [loading]);
347
372
  const [loadingMore, setLoadingMore] = useState(false);
348
373
  const [hasMore, setHasMore] = useState(false);
349
374
  const [error, setError] = useState(null);
@@ -481,6 +506,10 @@ function InboxContent({ userUid }) {
481
506
  // removal steps this back too, or the next page would silently skip a message. See `loadMore()`.
482
507
  const listedOffsetRef = useRef(0);
483
508
  const scrollContainerRef = useRef(null);
509
+ const searchInputRef = useRef(null);
510
+ // Where the message the keyboard just removed from the list (deleted, archived, moved) was: the next Down/Up continues from that spot, so
511
+ // clearing an inbox from the keyboard walks down it instead of jumping back to the top. Cleared by any selection.
512
+ const removedAnchorRef = useRef(null);
484
513
  // State (via a callback ref), not a plain ref: the sentinel mounts and unmounts as the list loads,
485
514
  // filters, and empties, and the observer effect below must re-attach to whichever node is current.
486
515
  const [sentinel, setSentinel] = useState(null);
@@ -652,6 +681,48 @@ function InboxContent({ userUid }) {
652
681
  const handle = setTimeout(() => setSearchQuery(searchInput.trim()), SEARCH_DEBOUNCE_MS);
653
682
  return () => clearTimeout(handle);
654
683
  }, [searchInput]);
684
+ // Choosing another folder no longer loads a new page, so what was being searched for would follow the reader into it: the
685
+ // search box and its results are cleared instead, as the page load used to. (The first selection resolves after mount - the
686
+ // shell reads the URL and lists the folders in effects - and is not a change of folder.)
687
+ const selectionKey = `${mailboxUid ?? ""}|${folderUid ?? ""}|${aggregateFolderType ?? ""}`;
688
+ const shownSelectionRef = useRef(null);
689
+ useEffect(() => {
690
+ const shown = shownSelectionRef.current;
691
+ if (shown !== null && shown !== selectionKey) {
692
+ setSearchInput("");
693
+ setSearchQuery("");
694
+ }
695
+ if (folderUid || aggregateFolderType) {
696
+ shownSelectionRef.current = selectionKey;
697
+ }
698
+ }, [selectionKey]);
699
+ // Which listing this is, for the short-lived snapshot of each folder that makes going back to one instant (see
700
+ // `listSnapshots.ts`). A search, an aggregate view and a folder that hasn't resolved have no snapshot.
701
+ const listKey = listSnapshotKey({
702
+ mailboxUid: activeMailboxUid,
703
+ folderUid,
704
+ conversations: preferences.showAsConversations,
705
+ filter: effectiveFilter,
706
+ labels: labelFilterKey,
707
+ sort: serverSortKey,
708
+ });
709
+ const snapshotable = !!folderUid && !isSearching && !aggregateFolderType;
710
+ /** The key of the listing `messages`/`conversations` currently hold - not `listKey` while a new folder's rows are still coming. */
711
+ const shownListKeyRef = useRef(null);
712
+ /** A scroll position to put the list back at once its rows are on screen (a snapshot's). */
713
+ const pendingScrollRef = useRef(null);
714
+ useLayoutEffect(() => {
715
+ if (pendingScrollRef.current !== null && !loading && scrollContainerRef.current) {
716
+ scrollContainerRef.current.scrollTop = pendingScrollRef.current;
717
+ pendingScrollRef.current = null;
718
+ }
719
+ });
720
+ // Keeps the snapshot of the folder on screen current - rows added by "load more" or live updates, a read message, the selection.
721
+ useEffect(() => {
722
+ if (snapshotable && !loading && shownListKeyRef.current === listKey) {
723
+ writeListSnapshot(listKey, { messages, conversations, hasMore, selectedUid });
724
+ }
725
+ }, [snapshotable, loading, listKey, messages, conversations, hasMore, selectedUid]);
655
726
  // Loads whichever list the current folder, arrangement and search state call for, and resets every
656
727
  // piece of per-listing state (selection, paging, select mode) that a previous listing left behind.
657
728
  useEffect(() => {
@@ -678,6 +749,8 @@ function InboxContent({ userUid }) {
678
749
  const invalidate = () => {
679
750
  searchRunIdRef.current += 1;
680
751
  };
752
+ // A folder shown a moment ago is put on screen at once and revalidated behind the rows; one that was not shows a skeleton.
753
+ const snapshot = snapshotable ? readListSnapshot(listKey) : undefined;
681
754
  if (!folderUid && !aggregateFolderType) {
682
755
  // The shell hasn't resolved this mailbox's folders yet (the render below says so). Listing
683
756
  // anything now means a request whose answer is thrown away the moment the Inbox arrives - and
@@ -702,11 +775,30 @@ function InboxContent({ userUid }) {
702
775
  // (always set - `MailShell` only renders this component once at least one mailbox exists).
703
776
  // They're scoped to the selected folder (`folderUid`, absent only in aggregate mode), so the
704
777
  // conversation list matches the folder the sidebar has selected rather than the whole mailbox.
705
- setLoading(true);
778
+ if (snapshot) {
779
+ setConversations(snapshot.conversations);
780
+ listedOffsetRef.current = snapshot.conversations.length;
781
+ setHasMore(snapshot.hasMore);
782
+ shownListKeyRef.current = listKey;
783
+ pendingScrollRef.current = snapshot.scrollTop;
784
+ setLoading(false);
785
+ }
786
+ else {
787
+ setLoading(true);
788
+ }
706
789
  setError(null);
707
790
  listConversations(activeMailboxUid, conversationParams(0))
708
791
  .then((result) => {
709
792
  if (isCurrentRun()) {
793
+ shownListKeyRef.current = listKey;
794
+ if (snapshot) {
795
+ // Folded into what is already shown, as a live refresh does, so the reader's place is kept.
796
+ const merged = mergeFirstPage(snapshot.conversations, result, conversationKey, MESSAGE_PAGE_SIZE);
797
+ setConversations(merged.rows);
798
+ listedOffsetRef.current = merged.complete ? result.length : snapshot.conversations.length + merged.added;
799
+ setHasMore(!merged.complete);
800
+ return;
801
+ }
710
802
  setConversations(result);
711
803
  listedOffsetRef.current = result.length;
712
804
  setHasMore(result.length === MESSAGE_PAGE_SIZE);
@@ -741,7 +833,7 @@ function InboxContent({ userUid }) {
741
833
  });
742
834
  return invalidate;
743
835
  }
744
- setLoading(true);
836
+ setLoading(!snapshot);
745
837
  setError(null);
746
838
  if (isSearching) {
747
839
  setCoverage(undefined);
@@ -829,9 +921,28 @@ function InboxContent({ userUid }) {
829
921
  }
830
922
  // Set: the guard at the top of this effect returned for a view with neither a folder nor an
831
923
  // aggregate type, and the aggregate branch above returned for the one with an aggregate type.
924
+ if (snapshot) {
925
+ setMessages(snapshot.messages);
926
+ listedOffsetRef.current = snapshot.messages.length;
927
+ setHasMore(snapshot.hasMore);
928
+ // The selection the reader left the folder with, if that message is still there.
929
+ setSelectedUid(snapshot.messages.some((m) => m.uid === snapshot.selectedUid) ? snapshot.selectedUid : null);
930
+ shownListKeyRef.current = listKey;
931
+ pendingScrollRef.current = snapshot.scrollTop;
932
+ }
832
933
  listMessages(folderUid, listParams)
833
934
  .then((results) => {
834
935
  if (isCurrentRun()) {
936
+ shownListKeyRef.current = listKey;
937
+ if (snapshot) {
938
+ // Folded into what is already shown, as a live refresh does: the reader's place, selection and paged-in
939
+ // rows stay, and a row they changed since (a higher version) is not put back.
940
+ const merged = mergeFirstPage(snapshot.messages, results, messageUid, MESSAGE_PAGE_SIZE, (current, next) => current.version >= next.version ? current : next);
941
+ setMessages(merged.rows);
942
+ listedOffsetRef.current = merged.complete ? results.length : snapshot.messages.length + merged.added;
943
+ setHasMore(!merged.complete);
944
+ return;
945
+ }
835
946
  setMessages(results);
836
947
  listedOffsetRef.current = results.length;
837
948
  setHasMore(results.length === MESSAGE_PAGE_SIZE);
@@ -920,7 +1031,9 @@ function InboxContent({ userUid }) {
920
1031
  return;
921
1032
  }
922
1033
  // A row the reader has just changed (a higher version) is not put back by a fetch that began before.
923
- const merged = mergeFirstPage(messagesRef.current, fresh, messageUid, MESSAGE_PAGE_SIZE, (current, next) => current.version > next.version ? current : next);
1034
+ // Equal versions are the same server state, so the row on screen - which may carry an optimistic read/unread
1035
+ // flip the server has not answered yet - wins too.
1036
+ const merged = mergeFirstPage(messagesRef.current, fresh, messageUid, MESSAGE_PAGE_SIZE, (current, next) => current.version >= next.version ? current : next);
924
1037
  setMessages(merged.rows);
925
1038
  listedOffsetRef.current = merged.complete ? fresh.length : listedOffsetRef.current + merged.added;
926
1039
  setHasMore(!merged.complete);
@@ -1104,22 +1217,27 @@ function InboxContent({ userUid }) {
1104
1217
  *
1105
1218
  * A conversation's *parent* row has no copy to replace: it is a summary of the whole thread, and its
1106
1219
  * "2 unread" chip and bold styling come from a count the server worked out when the list was fetched.
1107
- * So when the reading pane reports a message it has just read (`previous` unread, `updated` read), that
1108
- * count is decremented here - otherwise a conversation kept claiming unread mail the reader had just
1109
- * read, until the whole list was reloaded.
1220
+ * So when a message changes read state (`previous` and `updated` differ in it - the optimistic copy of a message just
1221
+ * read, or its revert), that count moves by one here - otherwise a conversation kept claiming unread mail the reader
1222
+ * had just read, until the whole list was reloaded.
1110
1223
  */
1111
1224
  function patchListedMessage(updated, previous) {
1112
1225
  setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)));
1113
1226
  // `ConversationList` fetched its own copy of this message when the thread was expanded; hand it the
1114
1227
  // newer one so the child row doesn't keep showing a stale read/flag state.
1115
1228
  setConversationPatches((prev) => ({ ...prev, [updated.uid]: updated }));
1116
- if (previous && !previous.flags.read && updated.flags.read) {
1229
+ const unreadChange = previous ? Number(isUnread(updated)) - Number(isUnread(previous)) : 0;
1230
+ if (unreadChange !== 0) {
1117
1231
  setConversations((prev) => prev.map((conversation) => conversation.messageUids.includes(updated.uid)
1118
- ? { ...conversation, unreadCount: Math.max(0, conversation.unreadCount - 1) }
1232
+ ? { ...conversation, unreadCount: Math.max(0, conversation.unreadCount + unreadChange) }
1119
1233
  : conversation));
1120
1234
  }
1121
1235
  }
1122
1236
  function removeListedMessages(uids) {
1237
+ const selectedIndex = messagesRef.current.findIndex((m) => m.uid === selectedUid);
1238
+ if (selectedUid && uids.has(selectedUid) && selectedIndex !== -1) {
1239
+ removedAnchorRef.current = selectedIndex;
1240
+ }
1123
1241
  setMessages((prev) => prev.filter((m) => !uids.has(m.uid)));
1124
1242
  listedOffsetRef.current = Math.max(0, listedOffsetRef.current - uids.size);
1125
1243
  setSelectedUid((prev) => (prev && uids.has(prev) ? null : prev));
@@ -1242,13 +1360,26 @@ function InboxContent({ userUid }) {
1242
1360
  * A bulk update is applied element by element server-side and stops at its first rejection, so a
1243
1361
  * failure leaves an unknown prefix of the selection already changed (see `bulkUpdateMessages()`) -
1244
1362
  * which is why a failure reloads the list rather than trying to reconcile it, and says so.
1363
+ *
1364
+ * `chosen` is the selection bar's ticked messages unless the caller (a keyboard shortcut acting on the one open message or conversation)
1365
+ * names its own. Resolves whether it succeeded.
1245
1366
  */
1246
- async function runBulkAction(action, removesRows) {
1247
- const chosen = selectedMessages;
1367
+ async function runBulkAction(action, removesRows, chosen = selectedMessages) {
1248
1368
  setBulkBusy(true);
1249
1369
  setBulkError(null);
1250
1370
  try {
1251
1371
  const updated = await action(chosen);
1372
+ if (removesRows) {
1373
+ // A move (Delete, Archive, Report junk, Move to): the source folders' badges go down, the target's up. Reading
1374
+ // and unreading are tracked by `setReadStateMany()` itself, as they happen.
1375
+ const movedByUid = new Map(updated.map((m) => [m.uid, m]));
1376
+ for (const before of chosen) {
1377
+ const after = movedByUid.get(before.uid);
1378
+ if (after) {
1379
+ trackMessageChange(before, after).settle();
1380
+ }
1381
+ }
1382
+ }
1252
1383
  if (preferences.showAsConversations) {
1253
1384
  // A conversation row is a summary of its messages - its count, unread count, participants
1254
1385
  // and preview all move when a bulk action changes or empties part of it - so the list is
@@ -1265,6 +1396,7 @@ function InboxContent({ userUid }) {
1265
1396
  setMessages((prev) => prev.map((m) => byUid.get(m.uid) ?? m));
1266
1397
  }
1267
1398
  setSelectedUids(new Set());
1399
+ return true;
1268
1400
  }
1269
1401
  catch (err) {
1270
1402
  setBulkError(`${err instanceof ApiRequestError ? err.message : "Those messages couldn't all be updated."} Some of them may already have changed, so the list has been reloaded.`);
@@ -1272,6 +1404,7 @@ function InboxContent({ userUid }) {
1272
1404
  setSelectedConversationIds(new Set());
1273
1405
  setConversationMessagesById({});
1274
1406
  setRefreshKey((n) => n + 1);
1407
+ return false;
1275
1408
  }
1276
1409
  finally {
1277
1410
  setBulkBusy(false);
@@ -1321,8 +1454,8 @@ function InboxContent({ userUid }) {
1321
1454
  return { uid: message.uid, version: message.version, labelUids: [...new Set([...labelUids, ...kept])] };
1322
1455
  })), false);
1323
1456
  }
1324
- function moveSelectionToType(type, name) {
1325
- void runBulkAction(async (chosen) => moveMessages(chosen, await resolveFolderOfType(type, name)), true);
1457
+ function moveSelectionToType(type, name, chosen) {
1458
+ return runBulkAction(async (moving) => moveMessages(moving, await resolveFolderOfType(type, name)), true, chosen);
1326
1459
  }
1327
1460
  const loadMoreStatus = loadingMore ? ("Loading more\u2026") : loadMoreError ? (_jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { role: "alert", className: "text-danger", children: loadMoreError }), _jsx("button", { type: "button", onClick: () => void loadMoreRef.current(), className: "text-primary-dark hover:underline font-medium", children: "Retry" })] })) : loadMoreStalled ? (_jsx("button", { type: "button", onClick: () => void loadMoreRef.current(), className: "text-primary-dark hover:underline font-medium", children: "Load more" })) : null;
1328
1461
  function handleSelect(message) {
@@ -1331,9 +1464,10 @@ function InboxContent({ userUid }) {
1331
1464
  return;
1332
1465
  }
1333
1466
  if (isMobile) {
1334
- window.location.href = `/messages/${encodeURIComponent(message.uid)}`;
1467
+ navigate(`/messages/${encodeURIComponent(message.uid)}`);
1335
1468
  return;
1336
1469
  }
1470
+ removedAnchorRef.current = null;
1337
1471
  setSelectedUid(message.uid);
1338
1472
  }
1339
1473
  /** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
@@ -1348,12 +1482,142 @@ function InboxContent({ userUid }) {
1348
1482
  if (isMobile) {
1349
1483
  // No dedicated mobile thread route yet - the existing single-message detail route already
1350
1484
  // handles any message uid regardless of conversation grouping.
1351
- window.location.href = `/messages/${encodeURIComponent(uid)}`;
1485
+ navigate(`/messages/${encodeURIComponent(uid)}`);
1352
1486
  return;
1353
1487
  }
1488
+ removedAnchorRef.current = null;
1354
1489
  setSelectedUid(uid);
1355
1490
  setOpenThread({ conversation, uid });
1356
1491
  }
1492
+ // ---- Keyboard shortcuts (see `shared/keyboard`). Each is registered only while this view can do it, and calls what the toolbar and
1493
+ // ---- the selection bar call: the same bulk-action path (`runBulkAction()` - so the same optimistic badge tracking, the same rollback,
1494
+ // ---- the same lazily created Deleted Items folder), never a second implementation. Reply, Reply all, Forward, Archive and Move to are
1495
+ // ---- registered by the reading pane itself (`MessageDetailPane`'s `shortcuts`), which owns those handlers.
1496
+ const inConversations = preferences.showAsConversations;
1497
+ /** The selection bar isn't offered in the aggregate view (its rows belong to several mailboxes), and neither are these. */
1498
+ const keyboardActions = !aggregateFolderType;
1499
+ /** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
1500
+ const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : inConversations ? openThread !== null : selected !== null;
1501
+ const rowCount = inConversations ? listedConversations.length : messages.length;
1502
+ const canMoveSelection = !selectMode && !isMobile && !loading && rowCount > 0;
1503
+ function folderTypeOf(folderUidOfMessage) {
1504
+ return currentFolders.find((f) => f.uid === folderUidOfMessage)?.type;
1505
+ }
1506
+ /** Moves the keyboard's focus to a row - and scrolls it into view - once the selection has moved to it. */
1507
+ function focusRow(uid) {
1508
+ // Only called from a key press, when the list is on screen.
1509
+ const row = [...scrollContainerRef.current.querySelectorAll("[data-message-uid]")].find((element) => element.getAttribute("data-message-uid") === uid);
1510
+ row?.scrollIntoView({ block: "nearest" });
1511
+ row?.querySelector("[data-row-open]")?.focus({ preventScroll: true });
1512
+ }
1513
+ /** The index of the selected row: the open message's, or the open conversation's; -1 with nothing selected. */
1514
+ function selectedRowIndex() {
1515
+ if (inConversations) {
1516
+ return openThread ? listedConversations.findIndex((c) => c.conversationId === openThread.conversation.conversationId) : -1;
1517
+ }
1518
+ return messages.findIndex((m) => m.uid === selectedUid);
1519
+ }
1520
+ function selectRow(index) {
1521
+ if (inConversations) {
1522
+ const conversation = listedConversations[index];
1523
+ handleOpenConversation(conversation, conversation.latestMessageUid);
1524
+ focusRow(conversation.latestMessageUid);
1525
+ }
1526
+ else {
1527
+ const message = messages[index];
1528
+ handleSelect(message);
1529
+ focusRow(message.uid);
1530
+ }
1531
+ }
1532
+ function selectNeighbour(step) {
1533
+ const current = selectedRowIndex();
1534
+ // After the selected message left the list (deleted, archived) the next row is the one that slid into its place.
1535
+ const anchor = removedAnchorRef.current;
1536
+ const target = current === -1 && anchor !== null ? (step === 1 ? anchor : anchor - 1) : current + step;
1537
+ selectRow(Math.min(rowCount - 1, Math.max(0, target)));
1538
+ }
1539
+ function selectNextUnread(step) {
1540
+ const isRowUnread = (index) => (inConversations ? listedConversations[index].unreadCount > 0 : isUnread(messages[index]));
1541
+ for (let index = selectedRowIndex() + step; index >= 0 && index < rowCount; index += step) {
1542
+ if (isRowUnread(index)) {
1543
+ selectRow(index);
1544
+ return;
1545
+ }
1546
+ }
1547
+ }
1548
+ /** The messages of the open conversation that are in the folder being listed - fetched (once) like a ticked conversation's. */
1549
+ async function loadOpenConversation(conversation) {
1550
+ const id = conversation.conversationId;
1551
+ let all = conversationMessagesById[id];
1552
+ if (!all) {
1553
+ all = await listConversationMessages(activeMailboxUid, id);
1554
+ const loaded = all;
1555
+ setConversationMessagesById((prev) => ({ ...prev, [id]: loaded }));
1556
+ }
1557
+ return all.filter((m) => !folderUid || m.folderUid === folderUid);
1558
+ }
1559
+ /**
1560
+ * Runs a bulk action on what the keyboard acts on - the ticked rows in select mode, else the open message or conversation - keeping only
1561
+ * the messages `applies` accepts (Delete leaves out what is already in Deleted Items, Mark read what is already read: nothing to do
1562
+ * then). Held while another bulk action is on the wire, like the bar's buttons. Closes the open thread after a move away from it.
1563
+ */
1564
+ async function runOnKeyboardTargets(action, removesRows, applies = () => true) {
1565
+ if (bulkBusy || resolvingSelection > 0) {
1566
+ return;
1567
+ }
1568
+ let chosen;
1569
+ try {
1570
+ chosen = selectMode ? selectedMessages : inConversations ? await loadOpenConversation(openThread.conversation) : [selected];
1571
+ }
1572
+ catch (err) {
1573
+ setBulkError(err instanceof ApiRequestError ? err.message : "Could not load the messages in that conversation.");
1574
+ return;
1575
+ }
1576
+ chosen = chosen.filter(applies);
1577
+ if (chosen.length === 0) {
1578
+ return;
1579
+ }
1580
+ const succeeded = await runBulkAction(action, removesRows, chosen);
1581
+ if (succeeded && removesRows && inConversations && !selectMode) {
1582
+ setOpenThread(null);
1583
+ setSelectedUid(null);
1584
+ }
1585
+ }
1586
+ useShortcut(SHORTCUTS.mail.next, () => selectNeighbour(1), { enabled: canMoveSelection });
1587
+ useShortcut(SHORTCUTS.mail.previous, () => selectNeighbour(-1), { enabled: canMoveSelection });
1588
+ useShortcut(SHORTCUTS.mail.nextUnread, () => selectNextUnread(1), { enabled: canMoveSelection });
1589
+ useShortcut(SHORTCUTS.mail.previousUnread, () => selectNextUnread(-1), { enabled: canMoveSelection });
1590
+ useShortcut(SHORTCUTS.mail.delete, () => void runOnKeyboardTargets(async (chosen) => moveMessages(chosen, await resolveFolderOfType("deleted_items", "Deleted Items")), true, (message) => folderTypeOf(message.folderUid) !== "deleted_items"), { enabled: keyboardActions && keyboardTargetExists });
1591
+ useShortcut(SHORTCUTS.mail.markRead, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, true, { patch: patchListedMessage, track: trackMessageChange }), false, isUnread), { enabled: keyboardActions && keyboardTargetExists });
1592
+ useShortcut(SHORTCUTS.mail.markUnread, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, false, { patch: patchListedMessage, track: trackMessageChange }), false, (message) => !isUnread(message)), { enabled: keyboardActions && keyboardTargetExists });
1593
+ useShortcut(SHORTCUTS.mail.flag, () =>
1594
+ // Flags them all unless every one already is - what the bar's Flag and Unflag would do on this selection.
1595
+ void runOnKeyboardTargets((chosen) => setMessagesFlagged(chosen, !chosen.every((m) => m.flags.flagged)), false), { enabled: keyboardActions && keyboardTargetExists });
1596
+ useShortcut(SHORTCUTS.mail.open, (event) => {
1597
+ // Enter on a row that is not selected yet is the row button's own click (it selects). On the selected one - or with the focus
1598
+ // elsewhere - it opens the message on its own page, as it does in Outlook.
1599
+ const rowUid = event.target.closest("[data-message-uid]")?.getAttribute("data-message-uid");
1600
+ if (rowUid ? rowUid !== selectedUid : isActivatable(event.target)) {
1601
+ return false;
1602
+ }
1603
+ navigate(`/messages/${encodeURIComponent(selectedUid)}`);
1604
+ }, { enabled: !selectMode && selectedUid !== null });
1605
+ useShortcut(SHORTCUTS.mail.close, () => {
1606
+ if (document.activeElement === searchInputRef.current && searchInput !== "") {
1607
+ setSearchInput("");
1608
+ }
1609
+ else if (selectMode) {
1610
+ leaveSelectMode();
1611
+ }
1612
+ else {
1613
+ setSelectedUid(null);
1614
+ setOpenThread(null);
1615
+ }
1616
+ }, { enabled: selectMode || selectedUid !== null || openThread !== null || searchInput !== "" });
1617
+ useShortcut(SHORTCUTS.mail.search, () => {
1618
+ searchInputRef.current?.focus();
1619
+ searchInputRef.current?.select();
1620
+ }, { enabled: !inConversations && !aggregateFolderType });
1357
1621
  if (!folderUid && !aggregateFolderType) {
1358
1622
  // `MailShell` never renders this component at all until a mailbox is resolved (see its own
1359
1623
  // full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
@@ -1362,11 +1626,18 @@ function InboxContent({ userUid }) {
1362
1626
  // indistinguishable to anything (a test, a user re-reading the screen) that catches this one.
1363
1627
  return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Loading your mailbox\u2026" });
1364
1628
  }
1365
- return (_jsxs("div", { className: "flex h-full min-h-0", children: [_jsxs("div", { ref: scrollContainerRef, className: "w-full md:w-96 shrink-0 md:border-r border-border overflow-y-auto", children: [selectMode ? (_jsx(MailSelectionBar, { selected: selectedMessages, listed: messages, totals: preferences.showAsConversations
1629
+ return (_jsxs("div", { className: "flex h-full min-h-0", children: [_jsxs("div", { ref: scrollContainerRef,
1630
+ // Remembered as it moves (not when the list is left: by then React has already detached the element), so
1631
+ // the folder - or Mail, after another app - comes back where it was.
1632
+ onScroll: (event) => saveListScroll(listKey, event.currentTarget.scrollTop), className: "w-full md:w-96 shrink-0 md:border-r border-border overflow-y-auto", children: [selectMode ? (_jsx(MailSelectionBar, { selected: selectedMessages, listed: messages, totals: preferences.showAsConversations
1366
1633
  ? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
1367
1634
  : undefined, onSelectAll: () => preferences.showAsConversations
1368
1635
  ? selectAllConversations()
1369
- : setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () => preferences.showAsConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set()), onCancel: leaveSelectMode, folders: currentFolders, currentFolderUid: folderUid, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated, onApplyLabels: applyLabelsToSelection, onSetRead: (read) => void runBulkAction((chosen) => setMessagesRead(chosen, read), false), onSetFlagged: (flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged), false), onArchive: () => void runBulkAction(bulkArchive, true), onMoveTo: (targetFolderUid) => runBulkAction((chosen) => moveMessages(chosen, targetFolderUid), true), onReportJunk: () => moveSelectionToType("junk", "Junk Email"), onDelete: () => moveSelectionToType("deleted_items", "Deleted Items"), busy: bulkBusy || resolvingSelection > 0, error: bulkError })) : (_jsx(MailListToolbar, { sortBy: preferences.sortBy, sortOrder: preferences.sortOrder, filter: preferences.filter, labelUids: preferences.labelUids, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onLabelUidsChange: (nextLabelUids) => updatePreferences({ labelUids: nextLabelUids }), showAsConversations: preferences.showAsConversations, onSortChange: (sortBy, sortOrder) => updatePreferences({ sortBy, sortOrder }), onFilterChange: (filter) => updatePreferences({ filter }), onShowAsConversationsChange: (showAsConversations) => updatePreferences({ showAsConversations }), selectMode: selectMode, onSelectModeChange: setSelectMode, offerClassificationFilters: offerClassificationFilters, filterDisabled: isSearching, filterDisabledReason: "Filters don't apply to search results", sortKeysDisabled: isSearching || !!aggregateFolderType,
1636
+ : setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () => preferences.showAsConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set()), onCancel: leaveSelectMode, folders: currentFolders, currentFolderUid: folderUid, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated, onApplyLabels: applyLabelsToSelection, onSetRead: (read) => void runBulkAction(
1637
+ // Optimistic: the rows and the folder badges change now, and change back if the server refuses.
1638
+ (chosen) => setReadStateMany(chosen, read, { patch: patchListedMessage, track: trackMessageChange }), false), onSetFlagged: (flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged), false), shortcuts: keyboardActions, onArchive: () => void runBulkAction(bulkArchive, true), onMoveTo: async (targetFolderUid) => {
1639
+ await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid), true);
1640
+ }, onReportJunk: () => void moveSelectionToType("junk", "Junk Email"), onDelete: () => void moveSelectionToType("deleted_items", "Deleted Items"), busy: bulkBusy || resolvingSelection > 0, error: bulkError })) : (_jsx(MailListToolbar, { sortBy: preferences.sortBy, sortOrder: preferences.sortOrder, filter: preferences.filter, labelUids: preferences.labelUids, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onLabelUidsChange: (nextLabelUids) => updatePreferences({ labelUids: nextLabelUids }), showAsConversations: preferences.showAsConversations, onSortChange: (sortBy, sortOrder) => updatePreferences({ sortBy, sortOrder }), onFilterChange: (filter) => updatePreferences({ filter }), onShowAsConversationsChange: (showAsConversations) => updatePreferences({ showAsConversations }), selectMode: selectMode, onSelectModeChange: setSelectMode, offerClassificationFilters: offerClassificationFilters, filterDisabled: isSearching, filterDisabledReason: "Filters don't apply to search results", sortKeysDisabled: isSearching || !!aggregateFolderType,
1370
1641
  // A conversation row is a thread summary, so only some of the keys have anything to
1371
1642
  // order by - the rest stay pickable and are applied to the rows already fetched.
1372
1643
  unavailableSortKeys: preferences.showAsConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined, sortKeysNote: isSearching
@@ -1379,7 +1650,7 @@ function InboxContent({ userUid }) {
1379
1650
  ? "Open a mailbox's own folder to select messages"
1380
1651
  : loading
1381
1652
  ? "Wait for this folder to finish loading"
1382
- : "There is nothing here to select" })), !preferences.showAsConversations && (_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…", "aria-label": "Search all mail", disabled: !!aggregateFolderType, className: "w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface disabled:opacity-55" }) })), offerClassificationFilters && (_jsx("div", { className: "flex border-b border-border text-xs", children: MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (_jsx("button", { type: "button", "aria-pressed": preferences.filter === value, onClick: () => updatePreferences({ filter: value }), className: [
1653
+ : "There is nothing here to select" })), !preferences.showAsConversations && (_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { ref: searchInputRef, type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…", "aria-label": "Search all mail", disabled: !!aggregateFolderType, className: "w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface disabled:opacity-55" }) })), offerClassificationFilters && (_jsx("div", { className: "flex border-b border-border text-xs", children: MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (_jsx("button", { type: "button", "aria-pressed": preferences.filter === value, onClick: () => updatePreferences({ filter: value }), className: [
1383
1654
  "flex-1 py-1.5 font-semibold",
1384
1655
  preferences.filter === value
1385
1656
  ? "text-primary-dark border-b-2 border-primary-dark"
@@ -1388,17 +1659,11 @@ function InboxContent({ userUid }) {
1388
1659
  ? `${messages.length} result${messages.length === 1 ? "" : "s"}`
1389
1660
  : `${messages.length} of ??` }), tier2Done && !searchAllMail && (_jsx("button", { type: "button", onClick: handleSearchAllMail, className: "text-primary-dark hover:underline font-medium shrink-0", children: "Search all mail" }))] })), isSearching && coverage?.indexedFrom && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["Local search covers messages back to ", new Date(coverage.indexedFrom).toLocaleDateString(), coverage.building ? " (still building)" : "", searchAllMail
1390
1661
  ? " - searching everything, not just recent mail."
1391
- : " - older encrypted mail is still searched, just slower."] })), isSearching && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching && !preferences.showAsConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockList, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to show ", undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'", " subject"] }) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : preferences.showAsConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: toggleConversationSelected }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
1662
+ : " - older encrypted mail is still searched, just slower."] })), isSearching && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching && !preferences.showAsConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockList, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to show ", undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'", " subject"] }) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), !selectMode && bulkError && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: bulkError }) })), loading ? (_jsxs("div", { role: "status", "aria-busy": "true", className: "p-4", children: [_jsx("span", { className: "sr-only", children: "Loading\u2026" }), _jsx(SkeletonList, { count: 8 })] })) : preferences.showAsConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: toggleConversationSelected }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
1392
1663
  ? `No messages match "${searchQuery}".`
1393
1664
  : effectiveFilter === "all"
1394
1665
  ? "No messages in this folder."
1395
- : "No messages here." }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : (_jsxs(_Fragment, { children: [_jsx("ul", { children: messages.map((message) => (_jsxs("li", { className: "flex items-stretch border-b border-border", children: [selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: selectedUids.has(message.uid), onChange: () => toggleSelected(message.uid), "aria-label": `Select ${rowSubject(message)}`, className: "w-4 h-4 accent-primary" }) })), _jsxs("button", { type: "button", onClick: () => handleSelect(message), className: [
1396
- "flex-1 min-w-0 text-left px-4 py-3",
1397
- message.uid === selectedUid || selectedUids.has(message.uid)
1398
- ? "bg-primary/10"
1399
- : "hover:bg-surface-alt",
1400
- message.flags.read ? "" : "font-semibold",
1401
- ].join(" "), children: [_jsxs("div", { 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).toLocaleDateString() })] }), aggregateFolderType && (_jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName })), isSearching && pendingUids.has(message.uid) ? (_jsxs("div", { className: "flex flex-col gap-1.5 py-0.5", children: [_jsx(Skeleton, { height: "h-3.5", className: "w-2/3 rounded-sm" }), _jsx(Skeleton, { height: "h-3", className: "w-full rounded-sm" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-sm truncate", children: rowSubject(message) }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: snippets[message.uid] || decryptedRows[message.uid]?.preview || message.bodyPreview }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }))] })] }, message.uid))) }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), aggregateFolderType && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0 min-h-0", children: preferences.showAsConversations ? (_jsx(ConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: activeMailboxUid, folders: currentFolders, labels: mailboxLabels, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(MessageDetailPane, { message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
1666
+ : "No messages here." }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : (_jsxs(_Fragment, { children: [_jsx("ul", { children: messages.map((message) => (_jsxs("li", { "data-message-uid": message.uid, "data-unread": isUnread(message) ? "true" : undefined, className: rowClass({ unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: isUnread(message) }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: selectedUids.has(message.uid), onChange: () => toggleSelected(message.uid), "aria-label": `Select ${rowSubject(message)}`, className: "w-4 h-4 accent-primary" }) })), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => handleSelect(message), className: ["flex-1 min-w-0 text-left px-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: isUnread(message) }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(isUnread(message)) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(isUnread(message))].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), aggregateFolderType && (_jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName })), isSearching && pendingUids.has(message.uid) ? (_jsxs("div", { className: "flex flex-col gap-1.5 py-0.5", children: [_jsx(Skeleton, { height: "h-3.5", className: "w-2/3 rounded-sm" }), _jsx(Skeleton, { height: "h-3", className: "w-full rounded-sm" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: ["text-sm truncate", subjectClass(isUnread(message))].join(" "), children: rowSubject(message) }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: snippets[message.uid] || decryptedRows[message.uid]?.preview || message.bodyPreview }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }))] })] }, message.uid))) }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), aggregateFolderType && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0 min-h-0", children: preferences.showAsConversations ? (_jsx(LazyConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: activeMailboxUid, folders: currentFolders, labels: mailboxLabels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(LazyMessageDetailPane, { shortcuts: !selectMode, message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
1402
1667
  // Same reasoning as onArchived below - a move takes the message out of the folder
1403
1668
  // being listed, so it leaves the list rather than being patched in place.
1404
1669
  removeListedMessage(updated.uid);
@@ -1414,3 +1679,4 @@ function InboxContent({ userUid }) {
1414
1679
  removeListedMessage(updated.uid);
1415
1680
  }, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) })] }));
1416
1681
  }
1682
+ export default routedPage("/", InboxPage);
@@ -1,11 +1,7 @@
1
- import React from "react";
2
1
  import { MailShellProps } from "../../shared/components/mail/layout/MailShell.js";
3
- /**
4
- * Only reached on mobile (below the `md` breakpoint) — desktop's `apps/www/index.tsx` keeps its existing
5
- * inline reading pane and never navigates here; see that file's `handleSelect`.
6
- */
7
- export default function MessageDetailPage(props: MailShellProps & {
2
+ declare const _default: import("../../shared/navigation/AppRouter.js").RoutedPageComponent<MailShellProps & {
8
3
  params: {
9
4
  uid: string;
10
5
  };
11
- }): React.JSX.Element;
6
+ }>;
7
+ export default _default;