@rapidmx/web-client 0.15.1 → 0.17.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 (318) hide show
  1. package/README.md +381 -373
  2. package/apps/admin/branding/index.tsx +39 -39
  3. package/apps/admin/data-requests/index.tsx +482 -480
  4. package/apps/admin/distribution-lists/new/index.tsx +3 -1
  5. package/apps/admin/domains/[uid].tsx +166 -164
  6. package/apps/admin/domains/new/index.tsx +3 -1
  7. package/apps/admin/escrow-scopes/[uid].tsx +350 -348
  8. package/apps/admin/escrow-scopes/new/index.tsx +3 -1
  9. package/apps/admin/index.tsx +129 -124
  10. package/apps/admin/mailboxes/[uid].tsx +271 -224
  11. package/apps/admin/mailboxes/new/index.tsx +30 -28
  12. package/apps/admin/plugins/index.tsx +15 -15
  13. package/apps/admin/retention-policy/index.tsx +39 -39
  14. package/apps/admin/signing-certificates/index.tsx +343 -343
  15. package/apps/admin/transport-rules/new/index.tsx +3 -1
  16. package/apps/escrow/audit-log/index.tsx +196 -196
  17. package/apps/escrow/matters/[uid].tsx +621 -621
  18. package/apps/escrow/matters/new/index.tsx +3 -1
  19. package/apps/shared/auth/adminAccess.ts +99 -99
  20. package/apps/shared/components/admin/layout/AdminShell.tsx +381 -378
  21. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
  22. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
  23. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
  24. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  25. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  26. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +50 -3
  27. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  28. package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1545
  29. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  30. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  31. package/apps/shared/components/admin/setup/SetupWizard.tsx +446 -444
  32. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  33. package/apps/shared/components/calendar/EventEditor.tsx +5 -10
  34. package/apps/shared/components/calendar/EventFormParts.tsx +16 -47
  35. package/apps/shared/components/calendar/EventModal.tsx +206 -206
  36. package/apps/shared/components/calendar/GuestInput.tsx +100 -0
  37. package/apps/shared/components/calendar/MonthView.tsx +185 -185
  38. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
  39. package/apps/shared/components/calendar/RequestChangeForm.tsx +23 -54
  40. package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
  41. package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
  42. package/apps/shared/components/calendar/allDay.ts +124 -124
  43. package/apps/shared/components/calendar/eventForm.ts +2 -2
  44. package/apps/shared/components/calendar/eventFormat.ts +49 -22
  45. package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
  46. package/apps/shared/components/contacts/ContactForm.tsx +383 -383
  47. package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
  48. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
  49. package/apps/shared/components/contacts/layout/ContactsShell.tsx +218 -216
  50. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  51. package/apps/shared/components/layout/AppShell.tsx +461 -480
  52. package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
  53. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  54. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
  55. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  56. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  57. package/apps/shared/components/layout/UserMenu.tsx +439 -424
  58. package/apps/shared/components/mail/ConversationList.tsx +323 -323
  59. package/apps/shared/components/mail/ConversationThreadPane.tsx +613 -491
  60. package/apps/shared/components/mail/EmptyFolderBar.tsx +41 -0
  61. package/apps/shared/components/mail/MailSelectionBar.tsx +240 -227
  62. package/apps/shared/components/mail/MenuButton.tsx +31 -11
  63. package/apps/shared/components/mail/MessageDetailPane.tsx +1757 -1559
  64. package/apps/shared/components/mail/PermanentDeleteDialog.tsx +47 -0
  65. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
  66. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  67. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1677
  68. package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
  69. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  70. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  71. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  72. package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
  73. package/apps/shared/components/mail/layout/MailShell.tsx +161 -134
  74. package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
  75. package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -0
  76. package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -0
  77. package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
  78. package/apps/shared/components/mail/reading/messageExport.ts +59 -0
  79. package/apps/shared/components/mail/reading/printMessage.ts +99 -0
  80. package/apps/shared/components/mail/reading/useMessageActions.ts +443 -0
  81. package/apps/shared/components/mail/verificationSeals.ts +125 -125
  82. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  83. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  84. package/apps/shared/components/settings/layout/SettingsShell.tsx +257 -254
  85. package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
  86. package/apps/shared/components/tasks/layout/TasksShell.tsx +219 -217
  87. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  88. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  89. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  90. package/apps/shared/keyboard/dispatch.ts +124 -124
  91. package/apps/shared/keyboard/format.ts +89 -89
  92. package/apps/shared/keyboard/keymap.ts +114 -114
  93. package/apps/shared/keyboard/registry.ts +65 -65
  94. package/apps/shared/keyboard/targets.ts +79 -79
  95. package/apps/shared/mail/folderCounts.ts +17 -0
  96. package/apps/shared/mail/folderOfType.ts +49 -0
  97. package/apps/shared/mail/folderTree.ts +143 -143
  98. package/apps/shared/mail/listAllPages.ts +39 -39
  99. package/apps/shared/mail/newMailNotifications.ts +171 -171
  100. package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
  101. package/apps/shared/mail/outbox/sendJob.ts +19 -1
  102. package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
  103. package/apps/shared/mail/permanentDelete.ts +160 -0
  104. package/apps/shared/mail/primaryMailbox.ts +71 -0
  105. package/apps/shared/mail/reportNotices.ts +66 -0
  106. package/apps/shared/mail/senderBlocking.ts +141 -0
  107. package/apps/shared/mail/useCollapsedSections.ts +146 -0
  108. package/apps/shared/mail/useMailConnection.ts +205 -203
  109. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  110. package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -0
  111. package/apps/shared/mail/useMarkMessageRead.ts +47 -47
  112. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  113. package/apps/shared/mail/usePermanentDelete.tsx +160 -0
  114. package/apps/shared/navigation/frameContext.tsx +1 -1
  115. package/apps/shared/navigation/index.ts +43 -0
  116. package/apps/shared/navigation/pageTitle.ts +19 -0
  117. package/apps/shared/notifications/store.ts +560 -560
  118. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
  119. package/apps/shared/search/crossMailboxSearch.ts +169 -0
  120. package/apps/shared/search/localIndexBuilder.ts +481 -481
  121. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  122. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  123. package/apps/shared/signing/enrollmentView.ts +251 -251
  124. package/apps/shared/signing/useNow.ts +19 -19
  125. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
  126. package/apps/shared/styles/app.css +396 -386
  127. package/apps/www/_shell.tsx +82 -0
  128. package/apps/www/calendar/index.tsx +581 -571
  129. package/apps/www/contacts/[uid].tsx +112 -109
  130. package/apps/www/contacts/index.tsx +14 -3
  131. package/apps/www/index.tsx +2962 -2479
  132. package/apps/www/messages/[uid].tsx +139 -135
  133. package/apps/www/settings/appearance/index.tsx +5 -2
  134. package/apps/www/settings/auto-reply/index.tsx +136 -133
  135. package/apps/www/settings/blocked-senders/index.tsx +303 -0
  136. package/apps/www/settings/encryption/index.tsx +1290 -1287
  137. package/apps/www/settings/filters/[uid].tsx +179 -176
  138. package/apps/www/settings/filters/index.tsx +105 -102
  139. package/apps/www/settings/filters/new/index.tsx +165 -140
  140. package/apps/www/settings/labels/index.tsx +207 -204
  141. package/apps/www/settings/privacy/index.tsx +495 -492
  142. package/apps/www/settings/profile/index.tsx +251 -248
  143. package/apps/www/settings/read-receipts/index.tsx +150 -147
  144. package/apps/www/settings/sharing/index.tsx +259 -256
  145. package/apps/www/settings/signatures/[uid].tsx +175 -172
  146. package/apps/www/settings/signatures/index.tsx +91 -88
  147. package/apps/www/settings/signatures/new/index.tsx +138 -135
  148. package/apps/www/tasks/index.tsx +654 -649
  149. package/dist/apps/admin/data-requests/index.js +1 -1
  150. package/dist/apps/admin/distribution-lists/new/index.js +3 -1
  151. package/dist/apps/admin/domains/[uid].js +3 -1
  152. package/dist/apps/admin/domains/new/index.js +3 -1
  153. package/dist/apps/admin/escrow-scopes/[uid].js +3 -1
  154. package/dist/apps/admin/escrow-scopes/new/index.js +3 -1
  155. package/dist/apps/admin/index.js +5 -2
  156. package/dist/apps/admin/mailboxes/[uid].js +15 -3
  157. package/dist/apps/admin/mailboxes/new/index.js +3 -1
  158. package/dist/apps/admin/transport-rules/new/index.js +3 -1
  159. package/dist/apps/escrow/matters/new/index.js +3 -1
  160. package/dist/apps/shared/components/admin/layout/AdminShell.js +6 -3
  161. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
  162. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
  163. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
  164. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
  165. package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
  166. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +25 -3
  167. package/dist/apps/shared/components/admin/setup/SetupWizard.js +3 -1
  168. package/dist/apps/shared/components/calendar/EventEditor.js +5 -10
  169. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -2
  170. package/dist/apps/shared/components/calendar/EventFormParts.js +5 -16
  171. package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
  172. package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
  173. package/dist/apps/shared/components/calendar/RequestChangeForm.js +11 -22
  174. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -2
  175. package/dist/apps/shared/components/calendar/eventFormat.d.ts +21 -8
  176. package/dist/apps/shared/components/calendar/eventFormat.js +50 -23
  177. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +5 -4
  178. package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
  179. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  180. package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
  181. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +7 -5
  182. package/dist/apps/shared/components/layout/AppShell.d.ts +8 -11
  183. package/dist/apps/shared/components/layout/AppShell.js +24 -33
  184. package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
  185. package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
  186. package/dist/apps/shared/components/layout/UserMenu.d.ts +4 -1
  187. package/dist/apps/shared/components/layout/UserMenu.js +6 -5
  188. package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
  189. package/dist/apps/shared/components/mail/EmptyFolderBar.d.ts +18 -0
  190. package/dist/apps/shared/components/mail/EmptyFolderBar.js +10 -0
  191. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +14 -4
  192. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -13
  193. package/dist/apps/shared/components/mail/MenuButton.d.ts +9 -2
  194. package/dist/apps/shared/components/mail/MenuButton.js +15 -11
  195. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +5 -0
  196. package/dist/apps/shared/components/mail/MessageDetailPane.js +147 -7
  197. package/dist/apps/shared/components/mail/PermanentDeleteDialog.d.ts +19 -0
  198. package/dist/apps/shared/components/mail/PermanentDeleteDialog.js +11 -0
  199. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -4
  200. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
  201. package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
  202. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
  203. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
  204. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +16 -7
  205. package/dist/apps/shared/components/mail/layout/MailShell.js +78 -65
  206. package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
  207. package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
  208. package/dist/apps/shared/components/mail/reading/MessageMoreMenu.d.ts +56 -0
  209. package/dist/apps/shared/components/mail/reading/MessageMoreMenu.js +153 -0
  210. package/dist/apps/shared/components/mail/reading/MessageSourceDialog.d.ts +27 -0
  211. package/dist/apps/shared/components/mail/reading/MessageSourceDialog.js +27 -0
  212. package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
  213. package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
  214. package/dist/apps/shared/components/mail/reading/messageExport.d.ts +19 -0
  215. package/dist/apps/shared/components/mail/reading/messageExport.js +51 -0
  216. package/dist/apps/shared/components/mail/reading/printMessage.d.ts +30 -0
  217. package/dist/apps/shared/components/mail/reading/printMessage.js +70 -0
  218. package/dist/apps/shared/components/mail/reading/useMessageActions.d.ts +57 -0
  219. package/dist/apps/shared/components/mail/reading/useMessageActions.js +366 -0
  220. package/dist/apps/shared/components/settings/layout/SettingsShell.js +10 -7
  221. package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
  222. package/dist/apps/shared/components/tasks/layout/TasksShell.js +7 -5
  223. package/dist/apps/shared/keyboard/GlobalShortcuts.js +1 -1
  224. package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
  225. package/dist/apps/shared/mail/folderCounts.js +12 -0
  226. package/dist/apps/shared/mail/folderOfType.d.ts +13 -0
  227. package/dist/apps/shared/mail/folderOfType.js +40 -0
  228. package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
  229. package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
  230. package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
  231. package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
  232. package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
  233. package/dist/apps/shared/mail/permanentDelete.d.ts +62 -0
  234. package/dist/apps/shared/mail/permanentDelete.js +127 -0
  235. package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
  236. package/dist/apps/shared/mail/primaryMailbox.js +61 -0
  237. package/dist/apps/shared/mail/reportNotices.d.ts +21 -0
  238. package/dist/apps/shared/mail/reportNotices.js +47 -0
  239. package/dist/apps/shared/mail/senderBlocking.d.ts +26 -0
  240. package/dist/apps/shared/mail/senderBlocking.js +92 -0
  241. package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
  242. package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
  243. package/dist/apps/shared/mail/useMailConnection.js +3 -1
  244. package/dist/apps/shared/mail/useMailboxUpdateAccess.d.ts +11 -0
  245. package/dist/apps/shared/mail/useMailboxUpdateAccess.js +52 -0
  246. package/dist/apps/shared/mail/usePermanentDelete.d.ts +35 -0
  247. package/dist/apps/shared/mail/usePermanentDelete.js +96 -0
  248. package/dist/apps/shared/navigation/frameContext.d.ts +1 -1
  249. package/dist/apps/shared/navigation/index.d.ts +16 -0
  250. package/dist/apps/shared/navigation/index.js +34 -0
  251. package/dist/apps/shared/navigation/pageTitle.d.ts +11 -0
  252. package/dist/apps/shared/navigation/pageTitle.js +12 -0
  253. package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
  254. package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
  255. package/dist/apps/shared/styles/app.css +396 -386
  256. package/dist/apps/www/_shell.d.ts +26 -0
  257. package/dist/apps/www/_shell.js +31 -0
  258. package/dist/apps/www/calendar/index.d.ts +7 -2
  259. package/dist/apps/www/calendar/index.js +7 -4
  260. package/dist/apps/www/contacts/[uid].d.ts +14 -3
  261. package/dist/apps/www/contacts/[uid].js +5 -3
  262. package/dist/apps/www/contacts/index.d.ts +7 -2
  263. package/dist/apps/www/contacts/index.js +8 -4
  264. package/dist/apps/www/index.d.ts +7 -2
  265. package/dist/apps/www/index.js +542 -207
  266. package/dist/apps/www/messages/[uid].d.ts +14 -3
  267. package/dist/apps/www/messages/[uid].js +7 -5
  268. package/dist/apps/www/settings/appearance/index.d.ts +8 -2
  269. package/dist/apps/www/settings/appearance/index.js +4 -2
  270. package/dist/apps/www/settings/auto-reply/index.d.ts +7 -2
  271. package/dist/apps/www/settings/auto-reply/index.js +4 -2
  272. package/dist/apps/www/settings/blocked-senders/index.d.ts +9 -0
  273. package/dist/apps/www/settings/blocked-senders/index.js +134 -0
  274. package/dist/apps/www/settings/encryption/index.d.ts +7 -2
  275. package/dist/apps/www/settings/encryption/index.js +4 -2
  276. package/dist/apps/www/settings/filters/[uid].d.ts +7 -2
  277. package/dist/apps/www/settings/filters/[uid].js +4 -2
  278. package/dist/apps/www/settings/filters/index.d.ts +7 -2
  279. package/dist/apps/www/settings/filters/index.js +4 -2
  280. package/dist/apps/www/settings/filters/new/index.d.ts +7 -2
  281. package/dist/apps/www/settings/filters/new/index.js +28 -5
  282. package/dist/apps/www/settings/labels/index.d.ts +7 -2
  283. package/dist/apps/www/settings/labels/index.js +4 -2
  284. package/dist/apps/www/settings/privacy/index.d.ts +7 -2
  285. package/dist/apps/www/settings/privacy/index.js +4 -2
  286. package/dist/apps/www/settings/profile/index.d.ts +7 -2
  287. package/dist/apps/www/settings/profile/index.js +4 -2
  288. package/dist/apps/www/settings/read-receipts/index.d.ts +7 -2
  289. package/dist/apps/www/settings/read-receipts/index.js +4 -2
  290. package/dist/apps/www/settings/sharing/index.d.ts +7 -2
  291. package/dist/apps/www/settings/sharing/index.js +4 -2
  292. package/dist/apps/www/settings/signatures/[uid].d.ts +7 -2
  293. package/dist/apps/www/settings/signatures/[uid].js +4 -2
  294. package/dist/apps/www/settings/signatures/index.d.ts +7 -2
  295. package/dist/apps/www/settings/signatures/index.js +4 -2
  296. package/dist/apps/www/settings/signatures/new/index.d.ts +7 -2
  297. package/dist/apps/www/settings/signatures/new/index.js +5 -3
  298. package/dist/apps/www/tasks/index.d.ts +7 -2
  299. package/dist/apps/www/tasks/index.js +5 -3
  300. package/package.json +4 -2
  301. package/apps/shared/navigation/AppRouter.tsx +0 -300
  302. package/apps/shared/navigation/idle.ts +0 -45
  303. package/apps/shared/navigation/routerContext.tsx +0 -83
  304. package/apps/shared/navigation/routes.ts +0 -72
  305. package/apps/www/_routedPage.tsx +0 -24
  306. package/apps/www/_routes.ts +0 -37
  307. package/dist/apps/shared/navigation/AppRouter.d.ts +0 -52
  308. package/dist/apps/shared/navigation/AppRouter.js +0 -242
  309. package/dist/apps/shared/navigation/idle.d.ts +0 -14
  310. package/dist/apps/shared/navigation/idle.js +0 -43
  311. package/dist/apps/shared/navigation/routerContext.d.ts +0 -37
  312. package/dist/apps/shared/navigation/routerContext.js +0 -56
  313. package/dist/apps/shared/navigation/routes.d.ts +0 -32
  314. package/dist/apps/shared/navigation/routes.js +0 -37
  315. package/dist/apps/www/_routedPage.d.ts +0 -12
  316. package/dist/apps/www/_routedPage.js +0 -19
  317. package/dist/apps/www/_routes.d.ts +0 -11
  318. package/dist/apps/www/_routes.js +0 -31
@@ -1,560 +1,560 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import { getNotificationsEnabled } from "./preferences.js";
6
-
7
- /**
8
- * The app's one notification store: every pop-up - new mail, a failed send, an API error, an expired session - goes through
9
- * `notify()`. It is deliberately framework-free (no React, no DOM beyond `sessionStorage`/timers), so it can be called from a hook,
10
- * a plain function or an `apiFetch` error path alike; `NotificationCenter` (the view, mounted once in the app frame) and
11
- * `useNotifications()` only *read* it.
12
- *
13
- * Rules, all enforced here rather than by the view so they hold whoever renders:
14
- *
15
- * - **At most `MAX_VISIBLE` (3) are visible**; the rest wait in a queue and appear as room is made. A sticky notification (an error, or
16
- * anything with actions) that finds the stack full pushes out the oldest one that would have gone by itself, rather than waiting behind
17
- * it. A queued notification that has waited `QUEUE_STALE_MS` is dropped (it would be news from the past) - it is still in the history.
18
- * - **Sticky until dismissed or resolved**: errors and notifications with actions never expire (`update()` or `dismiss()` resolve them).
19
- * Everything else goes by itself after its kind's default time, or `timeoutMs`. `sticky` overrides both ways.
20
- * - **A clock that stops**: a notification's remaining time stops while it is hovered or focused (`setPaused()`) and while the tab is
21
- * hidden (`setAllPaused()`), and continues from where it was.
22
- * - **Deduplicated by `dedupeKey`**: a second notification with a key that is still on screen (or waiting) does not stack - it replaces
23
- * the first one's content, restarts its clock and raises its `count`, so a flapping error is one pop-up saying "x3".
24
- * - **The user can turn them all off** (`preferences.ts`): `notify()` then shows nothing new, and only records it in the history.
25
- * - **Nothing is lost when one goes**: the last `HISTORY_LIMIT` (30, except new-mail pop-ups, which the inbox already holds) are
26
- * kept, in memory and in `sessionStorage`, with which errors nobody has seen yet.
27
- *
28
- * Nothing is stored where there is no `window` (server-side rendering): the module state is shared between requests there, so
29
- * `notify()` only hands back an id.
30
- */
31
-
32
- export type NotificationKind = "mail" | "calendar" | "info" | "success" | "warning" | "error";
33
-
34
- export interface NotificationAction {
35
- label: string;
36
- /** Runs when the action is used. */
37
- onClick?: () => void;
38
- /** Or a link to follow (a plain `<a>`, so the router's link handling applies). */
39
- href?: string;
40
- /** Leave the notification on screen after the action ran. By default an action resolves (dismisses) it. */
41
- keepOpen?: boolean;
42
- }
43
-
44
- export interface NotifyInput {
45
- /** Reuse an id to replace a notification (it is the same one: its content is swapped and its clock restarted). Generated when absent. */
46
- id?: string;
47
- kind: NotificationKind;
48
- title: string;
49
- /** A second, muted line next to the title (a mail pop-up's sender address). */
50
- subtitle?: string;
51
- message?: string;
52
- /** A mail pop-up's preview text, under `message` (its subject). */
53
- preview?: string;
54
- /** A small muted explanation above the actions (the desktop-notification offer). */
55
- hint?: string;
56
- /** Technical detail: a list of lines, or one block of text. Shown collapsed, monospace, with a copy button. */
57
- details?: string[] | string;
58
- actions?: NotificationAction[];
59
- /** The whole pop-up is a link to this (new mail opens the message). */
60
- href?: string;
61
- /** Never expires by itself. Default: errors and anything with actions. */
62
- sticky?: boolean;
63
- /** How long it stays, in ms. Default by kind, see `DEFAULT_TIMEOUT_MS`. Ignored for a sticky one. */
64
- timeoutMs?: number;
65
- /** Notifications with the same key that are still showing are one: see the module doc comment. */
66
- dedupeKey?: string;
67
- /** Keep it out of the history (default: kept, except `mail`). */
68
- history?: boolean;
69
- }
70
-
71
- /** A notification as a view renders it. */
72
- export interface NotificationView {
73
- id: string;
74
- kind: NotificationKind;
75
- title: string;
76
- subtitle?: string;
77
- message?: string;
78
- preview?: string;
79
- hint?: string;
80
- details: string[];
81
- actions: NotificationAction[];
82
- href?: string;
83
- /** How many times this one was raised (`dedupeKey`), 1 for a single one. */
84
- count: number;
85
- sticky: boolean;
86
- createdAt: number;
87
- }
88
-
89
- export interface HistoryEntry {
90
- id: string;
91
- kind: NotificationKind;
92
- title: string;
93
- message?: string;
94
- details: string[];
95
- count: number;
96
- /** When it was last raised (epoch ms). */
97
- at: number;
98
- /** Nobody has opened the history since it arrived. */
99
- unseen: boolean;
100
- }
101
-
102
- export interface NotificationsSnapshot {
103
- /** What is on screen, oldest first. */
104
- visible: NotificationView[];
105
- /** How many are waiting for room. */
106
- queued: number;
107
- /** The last `HISTORY_LIMIT`, newest first. */
108
- history: HistoryEntry[];
109
- /** Errors in the history that nobody has looked at. */
110
- unseenErrors: number;
111
- }
112
-
113
- export const MAX_VISIBLE = 3;
114
- export const MAX_QUEUED = 20;
115
- export const QUEUE_STALE_MS = 30_000;
116
- export const HISTORY_LIMIT = 30;
117
- export const HISTORY_STORAGE_KEY = "rapidmx-notification-history";
118
- /** The most technical lines and the longest line kept per notification - a transport error can carry a whole SMTP transcript. */
119
- export const MAX_DETAIL_LINES = 50;
120
- export const MAX_DETAIL_LINE_LENGTH = 500;
121
-
122
- /** How long a non-sticky notification stays, by kind. Errors are sticky, so theirs is only for an explicit `sticky: false`. */
123
- export const DEFAULT_TIMEOUT_MS: Record<NotificationKind, number> = {
124
- mail: 8_000,
125
- // Irrelevant in practice - a reminder always carries actions, which makes it sticky (see `applyInput()`) regardless of this value.
126
- calendar: 8_000,
127
- info: 6_000,
128
- success: 5_000,
129
- warning: 10_000,
130
- error: 10_000,
131
- };
132
-
133
- interface Entry {
134
- id: string;
135
- kind: NotificationKind;
136
- title: string;
137
- subtitle?: string;
138
- message?: string;
139
- preview?: string;
140
- hint?: string;
141
- details: string[];
142
- actions: NotificationAction[];
143
- href?: string;
144
- dedupeKey?: string;
145
- sticky: boolean;
146
- timeoutMs: number;
147
- count: number;
148
- createdAt: number;
149
- history: boolean;
150
- status: "visible" | "queued";
151
- remainingMs: number;
152
- startedAt?: number;
153
- timer?: ReturnType<typeof setTimeout>;
154
- paused: boolean;
155
- }
156
-
157
- const EMPTY_SNAPSHOT: NotificationsSnapshot = { visible: [], queued: 0, history: [], unseenErrors: 0 };
158
-
159
- let entries: Entry[] = [];
160
- let history: HistoryEntry[] = [];
161
- let historyLoaded = false;
162
- let allPaused = false;
163
- let counter = 0;
164
- let snapshot: NotificationsSnapshot = EMPTY_SNAPSHOT;
165
- const listeners = new Set<() => void>();
166
-
167
- function isClient(): boolean {
168
- return typeof window !== "undefined";
169
- }
170
-
171
- function normalizeDetails(details: string[] | string | undefined): string[] {
172
- if (details === undefined) {
173
- return [];
174
- }
175
- const lines = (Array.isArray(details) ? details : details.split(/\r?\n/)).filter((line) => line.length > 0);
176
- return lines.slice(0, MAX_DETAIL_LINES).map((line) => (line.length > MAX_DETAIL_LINE_LENGTH ? `${line.slice(0, MAX_DETAIL_LINE_LENGTH)}...` : line));
177
- }
178
-
179
- function loadHistory(): void {
180
- if (historyLoaded || !isClient()) {
181
- return;
182
- }
183
- historyLoaded = true;
184
- try {
185
- const stored = JSON.parse(sessionStorage.getItem(HISTORY_STORAGE_KEY) ?? "[]") as HistoryEntry[];
186
- history = Array.isArray(stored)
187
- ? stored
188
- .filter((item) => !!item && typeof item.id === "string" && typeof item.title === "string" && typeof item.at === "number")
189
- .slice(0, HISTORY_LIMIT)
190
- .map((item) => ({ ...item, details: Array.isArray(item.details) ? item.details : [], unseen: item.unseen === true }))
191
- : [];
192
- } catch {
193
- history = [];
194
- }
195
- }
196
-
197
- function saveHistory(): void {
198
- try {
199
- sessionStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(history));
200
- } catch {
201
- // Storage unavailable or full: the history is only kept in memory, which is all it needs to be for this page.
202
- }
203
- }
204
-
205
- function view(entry: Entry): NotificationView {
206
- return {
207
- id: entry.id,
208
- kind: entry.kind,
209
- title: entry.title,
210
- subtitle: entry.subtitle,
211
- message: entry.message,
212
- preview: entry.preview,
213
- hint: entry.hint,
214
- details: entry.details,
215
- actions: entry.actions,
216
- href: entry.href,
217
- count: entry.count,
218
- sticky: entry.sticky,
219
- createdAt: entry.createdAt,
220
- };
221
- }
222
-
223
- function rebuild(): void {
224
- const visible = entries.filter((entry) => entry.status === "visible").map(view);
225
- snapshot = {
226
- visible,
227
- queued: entries.length - visible.length,
228
- history,
229
- unseenErrors: history.filter((item) => item.unseen && item.kind === "error").length,
230
- };
231
- }
232
-
233
- function emit(): void {
234
- rebuild();
235
- for (const listener of [...listeners]) {
236
- listener();
237
- }
238
- }
239
-
240
- function findEntry(id: string): Entry | undefined {
241
- return entries.find((entry) => entry.id === id);
242
- }
243
-
244
- /** Records (or refreshes) `entry` in the history, newest first. */
245
- function record(entry: Entry): void {
246
- if (!entry.history) {
247
- return;
248
- }
249
- loadHistory();
250
- const item: HistoryEntry = {
251
- id: entry.id,
252
- kind: entry.kind,
253
- title: entry.title,
254
- message: entry.message,
255
- details: entry.details,
256
- count: entry.count,
257
- at: Date.now(),
258
- unseen: true,
259
- };
260
- history = [item, ...history.filter((existing) => existing.id !== entry.id)].slice(0, HISTORY_LIMIT);
261
- saveHistory();
262
- }
263
-
264
- function disarm(entry: Entry): void {
265
- if (entry.timer !== undefined) {
266
- clearTimeout(entry.timer);
267
- entry.timer = undefined;
268
- entry.remainingMs = Math.max(0, entry.remainingMs - (Date.now() - entry.startedAt!));
269
- }
270
- }
271
-
272
- /** Starts (or resumes) the clock of a visible, non-sticky, unpaused notification. */
273
- function arm(entry: Entry): void {
274
- if (entry.status !== "visible" || entry.sticky || entry.paused || allPaused || entry.timer !== undefined) {
275
- return;
276
- }
277
- entry.startedAt = Date.now();
278
- entry.timer = setTimeout(() => {
279
- entry.timer = undefined;
280
- remove(entry.id);
281
- }, entry.remainingMs);
282
- }
283
-
284
- function visibleCount(): number {
285
- return entries.filter((entry) => entry.status === "visible").length;
286
- }
287
-
288
- function show(entry: Entry): void {
289
- entry.status = "visible";
290
- entry.remainingMs = entry.timeoutMs;
291
- arm(entry);
292
- }
293
-
294
- /** Fills free places from the queue, dropping what has waited too long. */
295
- function promote(): void {
296
- const now = Date.now();
297
- entries = entries.filter((entry) => entry.status === "visible" || entry.sticky || now - entry.createdAt < QUEUE_STALE_MS);
298
- while (visibleCount() < MAX_VISIBLE) {
299
- const next = entries.find((entry) => entry.status === "queued");
300
- if (!next) {
301
- return;
302
- }
303
- show(next);
304
- }
305
- }
306
-
307
- function remove(id: string): void {
308
- const entry = findEntry(id);
309
- if (!entry) {
310
- return;
311
- }
312
- disarm(entry);
313
- entries = entries.filter((existing) => existing !== entry);
314
- promote();
315
- emit();
316
- }
317
-
318
- /** Makes room for a sticky notification: the oldest visible one that would have gone by itself leaves (it stays in the history). */
319
- function evictForSticky(): boolean {
320
- const victim = entries.find((entry) => entry.status === "visible" && !entry.sticky);
321
- if (!victim) {
322
- return false;
323
- }
324
- disarm(victim);
325
- entries = entries.filter((entry) => entry !== victim);
326
- return true;
327
- }
328
-
329
- function applyInput(entry: Entry, input: NotifyInput): void {
330
- entry.kind = input.kind;
331
- entry.title = input.title;
332
- entry.subtitle = input.subtitle;
333
- entry.message = input.message;
334
- entry.preview = input.preview;
335
- entry.hint = input.hint;
336
- entry.details = normalizeDetails(input.details);
337
- entry.actions = input.actions ?? [];
338
- entry.href = input.href;
339
- entry.sticky = input.sticky ?? (input.kind === "error" || (input.actions?.length ?? 0) > 0);
340
- entry.timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS[input.kind];
341
- entry.history = input.history ?? input.kind !== "mail";
342
- }
343
-
344
- /**
345
- * Raises a notification and returns its id. See the module doc comment for what happens to it. Usable from anywhere - a component, a
346
- * hook, a plain function, an `apiFetch` error path - and safe where there is no window (it does nothing there).
347
- */
348
- export function notify(input: NotifyInput): string {
349
- counter += 1;
350
- const id = input.id ?? `notification-${counter}`;
351
- if (!isClient()) {
352
- return id;
353
- }
354
- const sameKey = input.dedupeKey !== undefined ? entries.find((entry) => entry.dedupeKey === input.dedupeKey) : undefined;
355
- const existing = sameKey ?? findEntry(id);
356
- if (existing) {
357
- // The same notification again: its content is replaced and it gets its full time again. Only a repeated dedupe key counts up
358
- // (an explicit id is a replacement, not another occurrence).
359
- disarm(existing);
360
- applyInput(existing, input);
361
- existing.count += sameKey ? 1 : 0;
362
- existing.remainingMs = existing.timeoutMs;
363
- arm(existing);
364
- record(existing);
365
- emit();
366
- return existing.id;
367
- }
368
- const entry: Entry = {
369
- id,
370
- dedupeKey: input.dedupeKey,
371
- kind: input.kind,
372
- title: input.title,
373
- details: [],
374
- actions: [],
375
- sticky: false,
376
- timeoutMs: 0,
377
- count: 1,
378
- createdAt: Date.now(),
379
- history: true,
380
- status: "queued",
381
- remainingMs: 0,
382
- paused: false,
383
- };
384
- applyInput(entry, input);
385
- if (!getNotificationsEnabled()) {
386
- // The user has pop-ups off: nothing is shown, but it is listed in the history like any other.
387
- record(entry);
388
- emit();
389
- return id;
390
- }
391
- const room = visibleCount() < MAX_VISIBLE;
392
- entries.push(entry);
393
- if (room) {
394
- show(entry);
395
- } else if (entry.sticky && evictForSticky()) {
396
- show(entry);
397
- } else {
398
- // Waiting for room: the queue itself is bounded, and the oldest waiting one that would not have stayed is the first to go.
399
- const waiting = entries.filter((e) => e.status === "queued");
400
- if (waiting.length > MAX_QUEUED) {
401
- const dropped = waiting.find((e) => !e.sticky) ?? waiting[0];
402
- entries = entries.filter((e) => e !== dropped);
403
- }
404
- }
405
- record(entry);
406
- emit();
407
- return id;
408
- }
409
-
410
- /**
411
- * Changes a notification that is still showing or waiting - the way to resolve it ("Sending..." becoming "Sent"). Only the given
412
- * fields change. Its clock restarts, and it may become sticky or stop being so with its new kind. Returns `false` when there is no such
413
- * notification any more (it was dismissed or expired); its history entry is updated all the same.
414
- */
415
- export function update(id: string, patch: Partial<Omit<NotifyInput, "id" | "dedupeKey">>): boolean {
416
- const entry = findEntry(id);
417
- if (!entry) {
418
- loadHistory();
419
- const item = history.find((existing) => existing.id === id);
420
- if (item) {
421
- history = history.map((existing) =>
422
- existing === item
423
- ? {
424
- ...existing,
425
- kind: patch.kind ?? existing.kind,
426
- title: patch.title ?? existing.title,
427
- message: "message" in patch ? patch.message : existing.message,
428
- details: "details" in patch ? normalizeDetails(patch.details) : existing.details,
429
- }
430
- : existing,
431
- );
432
- saveHistory();
433
- emit();
434
- }
435
- return false;
436
- }
437
- disarm(entry);
438
- applyInput(entry, {
439
- kind: entry.kind,
440
- title: entry.title,
441
- subtitle: entry.subtitle,
442
- message: entry.message,
443
- preview: entry.preview,
444
- hint: entry.hint,
445
- details: entry.details,
446
- actions: entry.actions,
447
- href: entry.href,
448
- history: entry.history,
449
- // A change of kind or actions recomputes stickiness (unless the patch says), so it is only carried over otherwise.
450
- sticky: "kind" in patch || "actions" in patch ? undefined : entry.sticky,
451
- // Its own time again, unless the kind changed (then the new kind's default) or the patch says.
452
- timeoutMs: "kind" in patch ? undefined : entry.timeoutMs,
453
- ...patch,
454
- });
455
- entry.remainingMs = entry.timeoutMs;
456
- arm(entry);
457
- record(entry);
458
- emit();
459
- return true;
460
- }
461
-
462
- /** Removes a notification from the screen (or the queue). Its history entry stays. Does nothing for an unknown id. */
463
- export function dismiss(id: string): void {
464
- remove(id);
465
- }
466
-
467
- /** Removes everything showing and waiting. */
468
- export function dismissAll(): void {
469
- for (const entry of entries) {
470
- disarm(entry);
471
- }
472
- entries = [];
473
- emit();
474
- }
475
-
476
- /** Stops (or restarts) one notification's clock: it is being hovered or holds the keyboard focus. */
477
- export function setPaused(id: string, paused: boolean): void {
478
- const entry = findEntry(id);
479
- if (!entry || entry.paused === paused) {
480
- return;
481
- }
482
- entry.paused = paused;
483
- if (paused) {
484
- disarm(entry);
485
- } else {
486
- arm(entry);
487
- }
488
- }
489
-
490
- /** Stops (or restarts) every clock: the tab is hidden, so nobody could have read what is on screen. */
491
- export function setAllPaused(paused: boolean): void {
492
- if (allPaused === paused) {
493
- return;
494
- }
495
- allPaused = paused;
496
- for (const entry of entries) {
497
- if (paused) {
498
- disarm(entry);
499
- } else {
500
- arm(entry);
501
- }
502
- }
503
- }
504
-
505
- /** Marks every history entry as seen - the user has opened the history. */
506
- export function markHistorySeen(): void {
507
- loadHistory();
508
- if (!history.some((item) => item.unseen)) {
509
- return;
510
- }
511
- history = history.map((item) => (item.unseen ? { ...item, unseen: false } : item));
512
- saveHistory();
513
- emit();
514
- }
515
-
516
- /** Empties the history. */
517
- export function clearHistory(): void {
518
- loadHistory();
519
- history = [];
520
- saveHistory();
521
- emit();
522
- }
523
-
524
- export function subscribeNotifications(listener: () => void): () => void {
525
- listeners.add(listener);
526
- return () => {
527
- listeners.delete(listener);
528
- };
529
- }
530
-
531
- export function getNotificationsSnapshot(): NotificationsSnapshot {
532
- if (!historyLoaded && isClient()) {
533
- // The history is read from storage on first use, without telling listeners (this runs during a render).
534
- loadHistory();
535
- rebuild();
536
- }
537
- return snapshot;
538
- }
539
-
540
- export function getServerNotificationsSnapshot(): NotificationsSnapshot {
541
- return EMPTY_SNAPSHOT;
542
- }
543
-
544
- /** Forgets everything and stops every clock - for a test, or a page that outlives its session. */
545
- export function resetNotifications(): void {
546
- for (const entry of entries) {
547
- disarm(entry);
548
- }
549
- entries = [];
550
- history = [];
551
- historyLoaded = false;
552
- allPaused = false;
553
- counter = 0;
554
- snapshot = EMPTY_SNAPSHOT;
555
- try {
556
- sessionStorage.removeItem(HISTORY_STORAGE_KEY);
557
- } catch {
558
- // Nothing stored.
559
- }
560
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { getNotificationsEnabled } from "./preferences.js";
6
+
7
+ /**
8
+ * The app's one notification store: every pop-up - new mail, a failed send, an API error, an expired session - goes through
9
+ * `notify()`. It is deliberately framework-free (no React, no DOM beyond `sessionStorage`/timers), so it can be called from a hook,
10
+ * a plain function or an `apiFetch` error path alike; `NotificationCenter` (the view, mounted once in the app frame) and
11
+ * `useNotifications()` only *read* it.
12
+ *
13
+ * Rules, all enforced here rather than by the view so they hold whoever renders:
14
+ *
15
+ * - **At most `MAX_VISIBLE` (3) are visible**; the rest wait in a queue and appear as room is made. A sticky notification (an error, or
16
+ * anything with actions) that finds the stack full pushes out the oldest one that would have gone by itself, rather than waiting behind
17
+ * it. A queued notification that has waited `QUEUE_STALE_MS` is dropped (it would be news from the past) - it is still in the history.
18
+ * - **Sticky until dismissed or resolved**: errors and notifications with actions never expire (`update()` or `dismiss()` resolve them).
19
+ * Everything else goes by itself after its kind's default time, or `timeoutMs`. `sticky` overrides both ways.
20
+ * - **A clock that stops**: a notification's remaining time stops while it is hovered or focused (`setPaused()`) and while the tab is
21
+ * hidden (`setAllPaused()`), and continues from where it was.
22
+ * - **Deduplicated by `dedupeKey`**: a second notification with a key that is still on screen (or waiting) does not stack - it replaces
23
+ * the first one's content, restarts its clock and raises its `count`, so a flapping error is one pop-up saying "x3".
24
+ * - **The user can turn them all off** (`preferences.ts`): `notify()` then shows nothing new, and only records it in the history.
25
+ * - **Nothing is lost when one goes**: the last `HISTORY_LIMIT` (30, except new-mail pop-ups, which the inbox already holds) are
26
+ * kept, in memory and in `sessionStorage`, with which errors nobody has seen yet.
27
+ *
28
+ * Nothing is stored where there is no `window` (server-side rendering): the module state is shared between requests there, so
29
+ * `notify()` only hands back an id.
30
+ */
31
+
32
+ export type NotificationKind = "mail" | "calendar" | "info" | "success" | "warning" | "error";
33
+
34
+ export interface NotificationAction {
35
+ label: string;
36
+ /** Runs when the action is used. */
37
+ onClick?: () => void;
38
+ /** Or a link to follow (a plain `<a>`, so the router's link handling applies). */
39
+ href?: string;
40
+ /** Leave the notification on screen after the action ran. By default an action resolves (dismisses) it. */
41
+ keepOpen?: boolean;
42
+ }
43
+
44
+ export interface NotifyInput {
45
+ /** Reuse an id to replace a notification (it is the same one: its content is swapped and its clock restarted). Generated when absent. */
46
+ id?: string;
47
+ kind: NotificationKind;
48
+ title: string;
49
+ /** A second, muted line next to the title (a mail pop-up's sender address). */
50
+ subtitle?: string;
51
+ message?: string;
52
+ /** A mail pop-up's preview text, under `message` (its subject). */
53
+ preview?: string;
54
+ /** A small muted explanation above the actions (the desktop-notification offer). */
55
+ hint?: string;
56
+ /** Technical detail: a list of lines, or one block of text. Shown collapsed, monospace, with a copy button. */
57
+ details?: string[] | string;
58
+ actions?: NotificationAction[];
59
+ /** The whole pop-up is a link to this (new mail opens the message). */
60
+ href?: string;
61
+ /** Never expires by itself. Default: errors and anything with actions. */
62
+ sticky?: boolean;
63
+ /** How long it stays, in ms. Default by kind, see `DEFAULT_TIMEOUT_MS`. Ignored for a sticky one. */
64
+ timeoutMs?: number;
65
+ /** Notifications with the same key that are still showing are one: see the module doc comment. */
66
+ dedupeKey?: string;
67
+ /** Keep it out of the history (default: kept, except `mail`). */
68
+ history?: boolean;
69
+ }
70
+
71
+ /** A notification as a view renders it. */
72
+ export interface NotificationView {
73
+ id: string;
74
+ kind: NotificationKind;
75
+ title: string;
76
+ subtitle?: string;
77
+ message?: string;
78
+ preview?: string;
79
+ hint?: string;
80
+ details: string[];
81
+ actions: NotificationAction[];
82
+ href?: string;
83
+ /** How many times this one was raised (`dedupeKey`), 1 for a single one. */
84
+ count: number;
85
+ sticky: boolean;
86
+ createdAt: number;
87
+ }
88
+
89
+ export interface HistoryEntry {
90
+ id: string;
91
+ kind: NotificationKind;
92
+ title: string;
93
+ message?: string;
94
+ details: string[];
95
+ count: number;
96
+ /** When it was last raised (epoch ms). */
97
+ at: number;
98
+ /** Nobody has opened the history since it arrived. */
99
+ unseen: boolean;
100
+ }
101
+
102
+ export interface NotificationsSnapshot {
103
+ /** What is on screen, oldest first. */
104
+ visible: NotificationView[];
105
+ /** How many are waiting for room. */
106
+ queued: number;
107
+ /** The last `HISTORY_LIMIT`, newest first. */
108
+ history: HistoryEntry[];
109
+ /** Errors in the history that nobody has looked at. */
110
+ unseenErrors: number;
111
+ }
112
+
113
+ export const MAX_VISIBLE = 3;
114
+ export const MAX_QUEUED = 20;
115
+ export const QUEUE_STALE_MS = 30_000;
116
+ export const HISTORY_LIMIT = 30;
117
+ export const HISTORY_STORAGE_KEY = "rapidmx-notification-history";
118
+ /** The most technical lines and the longest line kept per notification - a transport error can carry a whole SMTP transcript. */
119
+ export const MAX_DETAIL_LINES = 50;
120
+ export const MAX_DETAIL_LINE_LENGTH = 500;
121
+
122
+ /** How long a non-sticky notification stays, by kind. Errors are sticky, so theirs is only for an explicit `sticky: false`. */
123
+ export const DEFAULT_TIMEOUT_MS: Record<NotificationKind, number> = {
124
+ mail: 8_000,
125
+ // Irrelevant in practice - a reminder always carries actions, which makes it sticky (see `applyInput()`) regardless of this value.
126
+ calendar: 8_000,
127
+ info: 6_000,
128
+ success: 5_000,
129
+ warning: 10_000,
130
+ error: 10_000,
131
+ };
132
+
133
+ interface Entry {
134
+ id: string;
135
+ kind: NotificationKind;
136
+ title: string;
137
+ subtitle?: string;
138
+ message?: string;
139
+ preview?: string;
140
+ hint?: string;
141
+ details: string[];
142
+ actions: NotificationAction[];
143
+ href?: string;
144
+ dedupeKey?: string;
145
+ sticky: boolean;
146
+ timeoutMs: number;
147
+ count: number;
148
+ createdAt: number;
149
+ history: boolean;
150
+ status: "visible" | "queued";
151
+ remainingMs: number;
152
+ startedAt?: number;
153
+ timer?: ReturnType<typeof setTimeout>;
154
+ paused: boolean;
155
+ }
156
+
157
+ const EMPTY_SNAPSHOT: NotificationsSnapshot = { visible: [], queued: 0, history: [], unseenErrors: 0 };
158
+
159
+ let entries: Entry[] = [];
160
+ let history: HistoryEntry[] = [];
161
+ let historyLoaded = false;
162
+ let allPaused = false;
163
+ let counter = 0;
164
+ let snapshot: NotificationsSnapshot = EMPTY_SNAPSHOT;
165
+ const listeners = new Set<() => void>();
166
+
167
+ function isClient(): boolean {
168
+ return typeof window !== "undefined";
169
+ }
170
+
171
+ function normalizeDetails(details: string[] | string | undefined): string[] {
172
+ if (details === undefined) {
173
+ return [];
174
+ }
175
+ const lines = (Array.isArray(details) ? details : details.split(/\r?\n/)).filter((line) => line.length > 0);
176
+ return lines.slice(0, MAX_DETAIL_LINES).map((line) => (line.length > MAX_DETAIL_LINE_LENGTH ? `${line.slice(0, MAX_DETAIL_LINE_LENGTH)}...` : line));
177
+ }
178
+
179
+ function loadHistory(): void {
180
+ if (historyLoaded || !isClient()) {
181
+ return;
182
+ }
183
+ historyLoaded = true;
184
+ try {
185
+ const stored = JSON.parse(sessionStorage.getItem(HISTORY_STORAGE_KEY) ?? "[]") as HistoryEntry[];
186
+ history = Array.isArray(stored)
187
+ ? stored
188
+ .filter((item) => !!item && typeof item.id === "string" && typeof item.title === "string" && typeof item.at === "number")
189
+ .slice(0, HISTORY_LIMIT)
190
+ .map((item) => ({ ...item, details: Array.isArray(item.details) ? item.details : [], unseen: item.unseen === true }))
191
+ : [];
192
+ } catch {
193
+ history = [];
194
+ }
195
+ }
196
+
197
+ function saveHistory(): void {
198
+ try {
199
+ sessionStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(history));
200
+ } catch {
201
+ // Storage unavailable or full: the history is only kept in memory, which is all it needs to be for this page.
202
+ }
203
+ }
204
+
205
+ function view(entry: Entry): NotificationView {
206
+ return {
207
+ id: entry.id,
208
+ kind: entry.kind,
209
+ title: entry.title,
210
+ subtitle: entry.subtitle,
211
+ message: entry.message,
212
+ preview: entry.preview,
213
+ hint: entry.hint,
214
+ details: entry.details,
215
+ actions: entry.actions,
216
+ href: entry.href,
217
+ count: entry.count,
218
+ sticky: entry.sticky,
219
+ createdAt: entry.createdAt,
220
+ };
221
+ }
222
+
223
+ function rebuild(): void {
224
+ const visible = entries.filter((entry) => entry.status === "visible").map(view);
225
+ snapshot = {
226
+ visible,
227
+ queued: entries.length - visible.length,
228
+ history,
229
+ unseenErrors: history.filter((item) => item.unseen && item.kind === "error").length,
230
+ };
231
+ }
232
+
233
+ function emit(): void {
234
+ rebuild();
235
+ for (const listener of [...listeners]) {
236
+ listener();
237
+ }
238
+ }
239
+
240
+ function findEntry(id: string): Entry | undefined {
241
+ return entries.find((entry) => entry.id === id);
242
+ }
243
+
244
+ /** Records (or refreshes) `entry` in the history, newest first. */
245
+ function record(entry: Entry): void {
246
+ if (!entry.history) {
247
+ return;
248
+ }
249
+ loadHistory();
250
+ const item: HistoryEntry = {
251
+ id: entry.id,
252
+ kind: entry.kind,
253
+ title: entry.title,
254
+ message: entry.message,
255
+ details: entry.details,
256
+ count: entry.count,
257
+ at: Date.now(),
258
+ unseen: true,
259
+ };
260
+ history = [item, ...history.filter((existing) => existing.id !== entry.id)].slice(0, HISTORY_LIMIT);
261
+ saveHistory();
262
+ }
263
+
264
+ function disarm(entry: Entry): void {
265
+ if (entry.timer !== undefined) {
266
+ clearTimeout(entry.timer);
267
+ entry.timer = undefined;
268
+ entry.remainingMs = Math.max(0, entry.remainingMs - (Date.now() - entry.startedAt!));
269
+ }
270
+ }
271
+
272
+ /** Starts (or resumes) the clock of a visible, non-sticky, unpaused notification. */
273
+ function arm(entry: Entry): void {
274
+ if (entry.status !== "visible" || entry.sticky || entry.paused || allPaused || entry.timer !== undefined) {
275
+ return;
276
+ }
277
+ entry.startedAt = Date.now();
278
+ entry.timer = setTimeout(() => {
279
+ entry.timer = undefined;
280
+ remove(entry.id);
281
+ }, entry.remainingMs);
282
+ }
283
+
284
+ function visibleCount(): number {
285
+ return entries.filter((entry) => entry.status === "visible").length;
286
+ }
287
+
288
+ function show(entry: Entry): void {
289
+ entry.status = "visible";
290
+ entry.remainingMs = entry.timeoutMs;
291
+ arm(entry);
292
+ }
293
+
294
+ /** Fills free places from the queue, dropping what has waited too long. */
295
+ function promote(): void {
296
+ const now = Date.now();
297
+ entries = entries.filter((entry) => entry.status === "visible" || entry.sticky || now - entry.createdAt < QUEUE_STALE_MS);
298
+ while (visibleCount() < MAX_VISIBLE) {
299
+ const next = entries.find((entry) => entry.status === "queued");
300
+ if (!next) {
301
+ return;
302
+ }
303
+ show(next);
304
+ }
305
+ }
306
+
307
+ function remove(id: string): void {
308
+ const entry = findEntry(id);
309
+ if (!entry) {
310
+ return;
311
+ }
312
+ disarm(entry);
313
+ entries = entries.filter((existing) => existing !== entry);
314
+ promote();
315
+ emit();
316
+ }
317
+
318
+ /** Makes room for a sticky notification: the oldest visible one that would have gone by itself leaves (it stays in the history). */
319
+ function evictForSticky(): boolean {
320
+ const victim = entries.find((entry) => entry.status === "visible" && !entry.sticky);
321
+ if (!victim) {
322
+ return false;
323
+ }
324
+ disarm(victim);
325
+ entries = entries.filter((entry) => entry !== victim);
326
+ return true;
327
+ }
328
+
329
+ function applyInput(entry: Entry, input: NotifyInput): void {
330
+ entry.kind = input.kind;
331
+ entry.title = input.title;
332
+ entry.subtitle = input.subtitle;
333
+ entry.message = input.message;
334
+ entry.preview = input.preview;
335
+ entry.hint = input.hint;
336
+ entry.details = normalizeDetails(input.details);
337
+ entry.actions = input.actions ?? [];
338
+ entry.href = input.href;
339
+ entry.sticky = input.sticky ?? (input.kind === "error" || (input.actions?.length ?? 0) > 0);
340
+ entry.timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS[input.kind];
341
+ entry.history = input.history ?? input.kind !== "mail";
342
+ }
343
+
344
+ /**
345
+ * Raises a notification and returns its id. See the module doc comment for what happens to it. Usable from anywhere - a component, a
346
+ * hook, a plain function, an `apiFetch` error path - and safe where there is no window (it does nothing there).
347
+ */
348
+ export function notify(input: NotifyInput): string {
349
+ counter += 1;
350
+ const id = input.id ?? `notification-${counter}`;
351
+ if (!isClient()) {
352
+ return id;
353
+ }
354
+ const sameKey = input.dedupeKey !== undefined ? entries.find((entry) => entry.dedupeKey === input.dedupeKey) : undefined;
355
+ const existing = sameKey ?? findEntry(id);
356
+ if (existing) {
357
+ // The same notification again: its content is replaced and it gets its full time again. Only a repeated dedupe key counts up
358
+ // (an explicit id is a replacement, not another occurrence).
359
+ disarm(existing);
360
+ applyInput(existing, input);
361
+ existing.count += sameKey ? 1 : 0;
362
+ existing.remainingMs = existing.timeoutMs;
363
+ arm(existing);
364
+ record(existing);
365
+ emit();
366
+ return existing.id;
367
+ }
368
+ const entry: Entry = {
369
+ id,
370
+ dedupeKey: input.dedupeKey,
371
+ kind: input.kind,
372
+ title: input.title,
373
+ details: [],
374
+ actions: [],
375
+ sticky: false,
376
+ timeoutMs: 0,
377
+ count: 1,
378
+ createdAt: Date.now(),
379
+ history: true,
380
+ status: "queued",
381
+ remainingMs: 0,
382
+ paused: false,
383
+ };
384
+ applyInput(entry, input);
385
+ if (!getNotificationsEnabled()) {
386
+ // The user has pop-ups off: nothing is shown, but it is listed in the history like any other.
387
+ record(entry);
388
+ emit();
389
+ return id;
390
+ }
391
+ const room = visibleCount() < MAX_VISIBLE;
392
+ entries.push(entry);
393
+ if (room) {
394
+ show(entry);
395
+ } else if (entry.sticky && evictForSticky()) {
396
+ show(entry);
397
+ } else {
398
+ // Waiting for room: the queue itself is bounded, and the oldest waiting one that would not have stayed is the first to go.
399
+ const waiting = entries.filter((e) => e.status === "queued");
400
+ if (waiting.length > MAX_QUEUED) {
401
+ const dropped = waiting.find((e) => !e.sticky) ?? waiting[0];
402
+ entries = entries.filter((e) => e !== dropped);
403
+ }
404
+ }
405
+ record(entry);
406
+ emit();
407
+ return id;
408
+ }
409
+
410
+ /**
411
+ * Changes a notification that is still showing or waiting - the way to resolve it ("Sending..." becoming "Sent"). Only the given
412
+ * fields change. Its clock restarts, and it may become sticky or stop being so with its new kind. Returns `false` when there is no such
413
+ * notification any more (it was dismissed or expired); its history entry is updated all the same.
414
+ */
415
+ export function update(id: string, patch: Partial<Omit<NotifyInput, "id" | "dedupeKey">>): boolean {
416
+ const entry = findEntry(id);
417
+ if (!entry) {
418
+ loadHistory();
419
+ const item = history.find((existing) => existing.id === id);
420
+ if (item) {
421
+ history = history.map((existing) =>
422
+ existing === item
423
+ ? {
424
+ ...existing,
425
+ kind: patch.kind ?? existing.kind,
426
+ title: patch.title ?? existing.title,
427
+ message: "message" in patch ? patch.message : existing.message,
428
+ details: "details" in patch ? normalizeDetails(patch.details) : existing.details,
429
+ }
430
+ : existing,
431
+ );
432
+ saveHistory();
433
+ emit();
434
+ }
435
+ return false;
436
+ }
437
+ disarm(entry);
438
+ applyInput(entry, {
439
+ kind: entry.kind,
440
+ title: entry.title,
441
+ subtitle: entry.subtitle,
442
+ message: entry.message,
443
+ preview: entry.preview,
444
+ hint: entry.hint,
445
+ details: entry.details,
446
+ actions: entry.actions,
447
+ href: entry.href,
448
+ history: entry.history,
449
+ // A change of kind or actions recomputes stickiness (unless the patch says), so it is only carried over otherwise.
450
+ sticky: "kind" in patch || "actions" in patch ? undefined : entry.sticky,
451
+ // Its own time again, unless the kind changed (then the new kind's default) or the patch says.
452
+ timeoutMs: "kind" in patch ? undefined : entry.timeoutMs,
453
+ ...patch,
454
+ });
455
+ entry.remainingMs = entry.timeoutMs;
456
+ arm(entry);
457
+ record(entry);
458
+ emit();
459
+ return true;
460
+ }
461
+
462
+ /** Removes a notification from the screen (or the queue). Its history entry stays. Does nothing for an unknown id. */
463
+ export function dismiss(id: string): void {
464
+ remove(id);
465
+ }
466
+
467
+ /** Removes everything showing and waiting. */
468
+ export function dismissAll(): void {
469
+ for (const entry of entries) {
470
+ disarm(entry);
471
+ }
472
+ entries = [];
473
+ emit();
474
+ }
475
+
476
+ /** Stops (or restarts) one notification's clock: it is being hovered or holds the keyboard focus. */
477
+ export function setPaused(id: string, paused: boolean): void {
478
+ const entry = findEntry(id);
479
+ if (!entry || entry.paused === paused) {
480
+ return;
481
+ }
482
+ entry.paused = paused;
483
+ if (paused) {
484
+ disarm(entry);
485
+ } else {
486
+ arm(entry);
487
+ }
488
+ }
489
+
490
+ /** Stops (or restarts) every clock: the tab is hidden, so nobody could have read what is on screen. */
491
+ export function setAllPaused(paused: boolean): void {
492
+ if (allPaused === paused) {
493
+ return;
494
+ }
495
+ allPaused = paused;
496
+ for (const entry of entries) {
497
+ if (paused) {
498
+ disarm(entry);
499
+ } else {
500
+ arm(entry);
501
+ }
502
+ }
503
+ }
504
+
505
+ /** Marks every history entry as seen - the user has opened the history. */
506
+ export function markHistorySeen(): void {
507
+ loadHistory();
508
+ if (!history.some((item) => item.unseen)) {
509
+ return;
510
+ }
511
+ history = history.map((item) => (item.unseen ? { ...item, unseen: false } : item));
512
+ saveHistory();
513
+ emit();
514
+ }
515
+
516
+ /** Empties the history. */
517
+ export function clearHistory(): void {
518
+ loadHistory();
519
+ history = [];
520
+ saveHistory();
521
+ emit();
522
+ }
523
+
524
+ export function subscribeNotifications(listener: () => void): () => void {
525
+ listeners.add(listener);
526
+ return () => {
527
+ listeners.delete(listener);
528
+ };
529
+ }
530
+
531
+ export function getNotificationsSnapshot(): NotificationsSnapshot {
532
+ if (!historyLoaded && isClient()) {
533
+ // The history is read from storage on first use, without telling listeners (this runs during a render).
534
+ loadHistory();
535
+ rebuild();
536
+ }
537
+ return snapshot;
538
+ }
539
+
540
+ export function getServerNotificationsSnapshot(): NotificationsSnapshot {
541
+ return EMPTY_SNAPSHOT;
542
+ }
543
+
544
+ /** Forgets everything and stops every clock - for a test, or a page that outlives its session. */
545
+ export function resetNotifications(): void {
546
+ for (const entry of entries) {
547
+ disarm(entry);
548
+ }
549
+ entries = [];
550
+ history = [];
551
+ historyLoaded = false;
552
+ allPaused = false;
553
+ counter = 0;
554
+ snapshot = EMPTY_SNAPSHOT;
555
+ try {
556
+ sessionStorage.removeItem(HISTORY_STORAGE_KEY);
557
+ } catch {
558
+ // Nothing stored.
559
+ }
560
+ }