@rapidmx/web-client 0.10.0 → 0.11.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.
- package/README.md +183 -2
- package/apps/admin/_layout.tsx +8 -2
- package/apps/admin/index.tsx +7 -1
- package/apps/admin/ingest-queue/index.tsx +2 -1
- package/apps/admin/mailboxes/[uid].tsx +23 -10
- package/apps/admin/quarantine/index.tsx +2 -1
- package/apps/admin/signing-certificates/index.tsx +343 -0
- package/apps/escrow/_layout.tsx +8 -2
- package/apps/shared/appearance/AppearanceForm.tsx +392 -0
- package/apps/shared/appearance/AppearanceHead.tsx +49 -0
- package/apps/shared/appearance/AppearanceProvider.tsx +470 -0
- package/apps/shared/appearance/ColorField.tsx +107 -0
- package/apps/shared/appearance/appearanceCache.ts +106 -0
- package/apps/shared/appearance/appearanceContext.ts +77 -0
- package/apps/shared/appearance/bootScript.ts +35 -0
- package/apps/shared/appearance/color.ts +169 -0
- package/apps/shared/appearance/photo.ts +88 -0
- package/apps/shared/appearance/resolvedTheme.ts +67 -0
- package/apps/shared/appearance/theme.ts +355 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +22 -10
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +107 -60
- package/apps/shared/components/admin/settings/BrandingForm.tsx +24 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +228 -25
- package/apps/shared/components/admin/setup/SetupWizard.tsx +2 -1
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +4 -5
- package/apps/shared/components/contacts/ContactForm.tsx +11 -5
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -3
- package/apps/shared/components/contacts/ContactsToolbar.tsx +25 -52
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +10 -5
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +13 -6
- package/apps/shared/components/layout/AppShell.tsx +98 -32
- package/apps/shared/components/layout/BrandingChrome.tsx +200 -13
- package/apps/shared/components/layout/RailIcon.tsx +100 -0
- package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -0
- package/apps/shared/components/layout/ThemeSwitch.tsx +84 -0
- package/apps/shared/components/layout/UserMenu.tsx +75 -6
- package/apps/shared/components/mail/ConversationList.tsx +4 -2
- package/apps/shared/components/mail/ConversationThreadPane.tsx +487 -475
- package/apps/shared/components/mail/LazyReadingPane.tsx +14 -16
- package/apps/shared/components/mail/MailSelectionBar.tsx +2 -10
- package/apps/shared/components/mail/MessageDetailPane.tsx +366 -287
- package/apps/shared/components/mail/OutboxBadge.tsx +44 -0
- package/apps/shared/components/mail/OutboxRowStatus.tsx +31 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +35 -2
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +239 -437
- package/apps/shared/components/mail/compose/encryptionRequirement.ts +86 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +39 -25
- package/apps/shared/components/mail/reading/EncryptedBody.tsx +65 -0
- package/apps/shared/components/mail/reading/EncryptedPreview.tsx +35 -0
- package/apps/shared/components/mail/reading/MessageBody.tsx +240 -0
- package/apps/shared/components/mail/reading/MessageCard.tsx +174 -0
- package/apps/shared/components/mail/reading/bodyContent.ts +100 -0
- package/apps/shared/components/mail/reading/bodyHtml.ts +298 -0
- package/apps/shared/components/mail/reading/color.ts +245 -0
- package/apps/shared/components/mail/reading/frameControl.ts +142 -0
- package/apps/shared/components/mail/reading/frameDocument.ts +123 -0
- package/apps/shared/components/mail/reading/safeDocument.ts +24 -0
- package/apps/shared/components/mail/reading/themeAdaptation.ts +221 -0
- package/apps/shared/components/mail/reading/themeSurface.ts +90 -0
- package/apps/shared/components/mail/reading/viewOriginal.ts +38 -0
- package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/components/sharing/PrincipalPicker.tsx +145 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +5 -3
- package/apps/shared/components/tasks/layout/TasksShell.tsx +9 -5
- package/apps/shared/mail/folderCounts.ts +9 -3
- package/apps/shared/mail/folderTree.ts +143 -0
- package/apps/shared/mail/outbox/UnlockBridge.tsx +23 -0
- package/apps/shared/mail/outbox/composeBridge.ts +69 -0
- package/apps/shared/mail/outbox/outboxState.ts +88 -0
- package/apps/shared/mail/outbox/pendingSends.ts +146 -0
- package/apps/shared/mail/outbox/sendDecision.ts +157 -0
- package/apps/shared/mail/outbox/sendJob.ts +427 -0
- package/apps/shared/mail/outbox/sendOutcomes.ts +146 -0
- package/apps/shared/mail/outbox/sendState.ts +35 -0
- package/apps/shared/mail/outbox/useOutboxStatus.ts +61 -0
- package/apps/shared/mail/useMailConnection.ts +81 -28
- package/apps/shared/mail/useMailLiveUpdates.ts +55 -10
- package/apps/shared/mail/useNewMailNotifications.ts +47 -32
- package/apps/shared/navigation/AppRouter.tsx +2 -2
- package/apps/shared/notifications/NotificationCenter.tsx +215 -0
- package/apps/shared/notifications/NotificationHistoryDialog.tsx +90 -0
- package/apps/shared/notifications/apiErrors.ts +98 -0
- package/apps/shared/notifications/headerOffset.ts +35 -0
- package/apps/shared/notifications/pushStatus.ts +56 -0
- package/apps/shared/notifications/store.ts +549 -0
- package/apps/shared/notifications/systemErrors.ts +63 -0
- package/apps/shared/notifications/useNotifications.ts +41 -0
- package/apps/shared/signing/enrollmentStorage.ts +33 -0
- package/apps/shared/signing/enrollmentTracker.ts +385 -0
- package/apps/shared/signing/enrollmentView.ts +251 -0
- package/apps/shared/signing/signingInfo.ts +58 -0
- package/apps/shared/signing/useNow.ts +19 -0
- package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -0
- package/apps/shared/styles/app.css +80 -0
- package/apps/www/_layout.tsx +8 -2
- package/apps/www/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +9 -14
- package/apps/www/contacts/[uid].tsx +5 -8
- package/apps/www/contacts/index.tsx +587 -584
- package/apps/www/index.tsx +38 -27
- package/apps/www/messages/[uid].tsx +3 -1
- package/apps/www/settings/appearance/index.tsx +21 -0
- package/apps/www/settings/auto-reply/index.tsx +3 -7
- package/apps/www/settings/encryption/index.tsx +151 -116
- package/apps/www/settings/filters/[uid].tsx +3 -1
- package/apps/www/settings/filters/new/index.tsx +3 -1
- package/apps/www/settings/privacy/index.tsx +3 -8
- package/apps/www/settings/read-receipts/index.tsx +3 -7
- package/apps/www/settings/sharing/index.tsx +44 -65
- package/apps/www/settings/signatures/[uid].tsx +3 -1
- package/apps/www/settings/signatures/new/index.tsx +3 -2
- package/apps/www/tasks/index.tsx +11 -16
- package/dist/apps/admin/_layout.d.ts +5 -1
- package/dist/apps/admin/_layout.js +3 -2
- package/dist/apps/admin/index.js +3 -2
- package/dist/apps/admin/ingest-queue/index.js +2 -1
- package/dist/apps/admin/mailboxes/[uid].js +9 -6
- package/dist/apps/admin/quarantine/index.js +2 -1
- package/dist/apps/admin/signing-certificates/index.d.ts +3 -0
- package/dist/apps/admin/signing-certificates/index.js +161 -0
- package/dist/apps/escrow/_layout.d.ts +5 -1
- package/dist/apps/escrow/_layout.js +3 -2
- package/dist/apps/shared/appearance/AppearanceForm.d.ts +7 -0
- package/dist/apps/shared/appearance/AppearanceForm.js +131 -0
- package/dist/apps/shared/appearance/AppearanceHead.d.ts +15 -0
- package/dist/apps/shared/appearance/AppearanceHead.js +31 -0
- package/dist/apps/shared/appearance/AppearanceProvider.d.ts +32 -0
- package/dist/apps/shared/appearance/AppearanceProvider.js +390 -0
- package/dist/apps/shared/appearance/ColorField.d.ts +22 -0
- package/dist/apps/shared/appearance/ColorField.js +40 -0
- package/dist/apps/shared/appearance/appearanceCache.d.ts +45 -0
- package/dist/apps/shared/appearance/appearanceCache.js +80 -0
- package/dist/apps/shared/appearance/appearanceContext.d.ts +45 -0
- package/dist/apps/shared/appearance/appearanceContext.js +29 -0
- package/dist/apps/shared/appearance/bootScript.d.ts +16 -0
- package/dist/apps/shared/appearance/bootScript.js +33 -0
- package/dist/apps/shared/appearance/color.d.ts +65 -0
- package/dist/apps/shared/appearance/color.js +132 -0
- package/dist/apps/shared/appearance/photo.d.ts +17 -0
- package/dist/apps/shared/appearance/photo.js +79 -0
- package/dist/apps/shared/appearance/resolvedTheme.d.ts +21 -0
- package/dist/apps/shared/appearance/resolvedTheme.js +55 -0
- package/dist/apps/shared/appearance/theme.d.ts +111 -0
- package/dist/apps/shared/appearance/theme.js +251 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +16 -6
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +10 -5
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +49 -34
- package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +2 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +7 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +106 -20
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +2 -1
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +8 -8
- package/dist/apps/shared/components/contacts/ContactForm.js +1 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +4 -2
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +24 -9
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -1
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -4
- package/dist/apps/shared/components/layout/AppShell.d.ts +6 -1
- package/dist/apps/shared/components/layout/AppShell.js +59 -13
- package/dist/apps/shared/components/layout/BrandingChrome.d.ts +69 -10
- package/dist/apps/shared/components/layout/BrandingChrome.js +140 -12
- package/dist/apps/shared/components/layout/RailIcon.d.ts +21 -0
- package/dist/apps/shared/components/layout/RailIcon.js +85 -0
- package/dist/apps/shared/components/layout/ResponsiveToolbar.d.ts +55 -0
- package/dist/apps/shared/components/layout/ResponsiveToolbar.js +199 -0
- package/dist/apps/shared/components/layout/ThemeSwitch.d.ts +9 -0
- package/dist/apps/shared/components/layout/ThemeSwitch.js +54 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +7 -1
- package/dist/apps/shared/components/layout/UserMenu.js +41 -7
- package/dist/apps/shared/components/mail/ConversationList.js +3 -2
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +2 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +42 -20
- package/dist/apps/shared/components/mail/LazyReadingPane.js +9 -8
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +3 -4
- package/dist/apps/shared/components/mail/MailSelectionBar.js +2 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +18 -7
- package/dist/apps/shared/components/mail/MessageDetailPane.js +86 -41
- package/dist/apps/shared/components/mail/OutboxBadge.d.ts +17 -0
- package/dist/apps/shared/components/mail/OutboxBadge.js +18 -0
- package/dist/apps/shared/components/mail/OutboxRowStatus.d.ts +10 -0
- package/dist/apps/shared/components/mail/OutboxRowStatus.js +19 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +8 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +28 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +2 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +229 -328
- package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +49 -0
- package/dist/apps/shared/components/mail/compose/encryptionRequirement.js +35 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +2 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +20 -14
- package/dist/apps/shared/components/mail/reading/EncryptedBody.d.ts +21 -0
- package/dist/apps/shared/components/mail/reading/EncryptedBody.js +22 -0
- package/dist/apps/shared/components/mail/reading/EncryptedPreview.d.ts +18 -0
- package/dist/apps/shared/components/mail/reading/EncryptedPreview.js +22 -0
- package/dist/apps/shared/components/mail/reading/MessageBody.d.ts +32 -0
- package/dist/apps/shared/components/mail/reading/MessageBody.js +136 -0
- package/dist/apps/shared/components/mail/reading/MessageCard.d.ts +62 -0
- package/dist/apps/shared/components/mail/reading/MessageCard.js +58 -0
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +38 -0
- package/dist/apps/shared/components/mail/reading/bodyContent.js +84 -0
- package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +47 -0
- package/dist/apps/shared/components/mail/reading/bodyHtml.js +267 -0
- package/dist/apps/shared/components/mail/reading/color.d.ts +63 -0
- package/dist/apps/shared/components/mail/reading/color.js +201 -0
- package/dist/apps/shared/components/mail/reading/frameControl.d.ts +33 -0
- package/dist/apps/shared/components/mail/reading/frameControl.js +120 -0
- package/dist/apps/shared/components/mail/reading/frameDocument.d.ts +52 -0
- package/dist/apps/shared/components/mail/reading/frameDocument.js +98 -0
- package/dist/apps/shared/components/mail/reading/safeDocument.d.ts +3 -0
- package/dist/apps/shared/components/mail/reading/safeDocument.js +12 -0
- package/dist/apps/shared/components/mail/reading/themeAdaptation.d.ts +56 -0
- package/dist/apps/shared/components/mail/reading/themeAdaptation.js +144 -0
- package/dist/apps/shared/components/mail/reading/themeSurface.d.ts +17 -0
- package/dist/apps/shared/components/mail/reading/themeSurface.js +83 -0
- package/dist/apps/shared/components/mail/reading/viewOriginal.d.ts +9 -0
- package/dist/apps/shared/components/mail/reading/viewOriginal.js +32 -0
- package/dist/apps/shared/components/settings/SigningCertificateCard.d.ts +32 -0
- package/dist/apps/shared/components/settings/SigningCertificateCard.js +98 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +26 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.js +77 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +4 -2
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +3 -1
- package/dist/apps/shared/mail/folderCounts.d.ts +1 -1
- package/dist/apps/shared/mail/folderCounts.js +9 -3
- package/dist/apps/shared/mail/folderTree.d.ts +55 -0
- package/dist/apps/shared/mail/folderTree.js +116 -0
- package/dist/apps/shared/mail/outbox/UnlockBridge.d.ts +5 -0
- package/dist/apps/shared/mail/outbox/UnlockBridge.js +18 -0
- package/dist/apps/shared/mail/outbox/composeBridge.d.ts +34 -0
- package/dist/apps/shared/mail/outbox/composeBridge.js +32 -0
- package/dist/apps/shared/mail/outbox/outboxState.d.ts +41 -0
- package/dist/apps/shared/mail/outbox/outboxState.js +42 -0
- package/dist/apps/shared/mail/outbox/pendingSends.d.ts +39 -0
- package/dist/apps/shared/mail/outbox/pendingSends.js +109 -0
- package/dist/apps/shared/mail/outbox/sendDecision.d.ts +80 -0
- package/dist/apps/shared/mail/outbox/sendDecision.js +94 -0
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +68 -0
- package/dist/apps/shared/mail/outbox/sendJob.js +319 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +3 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +141 -0
- package/dist/apps/shared/mail/outbox/sendState.d.ts +25 -0
- package/dist/apps/shared/mail/outbox/sendState.js +8 -0
- package/dist/apps/shared/mail/outbox/useOutboxStatus.d.ts +11 -0
- package/dist/apps/shared/mail/outbox/useOutboxStatus.js +49 -0
- package/dist/apps/shared/mail/useMailConnection.d.ts +9 -8
- package/dist/apps/shared/mail/useMailConnection.js +64 -22
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +17 -4
- package/dist/apps/shared/mail/useMailLiveUpdates.js +36 -10
- package/dist/apps/shared/mail/useNewMailNotifications.d.ts +4 -10
- package/dist/apps/shared/mail/useNewMailNotifications.js +43 -23
- package/dist/apps/shared/navigation/AppRouter.js +2 -2
- package/dist/apps/shared/notifications/NotificationCenter.d.ts +19 -0
- package/dist/apps/shared/notifications/NotificationCenter.js +90 -0
- package/dist/apps/shared/notifications/NotificationHistoryDialog.d.ts +10 -0
- package/dist/apps/shared/notifications/NotificationHistoryDialog.js +36 -0
- package/dist/apps/shared/notifications/apiErrors.d.ts +19 -0
- package/dist/apps/shared/notifications/apiErrors.js +86 -0
- package/dist/apps/shared/notifications/headerOffset.d.ts +8 -0
- package/dist/apps/shared/notifications/headerOffset.js +33 -0
- package/dist/apps/shared/notifications/pushStatus.d.ts +9 -0
- package/dist/apps/shared/notifications/pushStatus.js +54 -0
- package/dist/apps/shared/notifications/store.d.ts +136 -0
- package/dist/apps/shared/notifications/store.js +416 -0
- package/dist/apps/shared/notifications/systemErrors.d.ts +9 -0
- package/dist/apps/shared/notifications/systemErrors.js +58 -0
- package/dist/apps/shared/notifications/useNotifications.d.ts +17 -0
- package/dist/apps/shared/notifications/useNotifications.js +18 -0
- package/dist/apps/shared/signing/enrollmentStorage.d.ts +10 -0
- package/dist/apps/shared/signing/enrollmentStorage.js +33 -0
- package/dist/apps/shared/signing/enrollmentTracker.d.ts +73 -0
- package/dist/apps/shared/signing/enrollmentTracker.js +307 -0
- package/dist/apps/shared/signing/enrollmentView.d.ts +57 -0
- package/dist/apps/shared/signing/enrollmentView.js +214 -0
- package/dist/apps/shared/signing/signingInfo.d.ts +11 -0
- package/dist/apps/shared/signing/signingInfo.js +48 -0
- package/dist/apps/shared/signing/useNow.d.ts +2 -0
- package/dist/apps/shared/signing/useNow.js +18 -0
- package/dist/apps/shared/signing/useSigningEnrollmentWatcher.d.ts +16 -0
- package/dist/apps/shared/signing/useSigningEnrollmentWatcher.js +87 -0
- package/dist/apps/shared/styles/app.css +80 -0
- package/dist/apps/www/_layout.d.ts +5 -1
- package/dist/apps/www/_layout.js +3 -2
- package/dist/apps/www/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +3 -4
- package/dist/apps/www/contacts/[uid].js +5 -7
- package/dist/apps/www/contacts/index.js +14 -21
- package/dist/apps/www/index.js +37 -19
- package/dist/apps/www/messages/[uid].js +3 -1
- package/dist/apps/www/settings/appearance/index.d.ts +4 -0
- package/dist/apps/www/settings/appearance/index.js +13 -0
- package/dist/apps/www/settings/auto-reply/index.js +3 -6
- package/dist/apps/www/settings/encryption/index.js +103 -95
- package/dist/apps/www/settings/filters/[uid].js +3 -1
- package/dist/apps/www/settings/filters/new/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +5 -8
- package/dist/apps/www/settings/read-receipts/index.js +3 -6
- package/dist/apps/www/settings/sharing/index.js +19 -33
- package/dist/apps/www/settings/signatures/[uid].js +3 -1
- package/dist/apps/www/settings/signatures/new/index.js +3 -2
- package/dist/apps/www/tasks/index.js +8 -14
- package/package.json +2 -2
- package/apps/shared/components/mail/NewMailToasts.tsx +0 -141
- package/apps/shared/components/mail/compose/SendFailureAlert.tsx +0 -48
- package/dist/apps/shared/components/mail/NewMailToasts.d.ts +0 -19
- package/dist/apps/shared/components/mail/NewMailToasts.js +0 -55
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +0 -14
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +0 -11
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The app's one notification store: every pop-up - new mail, a failed send, an API error, an expired session - goes through
|
|
7
|
+
* `notify()`. It is deliberately framework-free (no React, no DOM beyond `sessionStorage`/timers), so it can be called from a hook,
|
|
8
|
+
* a plain function or an `apiFetch` error path alike; `NotificationCenter` (the view, mounted once in the app frame) and
|
|
9
|
+
* `useNotifications()` only *read* it.
|
|
10
|
+
*
|
|
11
|
+
* Rules, all enforced here rather than by the view so they hold whoever renders:
|
|
12
|
+
*
|
|
13
|
+
* - **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
|
|
14
|
+
* anything with actions) that finds the stack full pushes out the oldest one that would have gone by itself, rather than waiting behind
|
|
15
|
+
* 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.
|
|
16
|
+
* - **Sticky until dismissed or resolved**: errors and notifications with actions never expire (`update()` or `dismiss()` resolve them).
|
|
17
|
+
* Everything else goes by itself after its kind's default time, or `timeoutMs`. `sticky` overrides both ways.
|
|
18
|
+
* - **A clock that stops**: a notification's remaining time stops while it is hovered or focused (`setPaused()`) and while the tab is
|
|
19
|
+
* hidden (`setAllPaused()`), and continues from where it was.
|
|
20
|
+
* - **Deduplicated by `dedupeKey`**: a second notification with a key that is still on screen (or waiting) does not stack - it replaces
|
|
21
|
+
* the first one's content, restarts its clock and raises its `count`, so a flapping error is one pop-up saying "x3".
|
|
22
|
+
* - **Nothing is lost when one goes**: the last `HISTORY_LIMIT` (30, except new-mail pop-ups, which the inbox already holds) are
|
|
23
|
+
* kept, in memory and in `sessionStorage`, with which errors nobody has seen yet.
|
|
24
|
+
*
|
|
25
|
+
* Nothing is stored where there is no `window` (server-side rendering): the module state is shared between requests there, so
|
|
26
|
+
* `notify()` only hands back an id.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type NotificationKind = "mail" | "info" | "success" | "warning" | "error";
|
|
30
|
+
|
|
31
|
+
export interface NotificationAction {
|
|
32
|
+
label: string;
|
|
33
|
+
/** Runs when the action is used. */
|
|
34
|
+
onClick?: () => void;
|
|
35
|
+
/** Or a link to follow (a plain `<a>`, so the router's link handling applies). */
|
|
36
|
+
href?: string;
|
|
37
|
+
/** Leave the notification on screen after the action ran. By default an action resolves (dismisses) it. */
|
|
38
|
+
keepOpen?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface NotifyInput {
|
|
42
|
+
/** Reuse an id to replace a notification (it is the same one: its content is swapped and its clock restarted). Generated when absent. */
|
|
43
|
+
id?: string;
|
|
44
|
+
kind: NotificationKind;
|
|
45
|
+
title: string;
|
|
46
|
+
/** A second, muted line next to the title (a mail pop-up's sender address). */
|
|
47
|
+
subtitle?: string;
|
|
48
|
+
message?: string;
|
|
49
|
+
/** A mail pop-up's preview text, under `message` (its subject). */
|
|
50
|
+
preview?: string;
|
|
51
|
+
/** A small muted explanation above the actions (the desktop-notification offer). */
|
|
52
|
+
hint?: string;
|
|
53
|
+
/** Technical detail: a list of lines, or one block of text. Shown collapsed, monospace, with a copy button. */
|
|
54
|
+
details?: string[] | string;
|
|
55
|
+
actions?: NotificationAction[];
|
|
56
|
+
/** The whole pop-up is a link to this (new mail opens the message). */
|
|
57
|
+
href?: string;
|
|
58
|
+
/** Never expires by itself. Default: errors and anything with actions. */
|
|
59
|
+
sticky?: boolean;
|
|
60
|
+
/** How long it stays, in ms. Default by kind, see `DEFAULT_TIMEOUT_MS`. Ignored for a sticky one. */
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
/** Notifications with the same key that are still showing are one: see the module doc comment. */
|
|
63
|
+
dedupeKey?: string;
|
|
64
|
+
/** Keep it out of the history (default: kept, except `mail`). */
|
|
65
|
+
history?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A notification as a view renders it. */
|
|
69
|
+
export interface NotificationView {
|
|
70
|
+
id: string;
|
|
71
|
+
kind: NotificationKind;
|
|
72
|
+
title: string;
|
|
73
|
+
subtitle?: string;
|
|
74
|
+
message?: string;
|
|
75
|
+
preview?: string;
|
|
76
|
+
hint?: string;
|
|
77
|
+
details: string[];
|
|
78
|
+
actions: NotificationAction[];
|
|
79
|
+
href?: string;
|
|
80
|
+
/** How many times this one was raised (`dedupeKey`), 1 for a single one. */
|
|
81
|
+
count: number;
|
|
82
|
+
sticky: boolean;
|
|
83
|
+
createdAt: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface HistoryEntry {
|
|
87
|
+
id: string;
|
|
88
|
+
kind: NotificationKind;
|
|
89
|
+
title: string;
|
|
90
|
+
message?: string;
|
|
91
|
+
details: string[];
|
|
92
|
+
count: number;
|
|
93
|
+
/** When it was last raised (epoch ms). */
|
|
94
|
+
at: number;
|
|
95
|
+
/** Nobody has opened the history since it arrived. */
|
|
96
|
+
unseen: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface NotificationsSnapshot {
|
|
100
|
+
/** What is on screen, oldest first. */
|
|
101
|
+
visible: NotificationView[];
|
|
102
|
+
/** How many are waiting for room. */
|
|
103
|
+
queued: number;
|
|
104
|
+
/** The last `HISTORY_LIMIT`, newest first. */
|
|
105
|
+
history: HistoryEntry[];
|
|
106
|
+
/** Errors in the history that nobody has looked at. */
|
|
107
|
+
unseenErrors: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const MAX_VISIBLE = 3;
|
|
111
|
+
export const MAX_QUEUED = 20;
|
|
112
|
+
export const QUEUE_STALE_MS = 30_000;
|
|
113
|
+
export const HISTORY_LIMIT = 30;
|
|
114
|
+
export const HISTORY_STORAGE_KEY = "rapidmx-notification-history";
|
|
115
|
+
/** The most technical lines and the longest line kept per notification - a transport error can carry a whole SMTP transcript. */
|
|
116
|
+
export const MAX_DETAIL_LINES = 50;
|
|
117
|
+
export const MAX_DETAIL_LINE_LENGTH = 500;
|
|
118
|
+
|
|
119
|
+
/** How long a non-sticky notification stays, by kind. Errors are sticky, so theirs is only for an explicit `sticky: false`. */
|
|
120
|
+
export const DEFAULT_TIMEOUT_MS: Record<NotificationKind, number> = {
|
|
121
|
+
mail: 8_000,
|
|
122
|
+
info: 6_000,
|
|
123
|
+
success: 5_000,
|
|
124
|
+
warning: 10_000,
|
|
125
|
+
error: 10_000,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
interface Entry {
|
|
129
|
+
id: string;
|
|
130
|
+
kind: NotificationKind;
|
|
131
|
+
title: string;
|
|
132
|
+
subtitle?: string;
|
|
133
|
+
message?: string;
|
|
134
|
+
preview?: string;
|
|
135
|
+
hint?: string;
|
|
136
|
+
details: string[];
|
|
137
|
+
actions: NotificationAction[];
|
|
138
|
+
href?: string;
|
|
139
|
+
dedupeKey?: string;
|
|
140
|
+
sticky: boolean;
|
|
141
|
+
timeoutMs: number;
|
|
142
|
+
count: number;
|
|
143
|
+
createdAt: number;
|
|
144
|
+
history: boolean;
|
|
145
|
+
status: "visible" | "queued";
|
|
146
|
+
remainingMs: number;
|
|
147
|
+
startedAt?: number;
|
|
148
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
149
|
+
paused: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const EMPTY_SNAPSHOT: NotificationsSnapshot = { visible: [], queued: 0, history: [], unseenErrors: 0 };
|
|
153
|
+
|
|
154
|
+
let entries: Entry[] = [];
|
|
155
|
+
let history: HistoryEntry[] = [];
|
|
156
|
+
let historyLoaded = false;
|
|
157
|
+
let allPaused = false;
|
|
158
|
+
let counter = 0;
|
|
159
|
+
let snapshot: NotificationsSnapshot = EMPTY_SNAPSHOT;
|
|
160
|
+
const listeners = new Set<() => void>();
|
|
161
|
+
|
|
162
|
+
function isClient(): boolean {
|
|
163
|
+
return typeof window !== "undefined";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeDetails(details: string[] | string | undefined): string[] {
|
|
167
|
+
if (details === undefined) {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
const lines = (Array.isArray(details) ? details : details.split(/\r?\n/)).filter((line) => line.length > 0);
|
|
171
|
+
return lines.slice(0, MAX_DETAIL_LINES).map((line) => (line.length > MAX_DETAIL_LINE_LENGTH ? `${line.slice(0, MAX_DETAIL_LINE_LENGTH)}...` : line));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function loadHistory(): void {
|
|
175
|
+
if (historyLoaded || !isClient()) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
historyLoaded = true;
|
|
179
|
+
try {
|
|
180
|
+
const stored = JSON.parse(sessionStorage.getItem(HISTORY_STORAGE_KEY) ?? "[]") as HistoryEntry[];
|
|
181
|
+
history = Array.isArray(stored)
|
|
182
|
+
? stored
|
|
183
|
+
.filter((item) => !!item && typeof item.id === "string" && typeof item.title === "string" && typeof item.at === "number")
|
|
184
|
+
.slice(0, HISTORY_LIMIT)
|
|
185
|
+
.map((item) => ({ ...item, details: Array.isArray(item.details) ? item.details : [], unseen: item.unseen === true }))
|
|
186
|
+
: [];
|
|
187
|
+
} catch {
|
|
188
|
+
history = [];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function saveHistory(): void {
|
|
193
|
+
try {
|
|
194
|
+
sessionStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(history));
|
|
195
|
+
} catch {
|
|
196
|
+
// Storage unavailable or full: the history is only kept in memory, which is all it needs to be for this page.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function view(entry: Entry): NotificationView {
|
|
201
|
+
return {
|
|
202
|
+
id: entry.id,
|
|
203
|
+
kind: entry.kind,
|
|
204
|
+
title: entry.title,
|
|
205
|
+
subtitle: entry.subtitle,
|
|
206
|
+
message: entry.message,
|
|
207
|
+
preview: entry.preview,
|
|
208
|
+
hint: entry.hint,
|
|
209
|
+
details: entry.details,
|
|
210
|
+
actions: entry.actions,
|
|
211
|
+
href: entry.href,
|
|
212
|
+
count: entry.count,
|
|
213
|
+
sticky: entry.sticky,
|
|
214
|
+
createdAt: entry.createdAt,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function rebuild(): void {
|
|
219
|
+
const visible = entries.filter((entry) => entry.status === "visible").map(view);
|
|
220
|
+
snapshot = {
|
|
221
|
+
visible,
|
|
222
|
+
queued: entries.length - visible.length,
|
|
223
|
+
history,
|
|
224
|
+
unseenErrors: history.filter((item) => item.unseen && item.kind === "error").length,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function emit(): void {
|
|
229
|
+
rebuild();
|
|
230
|
+
for (const listener of [...listeners]) {
|
|
231
|
+
listener();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function findEntry(id: string): Entry | undefined {
|
|
236
|
+
return entries.find((entry) => entry.id === id);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Records (or refreshes) `entry` in the history, newest first. */
|
|
240
|
+
function record(entry: Entry): void {
|
|
241
|
+
if (!entry.history) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
loadHistory();
|
|
245
|
+
const item: HistoryEntry = {
|
|
246
|
+
id: entry.id,
|
|
247
|
+
kind: entry.kind,
|
|
248
|
+
title: entry.title,
|
|
249
|
+
message: entry.message,
|
|
250
|
+
details: entry.details,
|
|
251
|
+
count: entry.count,
|
|
252
|
+
at: Date.now(),
|
|
253
|
+
unseen: true,
|
|
254
|
+
};
|
|
255
|
+
history = [item, ...history.filter((existing) => existing.id !== entry.id)].slice(0, HISTORY_LIMIT);
|
|
256
|
+
saveHistory();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function disarm(entry: Entry): void {
|
|
260
|
+
if (entry.timer !== undefined) {
|
|
261
|
+
clearTimeout(entry.timer);
|
|
262
|
+
entry.timer = undefined;
|
|
263
|
+
entry.remainingMs = Math.max(0, entry.remainingMs - (Date.now() - entry.startedAt!));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Starts (or resumes) the clock of a visible, non-sticky, unpaused notification. */
|
|
268
|
+
function arm(entry: Entry): void {
|
|
269
|
+
if (entry.status !== "visible" || entry.sticky || entry.paused || allPaused || entry.timer !== undefined) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
entry.startedAt = Date.now();
|
|
273
|
+
entry.timer = setTimeout(() => {
|
|
274
|
+
entry.timer = undefined;
|
|
275
|
+
remove(entry.id);
|
|
276
|
+
}, entry.remainingMs);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function visibleCount(): number {
|
|
280
|
+
return entries.filter((entry) => entry.status === "visible").length;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function show(entry: Entry): void {
|
|
284
|
+
entry.status = "visible";
|
|
285
|
+
entry.remainingMs = entry.timeoutMs;
|
|
286
|
+
arm(entry);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Fills free places from the queue, dropping what has waited too long. */
|
|
290
|
+
function promote(): void {
|
|
291
|
+
const now = Date.now();
|
|
292
|
+
entries = entries.filter((entry) => entry.status === "visible" || entry.sticky || now - entry.createdAt < QUEUE_STALE_MS);
|
|
293
|
+
while (visibleCount() < MAX_VISIBLE) {
|
|
294
|
+
const next = entries.find((entry) => entry.status === "queued");
|
|
295
|
+
if (!next) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
show(next);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function remove(id: string): void {
|
|
303
|
+
const entry = findEntry(id);
|
|
304
|
+
if (!entry) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
disarm(entry);
|
|
308
|
+
entries = entries.filter((existing) => existing !== entry);
|
|
309
|
+
promote();
|
|
310
|
+
emit();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Makes room for a sticky notification: the oldest visible one that would have gone by itself leaves (it stays in the history). */
|
|
314
|
+
function evictForSticky(): boolean {
|
|
315
|
+
const victim = entries.find((entry) => entry.status === "visible" && !entry.sticky);
|
|
316
|
+
if (!victim) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
disarm(victim);
|
|
320
|
+
entries = entries.filter((entry) => entry !== victim);
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function applyInput(entry: Entry, input: NotifyInput): void {
|
|
325
|
+
entry.kind = input.kind;
|
|
326
|
+
entry.title = input.title;
|
|
327
|
+
entry.subtitle = input.subtitle;
|
|
328
|
+
entry.message = input.message;
|
|
329
|
+
entry.preview = input.preview;
|
|
330
|
+
entry.hint = input.hint;
|
|
331
|
+
entry.details = normalizeDetails(input.details);
|
|
332
|
+
entry.actions = input.actions ?? [];
|
|
333
|
+
entry.href = input.href;
|
|
334
|
+
entry.sticky = input.sticky ?? (input.kind === "error" || (input.actions?.length ?? 0) > 0);
|
|
335
|
+
entry.timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS[input.kind];
|
|
336
|
+
entry.history = input.history ?? input.kind !== "mail";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Raises a notification and returns its id. See the module doc comment for what happens to it. Usable from anywhere - a component, a
|
|
341
|
+
* hook, a plain function, an `apiFetch` error path - and safe where there is no window (it does nothing there).
|
|
342
|
+
*/
|
|
343
|
+
export function notify(input: NotifyInput): string {
|
|
344
|
+
counter += 1;
|
|
345
|
+
const id = input.id ?? `notification-${counter}`;
|
|
346
|
+
if (!isClient()) {
|
|
347
|
+
return id;
|
|
348
|
+
}
|
|
349
|
+
const sameKey = input.dedupeKey !== undefined ? entries.find((entry) => entry.dedupeKey === input.dedupeKey) : undefined;
|
|
350
|
+
const existing = sameKey ?? findEntry(id);
|
|
351
|
+
if (existing) {
|
|
352
|
+
// The same notification again: its content is replaced and it gets its full time again. Only a repeated dedupe key counts up
|
|
353
|
+
// (an explicit id is a replacement, not another occurrence).
|
|
354
|
+
disarm(existing);
|
|
355
|
+
applyInput(existing, input);
|
|
356
|
+
existing.count += sameKey ? 1 : 0;
|
|
357
|
+
existing.remainingMs = existing.timeoutMs;
|
|
358
|
+
arm(existing);
|
|
359
|
+
record(existing);
|
|
360
|
+
emit();
|
|
361
|
+
return existing.id;
|
|
362
|
+
}
|
|
363
|
+
const entry: Entry = {
|
|
364
|
+
id,
|
|
365
|
+
dedupeKey: input.dedupeKey,
|
|
366
|
+
kind: input.kind,
|
|
367
|
+
title: input.title,
|
|
368
|
+
details: [],
|
|
369
|
+
actions: [],
|
|
370
|
+
sticky: false,
|
|
371
|
+
timeoutMs: 0,
|
|
372
|
+
count: 1,
|
|
373
|
+
createdAt: Date.now(),
|
|
374
|
+
history: true,
|
|
375
|
+
status: "queued",
|
|
376
|
+
remainingMs: 0,
|
|
377
|
+
paused: false,
|
|
378
|
+
};
|
|
379
|
+
applyInput(entry, input);
|
|
380
|
+
const room = visibleCount() < MAX_VISIBLE;
|
|
381
|
+
entries.push(entry);
|
|
382
|
+
if (room) {
|
|
383
|
+
show(entry);
|
|
384
|
+
} else if (entry.sticky && evictForSticky()) {
|
|
385
|
+
show(entry);
|
|
386
|
+
} else {
|
|
387
|
+
// Waiting for room: the queue itself is bounded, and the oldest waiting one that would not have stayed is the first to go.
|
|
388
|
+
const waiting = entries.filter((e) => e.status === "queued");
|
|
389
|
+
if (waiting.length > MAX_QUEUED) {
|
|
390
|
+
const dropped = waiting.find((e) => !e.sticky) ?? waiting[0];
|
|
391
|
+
entries = entries.filter((e) => e !== dropped);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
record(entry);
|
|
395
|
+
emit();
|
|
396
|
+
return id;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Changes a notification that is still showing or waiting - the way to resolve it ("Sending..." becoming "Sent"). Only the given
|
|
401
|
+
* 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
|
|
402
|
+
* notification any more (it was dismissed or expired); its history entry is updated all the same.
|
|
403
|
+
*/
|
|
404
|
+
export function update(id: string, patch: Partial<Omit<NotifyInput, "id" | "dedupeKey">>): boolean {
|
|
405
|
+
const entry = findEntry(id);
|
|
406
|
+
if (!entry) {
|
|
407
|
+
loadHistory();
|
|
408
|
+
const item = history.find((existing) => existing.id === id);
|
|
409
|
+
if (item) {
|
|
410
|
+
history = history.map((existing) =>
|
|
411
|
+
existing === item
|
|
412
|
+
? {
|
|
413
|
+
...existing,
|
|
414
|
+
kind: patch.kind ?? existing.kind,
|
|
415
|
+
title: patch.title ?? existing.title,
|
|
416
|
+
message: "message" in patch ? patch.message : existing.message,
|
|
417
|
+
details: "details" in patch ? normalizeDetails(patch.details) : existing.details,
|
|
418
|
+
}
|
|
419
|
+
: existing,
|
|
420
|
+
);
|
|
421
|
+
saveHistory();
|
|
422
|
+
emit();
|
|
423
|
+
}
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
disarm(entry);
|
|
427
|
+
applyInput(entry, {
|
|
428
|
+
kind: entry.kind,
|
|
429
|
+
title: entry.title,
|
|
430
|
+
subtitle: entry.subtitle,
|
|
431
|
+
message: entry.message,
|
|
432
|
+
preview: entry.preview,
|
|
433
|
+
hint: entry.hint,
|
|
434
|
+
details: entry.details,
|
|
435
|
+
actions: entry.actions,
|
|
436
|
+
href: entry.href,
|
|
437
|
+
history: entry.history,
|
|
438
|
+
// A change of kind or actions recomputes stickiness (unless the patch says), so it is only carried over otherwise.
|
|
439
|
+
sticky: "kind" in patch || "actions" in patch ? undefined : entry.sticky,
|
|
440
|
+
// Its own time again, unless the kind changed (then the new kind's default) or the patch says.
|
|
441
|
+
timeoutMs: "kind" in patch ? undefined : entry.timeoutMs,
|
|
442
|
+
...patch,
|
|
443
|
+
});
|
|
444
|
+
entry.remainingMs = entry.timeoutMs;
|
|
445
|
+
arm(entry);
|
|
446
|
+
record(entry);
|
|
447
|
+
emit();
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Removes a notification from the screen (or the queue). Its history entry stays. Does nothing for an unknown id. */
|
|
452
|
+
export function dismiss(id: string): void {
|
|
453
|
+
remove(id);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Removes everything showing and waiting. */
|
|
457
|
+
export function dismissAll(): void {
|
|
458
|
+
for (const entry of entries) {
|
|
459
|
+
disarm(entry);
|
|
460
|
+
}
|
|
461
|
+
entries = [];
|
|
462
|
+
emit();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Stops (or restarts) one notification's clock: it is being hovered or holds the keyboard focus. */
|
|
466
|
+
export function setPaused(id: string, paused: boolean): void {
|
|
467
|
+
const entry = findEntry(id);
|
|
468
|
+
if (!entry || entry.paused === paused) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
entry.paused = paused;
|
|
472
|
+
if (paused) {
|
|
473
|
+
disarm(entry);
|
|
474
|
+
} else {
|
|
475
|
+
arm(entry);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Stops (or restarts) every clock: the tab is hidden, so nobody could have read what is on screen. */
|
|
480
|
+
export function setAllPaused(paused: boolean): void {
|
|
481
|
+
if (allPaused === paused) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
allPaused = paused;
|
|
485
|
+
for (const entry of entries) {
|
|
486
|
+
if (paused) {
|
|
487
|
+
disarm(entry);
|
|
488
|
+
} else {
|
|
489
|
+
arm(entry);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Marks every history entry as seen - the user has opened the history. */
|
|
495
|
+
export function markHistorySeen(): void {
|
|
496
|
+
loadHistory();
|
|
497
|
+
if (!history.some((item) => item.unseen)) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
history = history.map((item) => (item.unseen ? { ...item, unseen: false } : item));
|
|
501
|
+
saveHistory();
|
|
502
|
+
emit();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Empties the history. */
|
|
506
|
+
export function clearHistory(): void {
|
|
507
|
+
loadHistory();
|
|
508
|
+
history = [];
|
|
509
|
+
saveHistory();
|
|
510
|
+
emit();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function subscribeNotifications(listener: () => void): () => void {
|
|
514
|
+
listeners.add(listener);
|
|
515
|
+
return () => {
|
|
516
|
+
listeners.delete(listener);
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function getNotificationsSnapshot(): NotificationsSnapshot {
|
|
521
|
+
if (!historyLoaded && isClient()) {
|
|
522
|
+
// The history is read from storage on first use, without telling listeners (this runs during a render).
|
|
523
|
+
loadHistory();
|
|
524
|
+
rebuild();
|
|
525
|
+
}
|
|
526
|
+
return snapshot;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function getServerNotificationsSnapshot(): NotificationsSnapshot {
|
|
530
|
+
return EMPTY_SNAPSHOT;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Forgets everything and stops every clock - for a test, or a page that outlives its session. */
|
|
534
|
+
export function resetNotifications(): void {
|
|
535
|
+
for (const entry of entries) {
|
|
536
|
+
disarm(entry);
|
|
537
|
+
}
|
|
538
|
+
entries = [];
|
|
539
|
+
history = [];
|
|
540
|
+
historyLoaded = false;
|
|
541
|
+
allPaused = false;
|
|
542
|
+
counter = 0;
|
|
543
|
+
snapshot = EMPTY_SNAPSHOT;
|
|
544
|
+
try {
|
|
545
|
+
sessionStorage.removeItem(HISTORY_STORAGE_KEY);
|
|
546
|
+
} catch {
|
|
547
|
+
// Nothing stored.
|
|
548
|
+
}
|
|
549
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { isAbortError } from "./apiErrors.js";
|
|
8
|
+
import { notify } from "./store.js";
|
|
9
|
+
|
|
10
|
+
/** The one key every unexpected failure shares: a page that throws in a loop is one pop-up with a count, never a flood. */
|
|
11
|
+
export const SYSTEM_ERROR_KEY = "system-error";
|
|
12
|
+
|
|
13
|
+
/** Browsers report this harmless layout notice through `error` events; it is not something wrong. */
|
|
14
|
+
const IGNORED_MESSAGE = /ResizeObserver loop/i;
|
|
15
|
+
|
|
16
|
+
/** The lines of a stack worth showing: the first few. */
|
|
17
|
+
function stackLines(error: unknown): string[] {
|
|
18
|
+
return error instanceof Error && error.stack ? error.stack.split("\n").slice(0, 6).map((line) => line.trim()) : [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Raises the "Something went wrong" pop-up for something nothing handled. Returns its id, or `""` when it is not worth showing. */
|
|
22
|
+
export function notifySystemError(error: unknown, where?: string): string {
|
|
23
|
+
if (isAbortError(error)) {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
// An unhandled request that was refused for a session that ended is already the "session expired" pop-up.
|
|
27
|
+
if (error instanceof ApiRequestError && error.status === 401) {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
31
|
+
if (IGNORED_MESSAGE.test(message)) {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
const lines = [message ? `${error instanceof Error ? error.name : "Error"}: ${message}` : "An error without a message", ...stackLines(error)];
|
|
35
|
+
return notify({
|
|
36
|
+
kind: "error",
|
|
37
|
+
title: "Something went wrong",
|
|
38
|
+
message: "An unexpected error happened. Your work is unaffected unless something on the page stopped responding; reloading the page is safe.",
|
|
39
|
+
details: where ? [`Where: ${where}`, ...lines] : lines,
|
|
40
|
+
dedupeKey: SYSTEM_ERROR_KEY,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Catches what nothing else did - an unhandled promise rejection or an uncaught error anywhere on the page - and shows one
|
|
46
|
+
* "Something went wrong" pop-up with the details, however many follow. Mounted once, in the app frame.
|
|
47
|
+
*/
|
|
48
|
+
export function useSystemErrorNotifications(): void {
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
function handleError(event: ErrorEvent) {
|
|
51
|
+
notifySystemError(event.error ?? event.message, event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : undefined);
|
|
52
|
+
}
|
|
53
|
+
function handleRejection(event: PromiseRejectionEvent) {
|
|
54
|
+
notifySystemError(event.reason, "unhandled promise rejection");
|
|
55
|
+
}
|
|
56
|
+
window.addEventListener("error", handleError);
|
|
57
|
+
window.addEventListener("unhandledrejection", handleRejection);
|
|
58
|
+
return () => {
|
|
59
|
+
window.removeEventListener("error", handleError);
|
|
60
|
+
window.removeEventListener("unhandledrejection", handleRejection);
|
|
61
|
+
};
|
|
62
|
+
}, []);
|
|
63
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useSyncExternalStore } from "react";
|
|
6
|
+
import {
|
|
7
|
+
NotificationsSnapshot,
|
|
8
|
+
clearHistory,
|
|
9
|
+
dismiss,
|
|
10
|
+
dismissAll,
|
|
11
|
+
getNotificationsSnapshot,
|
|
12
|
+
getServerNotificationsSnapshot,
|
|
13
|
+
markHistorySeen,
|
|
14
|
+
notify,
|
|
15
|
+
subscribeNotifications,
|
|
16
|
+
update,
|
|
17
|
+
} from "./store.js";
|
|
18
|
+
|
|
19
|
+
/** How many errors in the history nobody has looked at - a component that shows only this doesn't render again for every pop-up. */
|
|
20
|
+
export function useUnseenErrorCount(): number {
|
|
21
|
+
return useSyncExternalStore(subscribeNotifications, () => getNotificationsSnapshot().unseenErrors, () => 0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Notifications extends NotificationsSnapshot {
|
|
25
|
+
notify: typeof notify;
|
|
26
|
+
update: typeof update;
|
|
27
|
+
dismiss: typeof dismiss;
|
|
28
|
+
dismissAll: typeof dismissAll;
|
|
29
|
+
/** Marks the history as seen - clears the unseen-error count. */
|
|
30
|
+
markHistorySeen: typeof markHistorySeen;
|
|
31
|
+
clearHistory: typeof clearHistory;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The notification stack and its history, and the functions that change them. The functions are the store's own (`notify`, `update`,
|
|
36
|
+
* `dismiss` are plain functions usable outside React too); this hook is for a component that shows or reacts to what is on screen.
|
|
37
|
+
*/
|
|
38
|
+
export function useNotifications(): Notifications {
|
|
39
|
+
const snapshot = useSyncExternalStore(subscribeNotifications, getNotificationsSnapshot, getServerNotificationsSnapshot);
|
|
40
|
+
return { ...snapshot, notify, update, dismiss, dismissAll, markHistorySeen, clearHistory };
|
|
41
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A started signing enrollment's id, per mailbox. Kept only so a reload - and the app frame's watcher, on any page - can ask the server
|
|
8
|
+
* (`checkSignEnrollmentStatus()`) whether that enrollment is still pending: a rotation while it is pending would strand the enrollment's
|
|
9
|
+
* already-submitted private key under a master key that no longer exists. The stored id is never trusted on its own - the server's answer
|
|
10
|
+
* decides the state, and restapi's own `rekey()` refuses (409) a rotation during an enrollment this browser never saw (e.g. one started on
|
|
11
|
+
* another device).
|
|
12
|
+
*/
|
|
13
|
+
export const SIGN_ENROLLMENT_STORAGE_PREFIX = "rapidmx.signEnrollment.";
|
|
14
|
+
|
|
15
|
+
export function readStoredSignEnrollment(mailboxUid: string): string | null {
|
|
16
|
+
try {
|
|
17
|
+
return localStorage.getItem(SIGN_ENROLLMENT_STORAGE_PREFIX + mailboxUid);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function storeSignEnrollment(mailboxUid: string, enrollmentId: string | null): void {
|
|
24
|
+
try {
|
|
25
|
+
if (enrollmentId) {
|
|
26
|
+
localStorage.setItem(SIGN_ENROLLMENT_STORAGE_PREFIX + mailboxUid, enrollmentId);
|
|
27
|
+
} else {
|
|
28
|
+
localStorage.removeItem(SIGN_ENROLLMENT_STORAGE_PREFIX + mailboxUid);
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Storage blocked - restapi's own 409 on rekey still guards a rotation.
|
|
32
|
+
}
|
|
33
|
+
}
|