@rapidmx/web-client 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/README.md +192 -88
  2. package/apps/shared/auth/accountUrl.ts +9 -0
  3. package/apps/shared/auth/adminAccess.ts +99 -0
  4. package/apps/shared/components/admin/elevation.ts +61 -0
  5. package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
  6. package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
  7. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +87 -24
  8. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  9. package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
  10. package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
  11. package/apps/shared/components/layout/AppShell.tsx +411 -288
  12. package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
  13. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  14. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -126
  15. package/apps/shared/components/layout/UserMenu.tsx +303 -150
  16. package/apps/shared/components/mail/ConversationList.tsx +269 -253
  17. package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
  18. package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
  19. package/apps/shared/components/mail/MailAddress.tsx +88 -0
  20. package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1383
  22. package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
  23. package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
  24. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  25. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1700
  26. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  27. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  28. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
  29. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  30. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  31. package/apps/shared/components/mail/layout/MailShell.tsx +476 -445
  32. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
  34. package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
  35. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  36. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  37. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  38. package/apps/shared/keyboard/dispatch.ts +124 -0
  39. package/apps/shared/keyboard/format.ts +89 -0
  40. package/apps/shared/keyboard/keymap.ts +114 -0
  41. package/apps/shared/keyboard/match.ts +44 -0
  42. package/apps/shared/keyboard/parse.ts +136 -0
  43. package/apps/shared/keyboard/platform.ts +34 -0
  44. package/apps/shared/keyboard/registry.ts +65 -0
  45. package/apps/shared/keyboard/targets.ts +79 -0
  46. package/apps/shared/keyboard/useShortcut.ts +50 -0
  47. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  48. package/apps/shared/mail/folderCounts.ts +302 -0
  49. package/apps/shared/mail/listSnapshots.ts +87 -0
  50. package/apps/shared/mail/mergeFirstPage.ts +47 -0
  51. package/apps/shared/mail/messageReadState.ts +85 -0
  52. package/apps/shared/mail/newMailNotifications.ts +183 -0
  53. package/apps/shared/mail/useMailConnection.ts +150 -0
  54. package/apps/shared/mail/useMailLiveUpdates.ts +232 -0
  55. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  56. package/apps/shared/mail/useNewMailNotifications.ts +163 -0
  57. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  58. package/apps/shared/navigation/AppRouter.tsx +300 -0
  59. package/apps/shared/navigation/appHrefs.ts +23 -0
  60. package/apps/shared/navigation/frameContext.tsx +35 -0
  61. package/apps/shared/navigation/idle.ts +45 -0
  62. package/apps/shared/navigation/routerContext.tsx +83 -0
  63. package/apps/shared/navigation/routes.ts +72 -0
  64. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  65. package/apps/shared/styles/app.css +28 -10
  66. package/apps/www/_routedPage.tsx +24 -0
  67. package/apps/www/_routes.ts +35 -0
  68. package/apps/www/calendar/index.tsx +55 -19
  69. package/apps/www/contacts/[uid].tsx +112 -107
  70. package/apps/www/contacts/index.tsx +584 -567
  71. package/apps/www/index.tsx +2280 -1854
  72. package/apps/www/messages/[uid].tsx +106 -101
  73. package/apps/www/settings/auto-reply/index.tsx +4 -1
  74. package/apps/www/settings/encryption/index.tsx +1252 -1249
  75. package/apps/www/settings/filters/[uid].tsx +4 -1
  76. package/apps/www/settings/filters/index.tsx +102 -99
  77. package/apps/www/settings/filters/new/index.tsx +138 -133
  78. package/apps/www/settings/labels/index.tsx +204 -201
  79. package/apps/www/settings/privacy/index.tsx +4 -1
  80. package/apps/www/settings/read-receipts/index.tsx +4 -1
  81. package/apps/www/settings/sharing/index.tsx +277 -274
  82. package/apps/www/settings/signatures/[uid].tsx +170 -167
  83. package/apps/www/settings/signatures/index.tsx +88 -85
  84. package/apps/www/settings/signatures/new/index.tsx +134 -129
  85. package/apps/www/tasks/index.tsx +18 -2
  86. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  87. package/dist/apps/shared/auth/accountUrl.js +8 -0
  88. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  89. package/dist/apps/shared/auth/adminAccess.js +89 -0
  90. package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
  91. package/dist/apps/shared/components/admin/elevation.js +56 -0
  92. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
  93. package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
  94. package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
  95. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
  96. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  97. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  98. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  99. package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
  100. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  101. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
  102. package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
  103. package/dist/apps/shared/components/layout/AppShell.js +73 -14
  104. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
  105. package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
  106. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  107. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
  108. package/dist/apps/shared/components/layout/MailboxProvisioning.js +45 -8
  109. package/dist/apps/shared/components/layout/UserMenu.d.ts +28 -9
  110. package/dist/apps/shared/components/layout/UserMenu.js +87 -14
  111. package/dist/apps/shared/components/mail/ConversationList.js +9 -13
  112. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  113. package/dist/apps/shared/components/mail/ConversationThreadPane.js +53 -28
  114. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  115. package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
  116. package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
  117. package/dist/apps/shared/components/mail/MailAddress.js +39 -0
  118. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
  119. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
  120. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
  121. package/dist/apps/shared/components/mail/MessageDetailPane.js +87 -36
  122. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
  123. package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
  124. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
  125. package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
  126. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  127. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
  128. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +122 -27
  129. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  130. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  131. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  132. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  133. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
  134. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
  135. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  136. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  137. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  138. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  139. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +19 -4
  140. package/dist/apps/shared/components/mail/layout/MailShell.js +85 -85
  141. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  142. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  143. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  144. package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
  145. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  146. package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
  147. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  148. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  149. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  150. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  151. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  152. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  153. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  154. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  155. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  156. package/dist/apps/shared/keyboard/format.js +74 -0
  157. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  158. package/dist/apps/shared/keyboard/keymap.js +84 -0
  159. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  160. package/dist/apps/shared/keyboard/match.js +31 -0
  161. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  162. package/dist/apps/shared/keyboard/parse.js +107 -0
  163. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  164. package/dist/apps/shared/keyboard/platform.js +22 -0
  165. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  166. package/dist/apps/shared/keyboard/registry.js +33 -0
  167. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  168. package/dist/apps/shared/keyboard/targets.js +67 -0
  169. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  170. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  171. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  172. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  173. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  174. package/dist/apps/shared/mail/folderCounts.js +212 -0
  175. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  176. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  177. package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
  178. package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
  179. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  180. package/dist/apps/shared/mail/messageReadState.js +63 -0
  181. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  182. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  183. package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
  184. package/dist/apps/shared/mail/useMailConnection.js +96 -0
  185. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +55 -0
  186. package/dist/apps/shared/mail/useMailLiveUpdates.js +182 -0
  187. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  188. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  189. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
  190. package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
  191. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  192. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  193. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  194. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  195. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  196. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  197. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  198. package/dist/apps/shared/navigation/frameContext.js +24 -0
  199. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  200. package/dist/apps/shared/navigation/idle.js +43 -0
  201. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  202. package/dist/apps/shared/navigation/routerContext.js +56 -0
  203. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  204. package/dist/apps/shared/navigation/routes.js +37 -0
  205. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  206. package/dist/apps/shared/styles/app.css +28 -10
  207. package/dist/apps/www/_routedPage.d.ts +12 -0
  208. package/dist/apps/www/_routedPage.js +19 -0
  209. package/dist/apps/www/_routes.d.ts +11 -0
  210. package/dist/apps/www/_routes.js +29 -0
  211. package/dist/apps/www/calendar/index.d.ts +2 -2
  212. package/dist/apps/www/calendar/index.js +39 -8
  213. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  214. package/dist/apps/www/contacts/[uid].js +6 -2
  215. package/dist/apps/www/contacts/index.d.ts +2 -2
  216. package/dist/apps/www/contacts/index.js +17 -4
  217. package/dist/apps/www/index.d.ts +2 -2
  218. package/dist/apps/www/index.js +361 -34
  219. package/dist/apps/www/messages/[uid].d.ts +3 -7
  220. package/dist/apps/www/messages/[uid].js +7 -4
  221. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  222. package/dist/apps/www/settings/auto-reply/index.js +3 -1
  223. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  224. package/dist/apps/www/settings/encryption/index.js +3 -1
  225. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  226. package/dist/apps/www/settings/filters/[uid].js +3 -1
  227. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  228. package/dist/apps/www/settings/filters/index.js +3 -1
  229. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  230. package/dist/apps/www/settings/filters/new/index.js +6 -2
  231. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  232. package/dist/apps/www/settings/labels/index.js +3 -1
  233. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  234. package/dist/apps/www/settings/privacy/index.js +3 -1
  235. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  236. package/dist/apps/www/settings/read-receipts/index.js +3 -1
  237. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  238. package/dist/apps/www/settings/sharing/index.js +3 -1
  239. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  240. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  241. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  242. package/dist/apps/www/settings/signatures/index.js +3 -1
  243. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  244. package/dist/apps/www/settings/signatures/new/index.js +6 -2
  245. package/dist/apps/www/tasks/index.d.ts +2 -2
  246. package/dist/apps/www/tasks/index.js +15 -3
  247. package/package.json +2 -2
@@ -0,0 +1,302 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useCallback, useEffect, useRef, useState } from "react";
6
+ import { Folder, FolderType, Mailbox, Message, listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
7
+
8
+ /**
9
+ * The folder badges' numbers, kept right without a reload.
10
+ *
11
+ * A folder's `unreadCount`/`totalCount` come from the server (`GET /mail/folders`, the server-published `Folder` update
12
+ * event, and the poll), but waiting for a round trip to see a badge move after reading a message feels broken, so a change
13
+ * made here is applied to the badge at once - **optimistically** - and then *reconciled*: every so often, and after every
14
+ * change, the real counts are read back and replace whatever this page worked out. So the page can be wrong for a moment,
15
+ * never for good.
16
+ *
17
+ * What the server sends (`@rapidmx/restapi`): `GET /mail/folders` returns each folder with a derived `unreadCount`/`totalCount`
18
+ * (unread messages, all messages), and after any change to a folder's messages it publishes
19
+ * `{ type: /^Folder/, action: "update", data: { uid, mailboxUid, unreadCount, totalCount } }` on the folder's push channel *and* its
20
+ * mailbox's, so each is heard twice - the values are absolute and applying one again changes nothing. Nothing here fails without
21
+ * that event, or for a write that publishes none (ActiveSync/MAPI, erasure jobs): the read-back after each change and the poll do
22
+ * the same job a little later, and overlapping writes to one folder are unordered, so they always have the last word.
23
+ */
24
+
25
+ export interface FolderCount {
26
+ unread: number;
27
+ total: number;
28
+ }
29
+
30
+ /** A change to one folder's counts. */
31
+ export interface CountDelta {
32
+ folderUid: string;
33
+ unread: number;
34
+ total: number;
35
+ }
36
+
37
+ /** What one message change - a new message (`previous` absent), a deletion (`next` absent), a read/unread flip, a move - does to the folders' counts. */
38
+ export function countDeltas(previous: Message | null | undefined, next: Message | null | undefined): CountDelta[] {
39
+ const byFolder = new Map<string, CountDelta>();
40
+ function add(message: Message, sign: 1 | -1) {
41
+ const entry = byFolder.get(message.folderUid) ?? { folderUid: message.folderUid, unread: 0, total: 0 };
42
+ entry.total += sign;
43
+ entry.unread += message.flags.read === true ? 0 : sign;
44
+ byFolder.set(message.folderUid, entry);
45
+ }
46
+ if (previous) {
47
+ add(previous, -1);
48
+ }
49
+ if (next) {
50
+ add(next, 1);
51
+ }
52
+ return [...byFolder.values()].filter((delta) => delta.unread !== 0 || delta.total !== 0);
53
+ }
54
+
55
+ /** A folder's count as it should be shown: what this page last worked out or was told, else what the folder was listed with. */
56
+ export function countOfFolder(folder: Folder, counts: Record<string, FolderCount>): FolderCount {
57
+ return counts[folder.uid] ?? { unread: folder.unreadCount, total: folder.totalCount };
58
+ }
59
+
60
+ /** What a folder's badge shows: how many unread, or (Drafts and Outbox, Outlook-style) how many messages are in it. */
61
+ export interface FolderBadge {
62
+ kind: "unread" | "total";
63
+ value: number;
64
+ }
65
+
66
+ /**
67
+ * The badge a folder gets, if any. Inbox, Archive and the user's own folders show their unread count when it is above
68
+ * zero; Drafts and Outbox show how many messages they hold (an unread count means nothing there); Sent Items, Deleted
69
+ * Items and Junk Email show nothing.
70
+ */
71
+ export function badgeFor(type: FolderType, count: FolderCount): FolderBadge | undefined {
72
+ switch (type) {
73
+ case "sent_items":
74
+ case "deleted_items":
75
+ case "junk":
76
+ return undefined;
77
+ case "drafts":
78
+ case "outbox":
79
+ return count.total > 0 ? { kind: "total", value: count.total } : undefined;
80
+ default:
81
+ return count.unread > 0 ? { kind: "unread", value: count.unread } : undefined;
82
+ }
83
+ }
84
+
85
+ /** The badge's text for assistive technology: "3 unread", "2 messages". */
86
+ export function badgeLabel(badge: FolderBadge): string {
87
+ return badge.kind === "unread" ? `${badge.value} unread` : `${badge.value} ${badge.value === 1 ? "message" : "messages"}`;
88
+ }
89
+
90
+ /** The unread messages in every mailbox's Inbox - what the tab title counts. */
91
+ export function inboxUnreadTotal(mailboxFolders: { folders: Folder[] }[], counts: Record<string, FolderCount>): number {
92
+ return mailboxFolders.reduce(
93
+ (total, entry) =>
94
+ total + entry.folders.filter((folder) => folder.type === "inbox").reduce((sum, folder) => sum + countOfFolder(folder, counts).unread, 0),
95
+ 0,
96
+ );
97
+ }
98
+
99
+ /** How long after the last change of this page's own has finished a server-published count is trusted again: one published
100
+ * about an earlier state of the folder could otherwise arrive late and put back what the change just took away. */
101
+ export const COUNT_QUIET_MS = 1_500;
102
+
103
+ /** How long after a change the counts are read back from the server. */
104
+ export const COUNT_REFRESH_DELAY_MS = 600;
105
+
106
+ /** How many message uids the "already counted this new message" memory holds. */
107
+ const SEEN_LIMIT = 500;
108
+
109
+ /** An optimistic change in flight: call exactly one of these once the server has answered. */
110
+ export interface CountTracker {
111
+ /** The server accepted the change: keep what was applied, and read the real counts back. */
112
+ settle(): void;
113
+ /** The server refused it (or it failed): put the counts back as they were, and read the real ones back. */
114
+ revert(): void;
115
+ }
116
+
117
+ export interface FolderCounts {
118
+ /** The counts this page has worked out or been told since load, by folder uid - see `countOfFolder()`. */
119
+ counts: Record<string, FolderCount>;
120
+ /** Applies the effect of changing `previous` into `next` now, before the server has answered - see `CountTracker`. */
121
+ track(previous: Message | null | undefined, next: Message | null | undefined): CountTracker;
122
+ /** A new message arrived over the push connection: counts it (once per message) until the real counts are read back.
123
+ * `false` when this one was already counted - a duplicate event. */
124
+ noteCreated(message: Message): boolean;
125
+ /** The server's `Folder` update event's `data`: the folder's real counts - when this page has nothing of its own in flight. */
126
+ applyFolderEvent(data: unknown): void;
127
+ /** Reads every mailbox's real counts back and shows them, after `delayMs` (at once for 0). A later call replaces one still waiting. */
128
+ refresh(delayMs?: number): void;
129
+ }
130
+
131
+ /**
132
+ * Holds the folder counts overlay for `MailShell`. `folders` is every folder the sidebar lists (the overlay starts from what
133
+ * each was loaded with); `mailboxes` is whose folders to read back. The overlay starts again whenever `mailboxes` does.
134
+ */
135
+ export function useFolderCounts(mailboxes: Mailbox[], folders: Folder[]): FolderCounts {
136
+ const [counts, setCounts] = useState<Record<string, FolderCount>>({});
137
+ const latestRef = useRef({ mailboxes, folders });
138
+ latestRef.current = { mailboxes, folders };
139
+ // Bumped by every change of the overlay this page makes, so a read that started before one can tell it is out of date.
140
+ const changesRef = useRef(0);
141
+ const inFlightRef = useRef(0);
142
+ const lastDoneRef = useRef(0);
143
+ const runRef = useRef(0);
144
+ // Assigned during every render, before anything can call it.
145
+ const readBackRef = useRef<() => Promise<void>>(undefined as never);
146
+ const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
147
+ const stoppedRef = useRef(false);
148
+ const seenRef = useRef<Set<string>>(new Set());
149
+
150
+ const adjust = useCallback((deltas: CountDelta[]) => {
151
+ const known = deltas.filter((delta) => latestRef.current.folders.some((folder) => folder.uid === delta.folderUid));
152
+ if (known.length === 0) {
153
+ return;
154
+ }
155
+ changesRef.current++;
156
+ setCounts((previous) => {
157
+ const next = { ...previous };
158
+ for (const delta of known) {
159
+ const folder = latestRef.current.folders.find((f) => f.uid === delta.folderUid)!;
160
+ const base = next[delta.folderUid] ?? { unread: folder.unreadCount, total: folder.totalCount };
161
+ next[delta.folderUid] = {
162
+ unread: Math.max(0, base.unread + delta.unread),
163
+ total: Math.max(0, base.total + delta.total),
164
+ };
165
+ }
166
+ return next;
167
+ });
168
+ }, []);
169
+
170
+ const refresh = useCallback((delayMs: number = COUNT_REFRESH_DELAY_MS) => {
171
+ if (stoppedRef.current) {
172
+ return;
173
+ }
174
+ clearTimeout(timerRef.current);
175
+ if (delayMs <= 0) {
176
+ timerRef.current = undefined;
177
+ void readBackRef.current();
178
+ return;
179
+ }
180
+ timerRef.current = setTimeout(() => void readBackRef.current(), delayMs);
181
+ }, []);
182
+
183
+ // Re-assigned on every render so it reads the latest mailboxes; `refresh` (stable) calls whichever is current.
184
+ readBackRef.current = async () => {
185
+ timerRef.current = undefined;
186
+ const run = ++runRef.current;
187
+ const startedAt = changesRef.current;
188
+ const results = await Promise.all(latestRef.current.mailboxes.map((mailbox) => listFolders(mailbox.uid).catch(() => undefined)));
189
+ if (stoppedRef.current || run !== runRef.current) {
190
+ return;
191
+ }
192
+ if (inFlightRef.current > 0 || changesRef.current !== startedAt) {
193
+ // A change of this page's own began or ended while that was being read, so it may predate it: read again.
194
+ refresh();
195
+ return;
196
+ }
197
+ const fresh: Record<string, FolderCount> = {};
198
+ for (const list of results) {
199
+ for (const folder of list ?? []) {
200
+ fresh[folder.uid] = { unread: folder.unreadCount, total: folder.totalCount };
201
+ }
202
+ }
203
+ setCounts((previous) => {
204
+ const changed = Object.entries(fresh).some(
205
+ ([uid, count]) => previous[uid]?.unread !== count.unread || previous[uid]?.total !== count.total,
206
+ );
207
+ return changed ? { ...previous, ...fresh } : previous;
208
+ });
209
+ };
210
+
211
+ const track = useCallback(
212
+ (previous: Message | null | undefined, next: Message | null | undefined): CountTracker => {
213
+ const deltas = countDeltas(previous, next);
214
+ inFlightRef.current++;
215
+ adjust(deltas);
216
+ let finished = false;
217
+ /** Whether this call was the one that ended it (a tracker ends once). */
218
+ function finish(): boolean {
219
+ if (finished) {
220
+ return false;
221
+ }
222
+ finished = true;
223
+ inFlightRef.current--;
224
+ lastDoneRef.current = Date.now();
225
+ changesRef.current++;
226
+ return true;
227
+ }
228
+ return {
229
+ settle() {
230
+ if (finish()) {
231
+ refresh();
232
+ }
233
+ },
234
+ revert() {
235
+ if (finish()) {
236
+ adjust(deltas.map((delta) => ({ folderUid: delta.folderUid, unread: -delta.unread, total: -delta.total })));
237
+ refresh();
238
+ }
239
+ },
240
+ };
241
+ },
242
+ [adjust, refresh],
243
+ );
244
+
245
+ const noteCreated = useCallback(
246
+ (message: Message) => {
247
+ const seen = seenRef.current;
248
+ if (seen.has(message.uid)) {
249
+ return false;
250
+ }
251
+ seen.add(message.uid);
252
+ if (seen.size > SEEN_LIMIT) {
253
+ seen.delete(seen.values().next().value!);
254
+ }
255
+ adjust(countDeltas(null, message));
256
+ refresh();
257
+ return true;
258
+ },
259
+ [adjust, refresh],
260
+ );
261
+
262
+ const applyFolderEvent = useCallback(
263
+ (data: unknown) => {
264
+ const event = data as { uid?: unknown; unreadCount?: unknown; totalCount?: unknown } | null | undefined;
265
+ const uid = event?.uid;
266
+ const folder = typeof uid === "string" ? latestRef.current.folders.find((f) => f.uid === uid) : undefined;
267
+ if (!folder) {
268
+ return;
269
+ }
270
+ if (inFlightRef.current > 0 || Date.now() - lastDoneRef.current < COUNT_QUIET_MS) {
271
+ // Its number may be about a state older than this page's own change: let a fresh read settle it.
272
+ refresh();
273
+ return;
274
+ }
275
+ const valid = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
276
+ setCounts((previous) => {
277
+ const current = previous[folder.uid] ?? { unread: folder.unreadCount, total: folder.totalCount };
278
+ const unread = valid(event!.unreadCount) ? event!.unreadCount : current.unread;
279
+ const total = valid(event!.totalCount) ? event!.totalCount : current.total;
280
+ return unread === current.unread && total === current.total && previous[folder.uid] ? previous : { ...previous, [folder.uid]: { unread, total } };
281
+ });
282
+ },
283
+ [refresh],
284
+ );
285
+
286
+ // A fresh load of the mailboxes is a fresh start: the folders come with counts of their own. (Keyed on the uids, not the array.)
287
+ const mailboxKey = mailboxes.map((mailbox) => mailbox.uid).join("|");
288
+ useEffect(() => {
289
+ changesRef.current++;
290
+ setCounts((previous) => (Object.keys(previous).length > 0 ? {} : previous));
291
+ }, [mailboxKey]);
292
+
293
+ useEffect(() => {
294
+ stoppedRef.current = false;
295
+ return () => {
296
+ stoppedRef.current = true;
297
+ clearTimeout(timerRef.current);
298
+ };
299
+ }, []);
300
+
301
+ return { counts, track, noteCreated, applyFolderEvent, refresh };
302
+ }
@@ -0,0 +1,87 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import type { ConversationSummary } from "@rapidmx/react-shared/mail/conversationsApi.js";
6
+ import type { Message } from "@rapidmx/react-shared/mail/mailApi.js";
7
+
8
+ /**
9
+ * A short-lived, in-memory copy of a folder's listing, so that switching to a folder that was shown a moment ago (or coming back
10
+ * to Mail from another app) shows its rows on the very frame of the click and revalidates behind them (stale-while-revalidate),
11
+ * instead of replacing the list with a loading state and waiting for the network. It is the client-side router's answer to
12
+ * "folder navigation takes seconds": the list of the folder being switched to is usually already known.
13
+ *
14
+ * Only a plain folder's listing is kept - a search's, an aggregate ("All Mailboxes") view's and a load-more page's rows are not -
15
+ * and what is kept is exactly what was on screen, including the rows "load more" added, the selection and the scroll position,
16
+ * so returning to a folder puts the reader where they left it. A snapshot only makes the first paint fast; the listing is always
17
+ * fetched again and folded in (`mergeFirstPage()`), so nothing shown from here is ever taken as current.
18
+ */
19
+ export interface ListSnapshot {
20
+ messages: Message[];
21
+ conversations: ConversationSummary[];
22
+ hasMore: boolean;
23
+ /** The message that was selected (the reading pane's), if it is still in `messages` when the snapshot is shown. */
24
+ selectedUid: string | null;
25
+ /** The list's `scrollTop`, saved when the listing was left. */
26
+ scrollTop: number;
27
+ /** When it was last written. */
28
+ at: number;
29
+ }
30
+
31
+ /** How long a snapshot may be shown after it was last written. Longer than that the folder is shown as loading, as before. */
32
+ export const LIST_SNAPSHOT_TTL_MS = 5 * 60_000;
33
+
34
+ /** How many folders are remembered; the least recently written is dropped first. */
35
+ export const LIST_SNAPSHOT_MAX = 16;
36
+
37
+ const snapshots = new Map<string, ListSnapshot>();
38
+
39
+ /** Everything that makes one listing different from another: which folder, as messages or conversations, and how it is
40
+ * filtered and sorted. */
41
+ export function listSnapshotKey(parts: {
42
+ mailboxUid: string | undefined;
43
+ folderUid: string | undefined;
44
+ conversations: boolean;
45
+ filter: string;
46
+ labels: string;
47
+ sort: string;
48
+ }): string {
49
+ return [parts.mailboxUid, parts.folderUid, parts.conversations ? "conversations" : "messages", parts.filter, parts.labels, parts.sort].join("|");
50
+ }
51
+
52
+ /** The snapshot for `key`, or `undefined` when there is none or it is older than `LIST_SNAPSHOT_TTL_MS`. */
53
+ export function readListSnapshot(key: string, now: number = Date.now()): ListSnapshot | undefined {
54
+ const snapshot = snapshots.get(key);
55
+ if (!snapshot) {
56
+ return undefined;
57
+ }
58
+ if (now - snapshot.at > LIST_SNAPSHOT_TTL_MS) {
59
+ snapshots.delete(key);
60
+ return undefined;
61
+ }
62
+ return snapshot;
63
+ }
64
+
65
+ /** Remembers a listing as it is on screen (keeping the scroll position saved for it, if any). */
66
+ export function writeListSnapshot(key: string, listing: Omit<ListSnapshot, "scrollTop" | "at">, now: number = Date.now()): void {
67
+ const previous = snapshots.get(key);
68
+ // Re-inserted so that the Map's order is least-recently-written first.
69
+ snapshots.delete(key);
70
+ snapshots.set(key, { ...listing, scrollTop: previous?.scrollTop ?? 0, at: now });
71
+ while (snapshots.size > LIST_SNAPSHOT_MAX) {
72
+ snapshots.delete(snapshots.keys().next().value as string);
73
+ }
74
+ }
75
+
76
+ /** Records how far `key`'s list was scrolled when it was left. A no-op for a listing that was never remembered. */
77
+ export function saveListScroll(key: string, scrollTop: number): void {
78
+ const snapshot = snapshots.get(key);
79
+ if (snapshot) {
80
+ snapshot.scrollTop = scrollTop;
81
+ }
82
+ }
83
+
84
+ /** Forgets every snapshot (sign-out, and tests). */
85
+ export function clearListSnapshots(): void {
86
+ snapshots.clear();
87
+ }
@@ -0,0 +1,47 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /** What `mergeFirstPage()` made of a fresh first page and the rows already on screen. */
7
+ export interface MergedPage<T> {
8
+ /** The rows to show. */
9
+ rows: T[];
10
+ /** How many of the fresh page's rows were not on screen yet - the mail that arrived since. */
11
+ added: number;
12
+ /** `true` when `rows` is exactly the fresh page, because the whole listing fits on it. */
13
+ complete: boolean;
14
+ }
15
+
16
+ /**
17
+ * Folds a freshly fetched first page of a list into the rows already loaded (the first page plus whatever "load more"
18
+ * added), for the quiet refresh that follows a push event or a poll - so new mail appears without the list being reset,
19
+ * re-scrolled or re-selected the way a full reload would.
20
+ *
21
+ * - A page shorter than `pageSize` is the whole listing, so it simply replaces what is shown - which also drops what was
22
+ * deleted or moved elsewhere.
23
+ * - A full page is only the top of a longer listing. Its rows go first, in the server's order, followed by the loaded rows
24
+ * it did not repeat (older ones the reader has scrolled to). What was removed elsewhere from a long, partly loaded list
25
+ * stays until the next real load; nothing here can tell it from a row that merely slid off the first page.
26
+ * - A row in both keeps whichever copy `pick` prefers - by default the fresh one - so, for messages, a row the reader has
27
+ * just changed (a higher `version`) is not put back by a fetch that started before the change landed.
28
+ */
29
+ export function mergeFirstPage<T>(
30
+ current: T[],
31
+ fresh: T[],
32
+ idOf: (row: T) => string,
33
+ pageSize: number,
34
+ pick: (current: T, fresh: T) => T = (_current, next) => next,
35
+ ): MergedPage<T> {
36
+ const currentById = new Map(current.map((row) => [idOf(row), row]));
37
+ const added = fresh.filter((row) => !currentById.has(idOf(row))).length;
38
+ const rows = fresh.map((row) => {
39
+ const existing = currentById.get(idOf(row));
40
+ return existing ? pick(existing, row) : row;
41
+ });
42
+ if (fresh.length < pageSize) {
43
+ return { rows, added, complete: true };
44
+ }
45
+ const freshIds = new Set(fresh.map(idOf));
46
+ return { rows: [...rows, ...current.filter((row) => !freshIds.has(idOf(row)))], added, complete: false };
47
+ }
@@ -0,0 +1,85 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { Message, setMessageRead, setMessagesRead } from "@rapidmx/react-shared/mail/mailApi.js";
6
+ import type { CountTracker } from "./folderCounts.js";
7
+
8
+ /**
9
+ * The one way a message's read state changes in the webmail, so that every place that shows it moves together: the row in
10
+ * the list (and the conversation row's unread count), the folder badge, and the unread styling.
11
+ *
12
+ * It is **optimistic**: the row and the badge change the moment the user acts, the server is asked, and if it refuses the
13
+ * change is taken back. What each place is handed to do that is a `ReadStateSink`.
14
+ */
15
+ export interface ReadStateSink {
16
+ /**
17
+ * Shows a message wherever the caller lists it - the message list, the conversation list's child rows, the open thread.
18
+ * `previous` is given with the optimistic copy (and with the revert of it) and is the copy being replaced, so a
19
+ * conversation row can tell that its unread count went up or down; it is absent for the server's own copy, which
20
+ * differs only in its `version`.
21
+ */
22
+ patch(updated: Message, previous?: Message): void;
23
+ /** Applies the change to the folder badges: `MailShell`'s `trackMessageChange`. */
24
+ track(previous: Message, next: Message): CountTracker;
25
+ }
26
+
27
+ function withRead(message: Message, read: boolean): Message {
28
+ return { ...message, flags: { ...message.flags, read } };
29
+ }
30
+
31
+ /** Whether `message` is not yet in the state `read` asks for. A message with no `read` flag counts as unread. */
32
+ function needsChange(message: Message, read: boolean): boolean {
33
+ return (message.flags.read === true) !== read;
34
+ }
35
+
36
+ /**
37
+ * Marks one message read (`read: true`) or unread. Resolves the server's updated copy, or `undefined` when nothing needed
38
+ * doing (it already was that way) or the server refused - in which case everything has already been put back as it was.
39
+ * Never rejects: like the old mark-as-read, a failure must not get in the way of reading the message.
40
+ */
41
+ export async function setReadState(message: Message, read: boolean, sink: ReadStateSink): Promise<Message | undefined> {
42
+ if (!needsChange(message, read)) {
43
+ return undefined;
44
+ }
45
+ const optimistic = withRead(message, read);
46
+ sink.patch(optimistic, message);
47
+ const tracker = sink.track(message, optimistic);
48
+ try {
49
+ const updated = await setMessageRead(message, read);
50
+ sink.patch(updated);
51
+ tracker.settle();
52
+ return updated;
53
+ } catch {
54
+ sink.patch(message, optimistic);
55
+ tracker.revert();
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * The same for a whole selection, in one request. Resolves the updated messages, in request order. Unlike `setReadState()`
62
+ * it rejects when the server refuses (after putting everything back), because a bulk update is not atomic - some of it may
63
+ * have landed - and the caller's own handling reloads the list and says so (see `bulkUpdateMessages()`).
64
+ */
65
+ export async function setReadStateMany(messages: Message[], read: boolean, sink: ReadStateSink): Promise<Message[]> {
66
+ const changes = messages.filter((message) => needsChange(message, read)).map((message) => ({ message, optimistic: withRead(message, read) }));
67
+ const trackers = changes.map(({ message, optimistic }) => {
68
+ sink.patch(optimistic, message);
69
+ return sink.track(message, optimistic);
70
+ });
71
+ try {
72
+ const updated = await setMessagesRead(messages, read);
73
+ for (const message of updated) {
74
+ sink.patch(message);
75
+ }
76
+ trackers.forEach((tracker) => tracker.settle());
77
+ return updated;
78
+ } catch (err) {
79
+ changes.forEach(({ message, optimistic }, index) => {
80
+ sink.patch(message, optimistic);
81
+ trackers[index].revert();
82
+ });
83
+ throw err;
84
+ }
85
+ }