@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
@@ -0,0 +1,138 @@
1
+ import { formatMailAddress, splitMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
2
+ /**
3
+ * What decides whether a new message is announced, what the announcement says, and the two settings behind it (both per
4
+ * browser, in `localStorage`): whether new-mail pop-ups are on at all, and what the user told the "turn on desktop
5
+ * notifications" offer.
6
+ *
7
+ * The announcement is built only from what the push event carries - the whole `Message`, including `bodyPreview` - and only
8
+ * ever as text: React escapes it on screen and the Notifications API takes plain strings, so nothing here is HTML.
9
+ */
10
+ /** `localStorage` key of the pop-ups switch. Absent means on; `"off"` means the user turned them off. */
11
+ export const NEW_MAIL_POPUPS_KEY = "rapidmx-new-mail-popups";
12
+ /** `localStorage` key of the answer to the desktop-notifications offer: `"later"` (not now) or `"asked"` (they were asked). */
13
+ export const DESKTOP_OFFER_KEY = "rapidmx-desktop-notifications-offer";
14
+ /** How much of the body an announcement quotes. */
15
+ export const PREVIEW_MAX_LENGTH = 140;
16
+ /** A message received longer ago than this is not "new": mail imported or migrated in bulk arrives as `create` events too. */
17
+ export const MAX_NEW_AGE_MS = 6 * 60 * 60 * 1000;
18
+ /** The subject the server stores for an encrypted message (the real one is inside the ciphertext). */
19
+ const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
20
+ function readStorage(key) {
21
+ try {
22
+ return localStorage.getItem(key);
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ function writeStorage(key, value) {
29
+ try {
30
+ if (value === null) {
31
+ localStorage.removeItem(key);
32
+ }
33
+ else {
34
+ localStorage.setItem(key, value);
35
+ }
36
+ }
37
+ catch {
38
+ // Storage blocked or full: the choice lasts until the page is reloaded, no longer.
39
+ }
40
+ }
41
+ /** Whether new-mail pop-ups are on (the default). */
42
+ export function getNewMailPopupsEnabled() {
43
+ return readStorage(NEW_MAIL_POPUPS_KEY) !== "off";
44
+ }
45
+ export function setNewMailPopupsEnabled(enabled) {
46
+ writeStorage(NEW_MAIL_POPUPS_KEY, enabled ? null : "off");
47
+ }
48
+ /** Whether the "turn on desktop notifications" offer has been put away with "Not now" (or answered). */
49
+ export function getDesktopOfferDismissed() {
50
+ return readStorage(DESKTOP_OFFER_KEY) !== null;
51
+ }
52
+ export function setDesktopOfferDismissed(dismissed) {
53
+ writeStorage(DESKTOP_OFFER_KEY, dismissed ? "later" : null);
54
+ }
55
+ export function desktopPermission() {
56
+ return typeof Notification === "undefined" ? "unsupported" : Notification.permission;
57
+ }
58
+ /** Asks the browser for permission - call it from a click. Resolves the answer; never rejects. */
59
+ export async function requestDesktopPermission() {
60
+ if (typeof Notification === "undefined") {
61
+ return "unsupported";
62
+ }
63
+ try {
64
+ return await Notification.requestPermission();
65
+ }
66
+ catch {
67
+ return desktopPermission();
68
+ }
69
+ }
70
+ /** Control characters, and the invisible or bidirectional-override characters that can make text read as something else, as code point ranges. */
71
+ const UNWANTED_RANGES = [
72
+ [0x0000, 0x001f],
73
+ [0x007f, 0x009f],
74
+ [0x200b, 0x200f],
75
+ [0x2028, 0x202e],
76
+ [0x2060, 0x2064],
77
+ [0x2066, 0x2069],
78
+ [0xfeff, 0xfeff],
79
+ ];
80
+ const hex = (code) => "\\u" + code.toString(16).padStart(4, "0");
81
+ const UNWANTED = new RegExp("[" + UNWANTED_RANGES.map(([from, to]) => hex(from) + "-" + hex(to)).join("") + "]+", "g");
82
+ /** `text` with those characters replaced by spaces, whitespace folded, and cut to `max` characters (with an ellipsis). */
83
+ export function cleanPreview(text, max = PREVIEW_MAX_LENGTH) {
84
+ const cleaned = text.replace(UNWANTED, " ").replace(/\s+/g, " ").trim();
85
+ return cleaned.length > max ? cleaned.slice(0, max - 1).trimEnd() + String.fromCharCode(0x2026) : cleaned;
86
+ }
87
+ /** The text of an announcement of `message`. */
88
+ export function noticeFor(message) {
89
+ const { name, address } = splitMailAddress(message.from);
90
+ const encrypted = message.encrypted === true;
91
+ const rawSubject = cleanPreview(message.subject ?? "", 200);
92
+ const subject = !rawSubject || rawSubject === ENCRYPTED_SUBJECT_PLACEHOLDER ? (encrypted ? "(encrypted subject)" : "(no subject)") : rawSubject;
93
+ return {
94
+ uid: message.uid,
95
+ mailboxUid: message.mailboxUid,
96
+ folderUid: message.folderUid,
97
+ // `splitMailAddress()` has already taken control and bidirectional-override characters out of both.
98
+ senderName: name ?? "",
99
+ senderAddress: address,
100
+ subject,
101
+ preview: encrypted ? "Encrypted message" : cleanPreview(message.bodyPreview ?? ""),
102
+ href: `/messages/${encodeURIComponent(message.uid)}`,
103
+ };
104
+ }
105
+ /** Every address that is this user's own, lowercased - a message from one of them is not new mail worth announcing. */
106
+ export function ownAddressesOf(mailboxes) {
107
+ const own = new Set();
108
+ for (const mailbox of mailboxes) {
109
+ for (const address of [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])]) {
110
+ if (address) {
111
+ own.add(address.trim().toLowerCase());
112
+ }
113
+ }
114
+ }
115
+ return own;
116
+ }
117
+ /**
118
+ * Whether the arrival of `message` is worth announcing: unread mail in an Inbox (not Drafts, Sent Items, Outbox, Deleted Items,
119
+ * Junk Email or quarantine, which are other folders), that Focused Inbox did not put under Other, that its own user did not
120
+ * send, and that is not old (a bulk import).
121
+ */
122
+ export function shouldAnnounce(message, context) {
123
+ if (context.folders.find((folder) => folder.uid === message.folderUid)?.type !== "inbox") {
124
+ return false;
125
+ }
126
+ if (message.flags.read === true || message.inferenceClassification === "other") {
127
+ return false;
128
+ }
129
+ if (context.ownAddresses.has((message.from?.address ?? "").trim().toLowerCase())) {
130
+ return false;
131
+ }
132
+ const received = Date.parse(message.receivedDate);
133
+ return Number.isNaN(received) || (context.now ?? Date.now()) - received < MAX_NEW_AGE_MS;
134
+ }
135
+ /** `Name <address>`, or the address alone - the desktop notification's title. */
136
+ export function noticeSender(notice) {
137
+ return formatMailAddress({ address: notice.senderAddress, displayName: notice.senderName || undefined });
138
+ }
@@ -0,0 +1,55 @@
1
+ import { Folder, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
2
+ import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
3
+ import { LiveUpdates } from "./useMailLiveUpdates.js";
4
+ import type { FolderCounts } from "./folderCounts.js";
5
+ import { NewMailNotifications } from "./useNewMailNotifications.js";
6
+ /** Page size of the one `listMailboxes()` call. */
7
+ export declare const MAILBOX_LIST_LIMIT = 100;
8
+ /** Well-known folders sort first in the sidebar, in Gmail/Outlook's conventional order; anything else (incl. `user`) sorts after, alphabetically. */
9
+ export declare const FOLDER_ORDER: string[];
10
+ /**
11
+ * A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see `CalendarShell`/`ContactsShell`/`TasksShell`), not
12
+ * Mail - `listFolders()` returns every well-known folder for the mailbox regardless of which app owns it, so Mail's own folder tree filters down
13
+ * to just the mail ones itself, or those other apps' folders leak into its sidebar.
14
+ */
15
+ export declare const MAIL_FOLDER_TYPES: Set<string>;
16
+ export type MailConnectionStatus = "checking" | "error" | "ready";
17
+ /** Everything the mail apps share about the user's mailboxes, and the one live connection that keeps it current. */
18
+ export interface MailConnection {
19
+ /** Where the one `listMailboxes()` call is. */
20
+ status: MailConnectionStatus;
21
+ /** Why `status` is `"error"`. */
22
+ error: string | null;
23
+ mailboxes: Mailbox[];
24
+ /** Every accessible mailbox's own mail folders, one entry per mailbox - see `MailboxFolders`. */
25
+ mailboxFolders: MailboxFolders[];
26
+ /** Their folder lists are still being fetched. */
27
+ foldersLoading: boolean;
28
+ /** Files a folder that was just created (by this client or another) under its mailbox, so the sidebar and every folder picker show it. */
29
+ onFolderCreated: (folder: Folder) => void;
30
+ /** Bumped whenever new mail may have arrived - see `useMailLiveUpdates()`. */
31
+ live: LiveUpdates;
32
+ /** The folder badges' numbers and how to change them. */
33
+ folderCounts: FolderCounts;
34
+ /** The new-mail pop-ups and desktop notifications, and what feeds them. */
35
+ notifications: NewMailNotifications;
36
+ }
37
+ /** What the persistent app frame (`AppChrome`) offers to the Mail shell so that it doesn't open a second connection. `null` outside a frame. */
38
+ export declare const MailConnectionContext: import("react").Context<MailConnection | null>;
39
+ export interface UseMailConnectionOptions {
40
+ /** Nothing is fetched, and no socket opened, without a signed-in user. */
41
+ userUid?: string;
42
+ /** Runs only while true. The frame turns it on; a Mail shell rendered outside a frame turns it on for itself; and the one that isn't
43
+ * the owner leaves it off - so exactly one connection exists, whichever of them the page is rendered by. */
44
+ enabled: boolean;
45
+ /** Opens a message from a desktop notification's click (the router's `useNavigate()`); defaults to a plain navigation. */
46
+ open?: (href: string) => void;
47
+ }
48
+ /**
49
+ * The user's mailboxes and their mail folders, plus the live connection that keeps them - and the badges, the list, the new-mail
50
+ * pop-ups - current. It is everything `MailShell` used to do on mount, gathered so that it can live in the persistent app frame instead: the
51
+ * push socket (the server allows ten per user), the safety-net poll, the folder counters and the pop-ups then belong to the signed-in session
52
+ * rather than to whichever page happens to be showing, and keep working in Calendar, Contacts, Tasks and Settings. `MailShell` reads the same
53
+ * state from `MailConnectionContext` when there is a frame, and runs this hook itself when there isn't (a page rendered outside the router).
54
+ */
55
+ export declare function useMailConnection({ userUid, enabled, open }: UseMailConnectionOptions): MailConnection;
@@ -0,0 +1,96 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { createContext, useCallback, useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import { useMailLiveUpdates } from "./useMailLiveUpdates.js";
9
+ import { useNewMailNotifications } from "./useNewMailNotifications.js";
10
+ /** Page size of the one `listMailboxes()` call. */
11
+ export const MAILBOX_LIST_LIMIT = 100;
12
+ /** Well-known folders sort first in the sidebar, in Gmail/Outlook's conventional order; anything else (incl. `user`) sorts after, alphabetically. */
13
+ export const FOLDER_ORDER = ["inbox", "drafts", "outbox", "sent_items", "junk", "archive", "deleted_items"];
14
+ /**
15
+ * A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see `CalendarShell`/`ContactsShell`/`TasksShell`), not
16
+ * Mail - `listFolders()` returns every well-known folder for the mailbox regardless of which app owns it, so Mail's own folder tree filters down
17
+ * to just the mail ones itself, or those other apps' folders leak into its sidebar.
18
+ */
19
+ export const MAIL_FOLDER_TYPES = new Set([...FOLDER_ORDER, "user"]);
20
+ /** What the persistent app frame (`AppChrome`) offers to the Mail shell so that it doesn't open a second connection. `null` outside a frame. */
21
+ export const MailConnectionContext = createContext(null);
22
+ /**
23
+ * The user's mailboxes and their mail folders, plus the live connection that keeps them - and the badges, the list, the new-mail
24
+ * pop-ups - current. It is everything `MailShell` used to do on mount, gathered so that it can live in the persistent app frame instead: the
25
+ * push socket (the server allows ten per user), the safety-net poll, the folder counters and the pop-ups then belong to the signed-in session
26
+ * rather than to whichever page happens to be showing, and keep working in Calendar, Contacts, Tasks and Settings. `MailShell` reads the same
27
+ * state from `MailConnectionContext` when there is a frame, and runs this hook itself when there isn't (a page rendered outside the router).
28
+ */
29
+ export function useMailConnection({ userUid, enabled, open }) {
30
+ const [status, setStatus] = useState("checking");
31
+ const [error, setError] = useState(null);
32
+ const [mailboxes, setMailboxes] = useState([]);
33
+ const [mailboxFolders, setMailboxFolders] = useState([]);
34
+ const [foldersLoading, setFoldersLoading] = useState(true);
35
+ useEffect(() => {
36
+ if (!userUid || !enabled) {
37
+ return;
38
+ }
39
+ listMailboxes({ limit: MAILBOX_LIST_LIMIT })
40
+ .then((result) => {
41
+ setMailboxes(result);
42
+ setStatus("ready");
43
+ })
44
+ .catch((err) => {
45
+ setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
46
+ setStatus("error");
47
+ });
48
+ }, [userUid, enabled]);
49
+ // Fans out one listFolders() call per accessible mailbox in parallel - each call catches its own failure into a MailboxFolders.error
50
+ // rather than letting Promise.all reject, so one mailbox's fetch failure renders that section's own inline Alert instead of blanking out
51
+ // every other mailbox's folder tree.
52
+ useEffect(() => {
53
+ if (mailboxes.length === 0) {
54
+ // Deliberately leaves foldersLoading as-is: this also runs once before listMailboxes() has resolved, and clearing it here would
55
+ // briefly render an empty sidebar the moment mailboxes arrive, before their folders do. A genuinely mailbox-less caller gets
56
+ // MailboxProvisioning.
57
+ setMailboxFolders([]);
58
+ return;
59
+ }
60
+ setFoldersLoading(true);
61
+ void Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid)
62
+ .then((result) => ({ mailbox, folders: result.filter((f) => MAIL_FOLDER_TYPES.has(f.type)) }))
63
+ .catch((err) => ({
64
+ mailbox,
65
+ folders: [],
66
+ error: err instanceof ApiRequestError ? err.message : "Could not load folders.",
67
+ }))))
68
+ .then(setMailboxFolders)
69
+ .finally(() => setFoldersLoading(false));
70
+ }, [mailboxes]);
71
+ /** Files a newly created folder under its own mailbox, leaving every other mailbox's list untouched - and ignoring a type the
72
+ * sidebar doesn't list at all, the same filter the fetch above applies. */
73
+ const onFolderCreated = useCallback((folder) => {
74
+ if (!MAIL_FOLDER_TYPES.has(folder.type)) {
75
+ return;
76
+ }
77
+ setMailboxFolders((prev) => prev.map((entry) =>
78
+ // Not one already there: another client's create event can arrive after (or twice, or alongside) our own.
79
+ entry.mailbox.uid === folder.mailboxUid && !entry.folders.some((existing) => existing.uid === folder.uid)
80
+ ? { ...entry, folders: [...entry.folders, folder] }
81
+ : entry));
82
+ }, []);
83
+ // New mail without a reload: push events for every folder, plus a safety-net poll. Held in state beside `mailboxFolders` rather than
84
+ // written into it, so a refreshed unread count never looks like a change of folders to the list on screen (which reloads, and forgets its
85
+ // selection, whenever the folders do). A pop-up (and, in the background, a desktop notification) for each new message the connection
86
+ // announces.
87
+ const notifications = useNewMailNotifications({ mailboxes, mailboxFolders, open });
88
+ const { live, folderCounts } = useMailLiveUpdates({
89
+ userUid: enabled ? userUid : undefined,
90
+ mailboxes,
91
+ mailboxFolders,
92
+ onFolderCreated,
93
+ onMessageCreated: notifications.announce,
94
+ });
95
+ return { status, error, mailboxes, mailboxFolders, foldersLoading, onFolderCreated, live, folderCounts, notifications };
96
+ }
@@ -1,5 +1,6 @@
1
- import { Folder, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
1
+ import { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
2
2
  import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
3
+ import { FolderCounts } from "./folderCounts.js";
3
4
  /** How often the safety-net poll runs while the tab is visible. The push socket does the real work; this is for a network
4
5
  * that blocks or silently drops it, and for events published while it was reconnecting (Redis pub/sub is never replayed). */
5
6
  export declare const LIVE_POLL_INTERVAL_MS = 45000;
@@ -28,21 +29,27 @@ export interface UseMailLiveUpdatesOptions {
28
29
  mailboxFolders: MailboxFolders[];
29
30
  /** Called with a folder another client (or this one) created, so the sidebar shows it. Called only for one not already known. */
30
31
  onFolderCreated: (folder: Folder) => void;
32
+ /** Called with each new message the push connection announces (a `create` event) - once per message, never for a list
33
+ * refetch, the initial load or a reconnect (nothing published while the socket was down is replayed). The caller decides
34
+ * whether it is worth telling the user about. */
35
+ onMessageCreated?: (message: Message) => void;
31
36
  }
32
37
  export interface MailLiveUpdates {
33
38
  live: LiveUpdates;
34
- /** The latest unread count of each folder that has been refreshed since load, by folder uid - what the sidebar's badges show
35
- * instead of the count the folder list was loaded with. */
36
- unreadCounts: Record<string, number>;
39
+ /** The folder badges' numbers and how to change them - see `useFolderCounts()`. Kept in an overlay of their own, **not** in
40
+ * `mailboxFolders`, because the list effect reloads - and forgets the selection - whenever `mailboxFolders` changes. */
41
+ folderCounts: FolderCounts;
37
42
  }
38
43
  /**
39
44
  * Keeps Mail current without a page reload. One shared push connection per tab (`getPushClient()`) is subscribed to every
40
45
  * folder of every accessible mailbox; a message event for any of them, a reconnect, a poll of the safety net (every
41
46
  * `LIVE_POLL_INTERVAL_MS` while the tab is visible - and the only mechanism, silently, where the socket can't connect), the
42
47
  * tab coming back to the front or the browser coming back online, all end in the same debounced refresh: `live` is bumped
43
- * (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's unread count is re-read.
48
+ * (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's counts are re-read. A new message
49
+ * also bumps its folder's badge at once and is announced to `onMessageCreated`, and the server's `Folder` update events (the
50
+ * folder's real counts) are applied to the badges - see `useFolderCounts()`.
44
51
  *
45
52
  * Sign-out closes the socket for good: another tab's or this one's, heard on the same channel `AppShell` listens on.
46
53
  * Renders nothing and does nothing where there is no window (server-side rendering).
47
54
  */
48
- export declare function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated }: UseMailLiveUpdatesOptions): MailLiveUpdates;
55
+ export declare function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated, onMessageCreated }: UseMailLiveUpdatesOptions): MailLiveUpdates;
@@ -3,9 +3,9 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import { useEffect, useMemo, useRef, useState } from "react";
6
- import { listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
7
6
  import { getPushClient } from "@rapidmx/react-shared/mail/pushClient.js";
8
7
  import { SIGN_OUT_CHANNEL } from "../search/localIndexRpcClient.js";
8
+ import { useFolderCounts } from "./folderCounts.js";
9
9
  /** How often the safety-net poll runs while the tab is visible. The push socket does the real work; this is for a network
10
10
  * that blocks or silently drops it, and for events published while it was reconnecting (Redis pub/sub is never replayed). */
11
11
  export const LIVE_POLL_INTERVAL_MS = 45000;
@@ -31,6 +31,14 @@ function isFolder(value) {
31
31
  const folder = value;
32
32
  return !!folder && typeof folder.uid === "string" && typeof folder.mailboxUid === "string" && typeof folder.type === "string";
33
33
  }
34
+ function isMessage(value) {
35
+ const message = value;
36
+ return (!!message &&
37
+ typeof message.uid === "string" &&
38
+ typeof message.folderUid === "string" &&
39
+ typeof message.flags === "object" &&
40
+ message.flags !== null);
41
+ }
34
42
  /** The folder a message event is about: the message's own `folderUid`, else the channel it arrived on (when the server said). */
35
43
  function folderOfMessageEvent(event) {
36
44
  const data = event.data;
@@ -44,17 +52,20 @@ function folderOfMessageEvent(event) {
44
52
  * folder of every accessible mailbox; a message event for any of them, a reconnect, a poll of the safety net (every
45
53
  * `LIVE_POLL_INTERVAL_MS` while the tab is visible - and the only mechanism, silently, where the socket can't connect), the
46
54
  * tab coming back to the front or the browser coming back online, all end in the same debounced refresh: `live` is bumped
47
- * (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's unread count is re-read.
55
+ * (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's counts are re-read. A new message
56
+ * also bumps its folder's badge at once and is announced to `onMessageCreated`, and the server's `Folder` update events (the
57
+ * folder's real counts) are applied to the badges - see `useFolderCounts()`.
48
58
  *
49
59
  * Sign-out closes the socket for good: another tab's or this one's, heard on the same channel `AppShell` listens on.
50
60
  * Renders nothing and does nothing where there is no window (server-side rendering).
51
61
  */
52
- export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated }) {
62
+ export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated, onMessageCreated }) {
53
63
  const [live, setLive] = useState(NO_LIVE_UPDATES);
54
- const [unreadCounts, setUnreadCounts] = useState({});
64
+ const folders = useMemo(() => mailboxFolders.flatMap((entry) => entry.folders), [mailboxFolders]);
65
+ const folderCounts = useFolderCounts(mailboxes, folders);
55
66
  // The latest inputs, for the long-lived listeners below.
56
- const latestRef = useRef({ mailboxes, mailboxFolders, onFolderCreated });
57
- latestRef.current = { mailboxes, mailboxFolders, onFolderCreated };
67
+ const latestRef = useRef({ mailboxes, mailboxFolders, onFolderCreated, onMessageCreated, folderCounts });
68
+ latestRef.current = { mailboxes, mailboxFolders, onFolderCreated, onMessageCreated, folderCounts };
58
69
  const channels = useMemo(() => pushChannelsFor(mailboxFolders, mailboxes), [mailboxFolders, mailboxes]);
59
70
  const channelKey = channels.join("|");
60
71
  const channelsRef = useRef(channels);
@@ -68,25 +79,7 @@ export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolde
68
79
  let timer;
69
80
  let pendingFolders = new Set();
70
81
  let pendingUnknown = false;
71
- let countsRun = 0;
72
82
  let everOpen = false;
73
- async function refreshCounts() {
74
- const run = ++countsRun;
75
- const results = await Promise.all(latestRef.current.mailboxes.map((mailbox) => listFolders(mailbox.uid).catch(() => undefined)));
76
- if (stopped || run !== countsRun) {
77
- return;
78
- }
79
- const fresh = {};
80
- for (const folders of results) {
81
- for (const folder of folders ?? []) {
82
- fresh[folder.uid] = folder.unreadCount;
83
- }
84
- }
85
- setUnreadCounts((previous) => {
86
- const changed = Object.entries(fresh).some(([uid, count]) => previous[uid] !== count);
87
- return changed ? { ...previous, ...fresh } : previous;
88
- });
89
- }
90
83
  // Never runs once `stopped`: whatever sets it clears the timer first.
91
84
  function flush() {
92
85
  timer = undefined;
@@ -94,7 +87,7 @@ export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolde
94
87
  pendingFolders = new Set();
95
88
  pendingUnknown = false;
96
89
  setLive((previous) => ({ tick: previous.tick + 1, folderUids }));
97
- void refreshCounts();
90
+ latestRef.current.folderCounts.refresh(0);
98
91
  }
99
92
  /** Asks for a refresh: of the given folders, or (no argument) of whatever may have changed. */
100
93
  function schedule(folderUid) {
@@ -111,6 +104,12 @@ export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolde
111
104
  }
112
105
  const offEvent = client.onEvent((event) => {
113
106
  if (MESSAGE_EVENT.test(event.type) && MESSAGE_ACTIONS.has(event.action ?? "")) {
107
+ if (event.action === "create" && isMessage(event.data)) {
108
+ // Once per message, whatever else the event triggers: the badge first, then whoever announces it.
109
+ if (latestRef.current.folderCounts.noteCreated(event.data)) {
110
+ latestRef.current.onMessageCreated?.(event.data);
111
+ }
112
+ }
114
113
  schedule(folderOfMessageEvent(event));
115
114
  }
116
115
  else if (FOLDER_EVENT.test(event.type) && event.action === "create" && isFolder(event.data)) {
@@ -120,6 +119,9 @@ export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolde
120
119
  latestRef.current.onFolderCreated(folder);
121
120
  }
122
121
  }
122
+ else if (FOLDER_EVENT.test(event.type) && event.action === "update") {
123
+ latestRef.current.folderCounts.applyFolderEvent(event.data);
124
+ }
123
125
  });
124
126
  // Anything published while the socket was down is gone for good, so every reconnect is followed by a refresh.
125
127
  const offStatus = client.onStatus((status) => {
@@ -176,5 +178,5 @@ export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolde
176
178
  getPushClient().setChannels(channels);
177
179
  }
178
180
  }, [userUid, channelKey]);
179
- return { live, unreadCounts };
181
+ return { live, folderCounts };
180
182
  }
@@ -0,0 +1,12 @@
1
+ import type { Message } from "@rapidmx/react-shared/mail/mailApi.js";
2
+ /**
3
+ * Marks a message read when it is opened in a reading pane, through `setReadState()`: the row and the folder badge change at
4
+ * once. `onPatched` is handed each copy of the message as it changes (the optimistic one, then the server's, or the original
5
+ * again if the server refused).
6
+ *
7
+ * Replaces `@rapidmx/react-shared`'s `useMarkMessageRead`, which waits for the server before telling anyone and drops that
8
+ * answer if the message changes meanwhile - which the optimistic copy itself does. A message is asked for once each time it is
9
+ * opened: one the reader then marks unread stays unread while it is open, and one whose request failed is not retried in a loop
10
+ * (its own revert changes it again), only when it is opened again.
11
+ */
12
+ export declare function useMarkMessageRead(message: Message | null, onPatched: (updated: Message, previous?: Message) => void): void;
@@ -0,0 +1,44 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useRef } from "react";
6
+ import { useMailShell } from "../components/mail/layout/MailShell.js";
7
+ import { setReadState } from "./messageReadState.js";
8
+ /**
9
+ * Marks a message read when it is opened in a reading pane, through `setReadState()`: the row and the folder badge change at
10
+ * once. `onPatched` is handed each copy of the message as it changes (the optimistic one, then the server's, or the original
11
+ * again if the server refused).
12
+ *
13
+ * Replaces `@rapidmx/react-shared`'s `useMarkMessageRead`, which waits for the server before telling anyone and drops that
14
+ * answer if the message changes meanwhile - which the optimistic copy itself does. A message is asked for once each time it is
15
+ * opened: one the reader then marks unread stays unread while it is open, and one whose request failed is not retried in a loop
16
+ * (its own revert changes it again), only when it is opened again.
17
+ */
18
+ export function useMarkMessageRead(message, onPatched) {
19
+ const { trackMessageChange } = useMailShell();
20
+ const openUidRef = useRef(undefined);
21
+ const requestedRef = useRef(new Set());
22
+ const latestRef = useRef({ onPatched, trackMessageChange });
23
+ latestRef.current = { onPatched, trackMessageChange };
24
+ useEffect(() => {
25
+ if (message?.uid !== openUidRef.current) {
26
+ // Another message (or none) is open now: whatever was asked about the last one is over.
27
+ openUidRef.current = message?.uid;
28
+ requestedRef.current.clear();
29
+ }
30
+ if (!message || requestedRef.current.has(message.uid)) {
31
+ return;
32
+ }
33
+ // Every opening is asked about once - a message that is already read included, so that marking it unread while it is open (the
34
+ // keyboard's Ctrl+U) doesn't make this run again and read it straight back.
35
+ requestedRef.current.add(message.uid);
36
+ if (message.flags.read === true) {
37
+ return;
38
+ }
39
+ void setReadState(message, true, {
40
+ patch: (updated, previous) => latestRef.current.onPatched(updated, previous),
41
+ track: (previous, next) => latestRef.current.trackMessageChange(previous, next),
42
+ });
43
+ }, [message]);
44
+ }
@@ -0,0 +1,41 @@
1
+ import type { Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
2
+ import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
3
+ import { NewMailNotice } from "./newMailNotifications.js";
4
+ /** The most pop-ups on screen at once; a new one pushes the oldest off. */
5
+ export declare const MAX_TOASTS = 3;
6
+ /** How long a pop-up stays before it goes by itself (while it is neither hovered nor focused, and the tab is in view). */
7
+ export declare const TOAST_DURATION_MS = 8000;
8
+ /** At most this many desktop notifications in `DESKTOP_BURST_WINDOW_MS`: a flood of mail is a few notifications, not a hundred. */
9
+ export declare const DESKTOP_BURST_LIMIT = 5;
10
+ export declare const DESKTOP_BURST_WINDOW_MS = 30000;
11
+ export interface NewMailNotifications {
12
+ /** The pop-ups on screen, oldest first. */
13
+ toasts: NewMailNotice[];
14
+ /** Takes one off the screen. */
15
+ dismiss(uid: string): void;
16
+ /** Whether the first pop-up should also offer to turn on desktop notifications: the browser can, has not been asked,
17
+ * and the user has not said "Not now". */
18
+ offerDesktop: boolean;
19
+ /** The offer's "Turn on desktop notifications": asks the browser. Must be called from a click. */
20
+ enableDesktop(): Promise<void>;
21
+ /** The offer's "Not now": remembered, so it is not made again. */
22
+ declineDesktop(): void;
23
+ /** Announces a message that just arrived - if it is worth it (see `shouldAnnounce()`) and pop-ups are on. Stable. */
24
+ announce(message: Message): void;
25
+ }
26
+ export interface UseNewMailNotificationsOptions {
27
+ mailboxes: Mailbox[];
28
+ mailboxFolders: MailboxFolders[];
29
+ /** Opens a message from a desktop notification's click. Defaults to a plain navigation. */
30
+ open?: (href: string) => void;
31
+ }
32
+ /**
33
+ * New-mail announcements: an in-app pop-up for each message that arrives (`MAX_TOASTS` at most on screen), and - while the tab
34
+ * is in the background and the user has allowed it - a desktop notification with the same content, one per message however
35
+ * many events name it. `announce()` is what `useMailLiveUpdates()`'s `onMessageCreated` calls; nothing else feeds it, so a
36
+ * page load, a list refetch and a reconnect never announce anything.
37
+ *
38
+ * Nothing is ever asked of the browser on load: permission is requested only by `enableDesktop()`, from the offer in the
39
+ * first pop-up, or from Settings. What the user answered is remembered in `localStorage` (see `newMailNotifications.ts`).
40
+ */
41
+ export declare function useNewMailNotifications({ mailboxes, mailboxFolders, open }: UseNewMailNotificationsOptions): NewMailNotifications;