@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
|
@@ -2,21 +2,21 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import { createContext, useCallback, useEffect, useState } from "react";
|
|
5
|
+
import { createContext, useCallback, useEffect, useRef, useState } from "react";
|
|
6
6
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
7
|
import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
|
+
import { FOLDER_ORDER, MAIL_FOLDER_TYPES, isMailFolder, knownFolderUids, reconcileFolders, removeFolder, upsertFolder } from "./folderTree.js";
|
|
8
9
|
import { useMailLiveUpdates } from "./useMailLiveUpdates.js";
|
|
9
10
|
import { useNewMailNotifications } from "./useNewMailNotifications.js";
|
|
11
|
+
import { usePushConnectionNotice } from "../notifications/pushStatus.js";
|
|
12
|
+
import { registerOutboxCounter } from "./outbox/sendJob.js";
|
|
13
|
+
import { handleSendEvent } from "./outbox/sendOutcomes.js";
|
|
14
|
+
import { useOutboxStatus } from "./outbox/useOutboxStatus.js";
|
|
10
15
|
/** Page size of the one `listMailboxes()` call. */
|
|
11
16
|
export const MAILBOX_LIST_LIMIT = 100;
|
|
12
|
-
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
* A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see `CalendarShell`/`ContactsShell`/`TasksShell`), not
|
|
16
|
-
* Mail - `listFolders()` returns every well-known folder for the mailbox regardless of which app owns it, so Mail's own folder tree filters down
|
|
17
|
-
* to just the mail ones itself, or those other apps' folders leak into its sidebar.
|
|
18
|
-
*/
|
|
19
|
-
export const MAIL_FOLDER_TYPES = new Set([...FOLDER_ORDER, "user"]);
|
|
17
|
+
// The sidebar's order and the folders Mail lists live in `folderTree.ts`; re-exported for what already imports them from here.
|
|
18
|
+
export { FOLDER_ORDER, MAIL_FOLDER_TYPES };
|
|
19
|
+
const NO_TRACKER = { settle: () => undefined, revert: () => undefined };
|
|
20
20
|
/** What the persistent app frame (`AppChrome`) offers to the Mail shell so that it doesn't open a second connection. `null` outside a frame. */
|
|
21
21
|
export const MailConnectionContext = createContext(null);
|
|
22
22
|
/**
|
|
@@ -59,7 +59,7 @@ export function useMailConnection({ userUid, enabled, open }) {
|
|
|
59
59
|
}
|
|
60
60
|
setFoldersLoading(true);
|
|
61
61
|
void Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid)
|
|
62
|
-
.then((result) => ({ mailbox, folders: result.filter(
|
|
62
|
+
.then((result) => ({ mailbox, folders: result.filter(isMailFolder) }))
|
|
63
63
|
.catch((err) => ({
|
|
64
64
|
mailbox,
|
|
65
65
|
folders: [],
|
|
@@ -69,17 +69,18 @@ export function useMailConnection({ userUid, enabled, open }) {
|
|
|
69
69
|
.finally(() => setFoldersLoading(false));
|
|
70
70
|
}, [mailboxes]);
|
|
71
71
|
/** Files a newly created folder under its own mailbox, leaving every other mailbox's list untouched - and ignoring a type the
|
|
72
|
-
* sidebar doesn't list at all, the same filter the fetch above applies.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
72
|
+
* sidebar doesn't list at all, the same filter the fetch above applies. One already there (another client's create event can arrive
|
|
73
|
+
* after, or twice, or alongside our own) is not added again - see `upsertFolder()`. */
|
|
74
|
+
const onFolderCreated = useCallback((folder) => setMailboxFolders((prev) => upsertFolder(prev, folder)), []);
|
|
75
|
+
/** A folder was renamed or changed type elsewhere. */
|
|
76
|
+
const onFolderChanged = useCallback((change) => setMailboxFolders((prev) => upsertFolder(prev, change)), []);
|
|
77
|
+
const onFolderDeleted = useCallback((folderUid) => setMailboxFolders((prev) => removeFolder(prev, folderUid)), []);
|
|
78
|
+
/** A mailbox's folders as the server just listed them (every count refresh does): folders the tree lacks - one created lazily on the
|
|
79
|
+
* first send, or by another client whose event never arrived - are added. */
|
|
80
|
+
const onFoldersListed = useCallback((listed) => setMailboxFolders((prev) => reconcileFolders(prev, listed)), []);
|
|
81
|
+
const askedFolderUidsRef = useRef(new Set());
|
|
82
|
+
const foldersRef = useRef(mailboxFolders);
|
|
83
|
+
foldersRef.current = mailboxFolders;
|
|
83
84
|
// New mail without a reload: push events for every folder, plus a safety-net poll. Held in state beside `mailboxFolders` rather than
|
|
84
85
|
// written into it, so a refreshed unread count never looks like a change of folders to the list on screen (which reloads, and forgets its
|
|
85
86
|
// selection, whenever the folders do). A pop-up (and, in the background, a desktop notification) for each new message the connection
|
|
@@ -90,7 +91,48 @@ export function useMailConnection({ userUid, enabled, open }) {
|
|
|
90
91
|
mailboxes,
|
|
91
92
|
mailboxFolders,
|
|
92
93
|
onFolderCreated,
|
|
94
|
+
onFolderChanged,
|
|
95
|
+
onFolderDeleted,
|
|
96
|
+
onFoldersListed,
|
|
93
97
|
onMessageCreated: notifications.announce,
|
|
98
|
+
onSendEvent: handleSendEvent,
|
|
94
99
|
});
|
|
95
|
-
|
|
100
|
+
const noteFolderUids = useCallback((folderUids) => {
|
|
101
|
+
const known = knownFolderUids(foldersRef.current);
|
|
102
|
+
const asked = askedFolderUidsRef.current;
|
|
103
|
+
let unknown = false;
|
|
104
|
+
for (const uid of folderUids) {
|
|
105
|
+
if (uid && !known.has(uid) && !asked.has(uid)) {
|
|
106
|
+
asked.add(uid);
|
|
107
|
+
unknown = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (unknown) {
|
|
111
|
+
// The counts' read-back lists every mailbox's folders and files the new ones; a burst is one refresh.
|
|
112
|
+
folderCounts.refresh();
|
|
113
|
+
}
|
|
114
|
+
}, [folderCounts.refresh]);
|
|
115
|
+
// "Connection lost" for more than a few seconds is a subtle pop-up that goes when it is back.
|
|
116
|
+
usePushConnectionNotice(!!userUid && enabled);
|
|
117
|
+
// The Outbox indicator: a message being sent from this tab counts at once (before the server has heard of it), settled - or taken back - when
|
|
118
|
+
// the server answers (see `sendJob.ts`); once it accepted one, the folders are read again, since the Outbox may not have existed yet.
|
|
119
|
+
const latestRef = useRef({ mailboxFolders, folderCounts });
|
|
120
|
+
latestRef.current = { mailboxFolders, folderCounts };
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!userUid || !enabled) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
return registerOutboxCounter((mailboxUid) => {
|
|
126
|
+
const { mailboxFolders: known, folderCounts: counts } = latestRef.current;
|
|
127
|
+
const outbox = known.find((entry) => entry.mailbox.uid === mailboxUid)?.folders.find((folder) => folder.type === "outbox");
|
|
128
|
+
return outbox ? counts.track(null, { folderUid: outbox.uid, flags: { read: true } }) : NO_TRACKER;
|
|
129
|
+
}, (mailboxUid) => {
|
|
130
|
+
// The server accepted a message: the Outbox (and, on an older server, Sent Items) may just have been made. The counts' read-back lists
|
|
131
|
+
// every mailbox's folders and files the new ones (`onFoldersListed`); this reads the one mailbox at once.
|
|
132
|
+
latestRef.current.folderCounts.refresh(0);
|
|
133
|
+
listFolders(mailboxUid).then(onFoldersListed, () => undefined);
|
|
134
|
+
});
|
|
135
|
+
}, [userUid, enabled, onFoldersListed]);
|
|
136
|
+
const outbox = useOutboxStatus(mailboxFolders, folderCounts.counts, live.tick, !!userUid && enabled);
|
|
137
|
+
return { status, error, mailboxes, mailboxFolders, foldersLoading, onFolderCreated, noteFolderUids, live, folderCounts, notifications, outbox };
|
|
96
138
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
2
|
+
import { SendEvent } from "@rapidmx/react-shared/mail/sendEvents.js";
|
|
2
3
|
import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
|
|
3
4
|
import { FolderCounts } from "./folderCounts.js";
|
|
5
|
+
import { FolderChange } from "./folderTree.js";
|
|
4
6
|
/** How often the safety-net poll runs while the tab is visible. The push socket does the real work; this is for a network
|
|
5
7
|
* that blocks or silently drops it, and for events published while it was reconnecting (Redis pub/sub is never replayed). */
|
|
6
8
|
export declare const LIVE_POLL_INTERVAL_MS = 45000;
|
|
@@ -17,9 +19,10 @@ export interface LiveUpdates {
|
|
|
17
19
|
export declare const NO_LIVE_UPDATES: LiveUpdates;
|
|
18
20
|
/**
|
|
19
21
|
* The channels to subscribe to, most important first (the push client keeps the first `PUSH_MAX_CHANNELS`): every
|
|
20
|
-
* mailbox's Inbox - where mail lands - then
|
|
21
|
-
*
|
|
22
|
-
*
|
|
22
|
+
* mailbox's Inbox - where mail lands - then the mailbox uids themselves (which announce new, renamed and deleted folders, so
|
|
23
|
+
* they must outrank any folder's own mail), then each mailbox's other folders in the sidebar's own order - a folder that has
|
|
24
|
+
* just appeared is filed by its type like any other, so when the server's cap bites it is the last of them that go without live
|
|
25
|
+
* updates. A folder is a channel of its own; subscribing to a mailbox does not deliver its folders' events.
|
|
23
26
|
*/
|
|
24
27
|
export declare function pushChannelsFor(mailboxFolders: MailboxFolders[], mailboxes: Mailbox[]): string[];
|
|
25
28
|
export interface UseMailLiveUpdatesOptions {
|
|
@@ -29,10 +32,20 @@ export interface UseMailLiveUpdatesOptions {
|
|
|
29
32
|
mailboxFolders: MailboxFolders[];
|
|
30
33
|
/** Called with a folder another client (or this one) created, so the sidebar shows it. Called only for one not already known. */
|
|
31
34
|
onFolderCreated: (folder: Folder) => void;
|
|
35
|
+
/** Called when a folder's name or type was changed elsewhere (a `Folder` update event that says so - one that only carries counts is not this). */
|
|
36
|
+
onFolderChanged?: (change: FolderChange) => void;
|
|
37
|
+
/** Called with the uid of a folder that was deleted elsewhere. */
|
|
38
|
+
onFolderDeleted?: (folderUid: string) => void;
|
|
39
|
+
/** Called with a mailbox's folders as the server just listed them - on every refresh (a message event, the poll, a reconnect, the tab
|
|
40
|
+
* coming back, a send) - so a folder the tree does not know is found whichever way it appeared. */
|
|
41
|
+
onFoldersListed?: (folders: Folder[]) => void;
|
|
32
42
|
/** Called with each new message the push connection announces (a `create` event) - once per message, never for a list
|
|
33
43
|
* refetch, the initial load or a reconnect (nothing published while the socket was down is replayed). The caller decides
|
|
34
44
|
* whether it is worth telling the user about. */
|
|
35
45
|
onMessageCreated?: (message: Message) => void;
|
|
46
|
+
/** Called with the outcome of a message sent in the background (`send-succeeded`, `send-failed`, `send-retrying`). Every one also
|
|
47
|
+
* refreshes what is on screen (the Outbox and Sent Items lists and counts change with it). */
|
|
48
|
+
onSendEvent?: (event: SendEvent) => void;
|
|
36
49
|
}
|
|
37
50
|
export interface MailLiveUpdates {
|
|
38
51
|
live: LiveUpdates;
|
|
@@ -52,4 +65,4 @@ export interface MailLiveUpdates {
|
|
|
52
65
|
* Sign-out closes the socket for good: another tab's or this one's, heard on the same channel `AppShell` listens on.
|
|
53
66
|
* Renders nothing and does nothing where there is no window (server-side rendering).
|
|
54
67
|
*/
|
|
55
|
-
export declare function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated, onMessageCreated }: UseMailLiveUpdatesOptions): MailLiveUpdates;
|
|
68
|
+
export declare function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated, onFolderChanged, onFolderDeleted, onFoldersListed, onMessageCreated, onSendEvent, }: UseMailLiveUpdatesOptions): MailLiveUpdates;
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
6
6
|
import { getPushClient } from "@rapidmx/react-shared/mail/pushClient.js";
|
|
7
|
+
import { parseSendEvent } from "@rapidmx/react-shared/mail/sendEvents.js";
|
|
7
8
|
import { SIGN_OUT_CHANNEL } from "../search/localIndexRpcClient.js";
|
|
8
9
|
import { useFolderCounts } from "./folderCounts.js";
|
|
10
|
+
import { sortFolders } from "./folderTree.js";
|
|
9
11
|
/** How often the safety-net poll runs while the tab is visible. The push socket does the real work; this is for a network
|
|
10
12
|
* that blocks or silently drops it, and for events published while it was reconnecting (Redis pub/sub is never replayed). */
|
|
11
13
|
export const LIVE_POLL_INTERVAL_MS = 45000;
|
|
@@ -18,19 +20,25 @@ const FOLDER_EVENT = /^Folder/;
|
|
|
18
20
|
const MESSAGE_ACTIONS = new Set(["create", "update", "delete"]);
|
|
19
21
|
/**
|
|
20
22
|
* The channels to subscribe to, most important first (the push client keeps the first `PUSH_MAX_CHANNELS`): every
|
|
21
|
-
* mailbox's Inbox - where mail lands - then
|
|
22
|
-
*
|
|
23
|
-
*
|
|
23
|
+
* mailbox's Inbox - where mail lands - then the mailbox uids themselves (which announce new, renamed and deleted folders, so
|
|
24
|
+
* they must outrank any folder's own mail), then each mailbox's other folders in the sidebar's own order - a folder that has
|
|
25
|
+
* just appeared is filed by its type like any other, so when the server's cap bites it is the last of them that go without live
|
|
26
|
+
* updates. A folder is a channel of its own; subscribing to a mailbox does not deliver its folders' events.
|
|
24
27
|
*/
|
|
25
28
|
export function pushChannelsFor(mailboxFolders, mailboxes) {
|
|
26
29
|
const inboxes = mailboxFolders.flatMap((entry) => entry.folders.filter((folder) => folder.type === "inbox"));
|
|
27
|
-
const others = mailboxFolders.flatMap((entry) => entry.folders.filter((folder) => folder.type !== "inbox"));
|
|
28
|
-
return [...new Set([...inboxes
|
|
30
|
+
const others = mailboxFolders.flatMap((entry) => sortFolders(entry.folders.filter((folder) => folder.type !== "inbox")));
|
|
31
|
+
return [...new Set([...inboxes.map((folder) => folder.uid), ...mailboxes.map((mailbox) => mailbox.uid), ...others.map((folder) => folder.uid)])];
|
|
29
32
|
}
|
|
30
33
|
function isFolder(value) {
|
|
31
34
|
const folder = value;
|
|
32
35
|
return !!folder && typeof folder.uid === "string" && typeof folder.mailboxUid === "string" && typeof folder.type === "string";
|
|
33
36
|
}
|
|
37
|
+
/** A `Folder` update event that says the folder's name or type changed (and names the folder): the counts-only ones the server publishes after every message change do not. */
|
|
38
|
+
function isFolderChange(value) {
|
|
39
|
+
const change = value;
|
|
40
|
+
return !!change && typeof change.uid === "string" && (typeof change.name === "string" || typeof change.type === "string");
|
|
41
|
+
}
|
|
34
42
|
function isMessage(value) {
|
|
35
43
|
const message = value;
|
|
36
44
|
return (!!message &&
|
|
@@ -59,13 +67,13 @@ function folderOfMessageEvent(event) {
|
|
|
59
67
|
* Sign-out closes the socket for good: another tab's or this one's, heard on the same channel `AppShell` listens on.
|
|
60
68
|
* Renders nothing and does nothing where there is no window (server-side rendering).
|
|
61
69
|
*/
|
|
62
|
-
export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated, onMessageCreated }) {
|
|
70
|
+
export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated, onFolderChanged, onFolderDeleted, onFoldersListed, onMessageCreated, onSendEvent, }) {
|
|
63
71
|
const [live, setLive] = useState(NO_LIVE_UPDATES);
|
|
64
72
|
const folders = useMemo(() => mailboxFolders.flatMap((entry) => entry.folders), [mailboxFolders]);
|
|
65
|
-
const folderCounts = useFolderCounts(mailboxes, folders);
|
|
73
|
+
const folderCounts = useFolderCounts(mailboxes, folders, onFoldersListed);
|
|
66
74
|
// The latest inputs, for the long-lived listeners below.
|
|
67
|
-
const latestRef = useRef({ mailboxes, mailboxFolders, onFolderCreated, onMessageCreated, folderCounts });
|
|
68
|
-
latestRef.current = { mailboxes, mailboxFolders, onFolderCreated, onMessageCreated, folderCounts };
|
|
75
|
+
const latestRef = useRef({ mailboxes, mailboxFolders, onFolderCreated, onFolderChanged, onFolderDeleted, onMessageCreated, onSendEvent, folderCounts });
|
|
76
|
+
latestRef.current = { mailboxes, mailboxFolders, onFolderCreated, onFolderChanged, onFolderDeleted, onMessageCreated, onSendEvent, folderCounts };
|
|
69
77
|
const channels = useMemo(() => pushChannelsFor(mailboxFolders, mailboxes), [mailboxFolders, mailboxes]);
|
|
70
78
|
const channelKey = channels.join("|");
|
|
71
79
|
const channelsRef = useRef(channels);
|
|
@@ -103,7 +111,13 @@ export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolde
|
|
|
103
111
|
timer ?? (timer = setTimeout(flush, LIVE_EVENT_DEBOUNCE_MS));
|
|
104
112
|
}
|
|
105
113
|
const offEvent = client.onEvent((event) => {
|
|
106
|
-
|
|
114
|
+
const sendEvent = parseSendEvent(event);
|
|
115
|
+
if (sendEvent) {
|
|
116
|
+
// A queued message was relayed (or failed to be): it left Outbox for Sent Items, or stayed there failed - every list and count may have changed.
|
|
117
|
+
latestRef.current.onSendEvent?.(sendEvent);
|
|
118
|
+
schedule();
|
|
119
|
+
}
|
|
120
|
+
else if (MESSAGE_EVENT.test(event.type) && MESSAGE_ACTIONS.has(event.action ?? "")) {
|
|
107
121
|
if (event.action === "create" && isMessage(event.data)) {
|
|
108
122
|
// Once per message, whatever else the event triggers: the badge first, then whoever announces it.
|
|
109
123
|
if (latestRef.current.folderCounts.noteCreated(event.data)) {
|
|
@@ -113,15 +127,27 @@ export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolde
|
|
|
113
127
|
schedule(folderOfMessageEvent(event));
|
|
114
128
|
}
|
|
115
129
|
else if (FOLDER_EVENT.test(event.type) && event.action === "create" && isFolder(event.data)) {
|
|
130
|
+
// A folder made by the server (on first use, or by another client) or by this one: filed in the sidebar and subscribed to at once (the
|
|
131
|
+
// channel list follows the tree), and its real counts read - all without a reload.
|
|
116
132
|
const folder = event.data;
|
|
117
133
|
const known = latestRef.current.mailboxFolders.some((entry) => entry.folders.some((f) => f.uid === folder.uid));
|
|
118
134
|
if (!known) {
|
|
119
135
|
latestRef.current.onFolderCreated(folder);
|
|
136
|
+
latestRef.current.folderCounts.refresh(0);
|
|
120
137
|
}
|
|
121
138
|
}
|
|
122
139
|
else if (FOLDER_EVENT.test(event.type) && event.action === "update") {
|
|
140
|
+
if (isFolderChange(event.data)) {
|
|
141
|
+
latestRef.current.onFolderChanged?.(event.data);
|
|
142
|
+
}
|
|
123
143
|
latestRef.current.folderCounts.applyFolderEvent(event.data);
|
|
124
144
|
}
|
|
145
|
+
else if (FOLDER_EVENT.test(event.type) && event.action === "delete") {
|
|
146
|
+
const uid = event.data?.uid;
|
|
147
|
+
if (typeof uid === "string") {
|
|
148
|
+
latestRef.current.onFolderDeleted?.(uid);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
125
151
|
});
|
|
126
152
|
// Anything published while the socket was down is gone for good, so every reconnect is followed by a refresh.
|
|
127
153
|
const offStatus = client.onStatus((status) => {
|
|
@@ -1,19 +1,12 @@
|
|
|
1
1
|
import type { Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
2
2
|
import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
|
|
3
|
-
|
|
4
|
-
/** The most pop-ups on screen at once; a new one pushes the oldest off. */
|
|
5
|
-
export declare const MAX_TOASTS = 3;
|
|
6
|
-
/** How long a pop-up stays before it goes by itself (while it is neither hovered nor focused, and the tab is in view). */
|
|
3
|
+
/** How long a new-mail pop-up stays before it goes by itself (while it is neither hovered nor focused, and the tab is in view). */
|
|
7
4
|
export declare const TOAST_DURATION_MS = 8000;
|
|
8
5
|
/** At most this many desktop notifications in `DESKTOP_BURST_WINDOW_MS`: a flood of mail is a few notifications, not a hundred. */
|
|
9
6
|
export declare const DESKTOP_BURST_LIMIT = 5;
|
|
10
7
|
export declare const DESKTOP_BURST_WINDOW_MS = 30000;
|
|
11
8
|
export interface NewMailNotifications {
|
|
12
|
-
/**
|
|
13
|
-
toasts: NewMailNotice[];
|
|
14
|
-
/** Takes one off the screen. */
|
|
15
|
-
dismiss(uid: string): void;
|
|
16
|
-
/** Whether the first pop-up should also offer to turn on desktop notifications: the browser can, has not been asked,
|
|
9
|
+
/** Whether the next pop-up should also offer to turn on desktop notifications: the browser can, has not been asked,
|
|
17
10
|
* and the user has not said "Not now". */
|
|
18
11
|
offerDesktop: boolean;
|
|
19
12
|
/** The offer's "Turn on desktop notifications": asks the browser. Must be called from a click. */
|
|
@@ -30,7 +23,8 @@ export interface UseNewMailNotificationsOptions {
|
|
|
30
23
|
open?: (href: string) => void;
|
|
31
24
|
}
|
|
32
25
|
/**
|
|
33
|
-
* New-mail announcements: an in-app pop-up for each message that arrives (`
|
|
26
|
+
* New-mail announcements: an in-app pop-up for each message that arrives (a `mail` notification - see `notifications/store.ts`, which owns the
|
|
27
|
+
* stack, its limit of three and the clock), and - while the tab
|
|
34
28
|
* is in the background and the user has allowed it - a desktop notification with the same content, one per message however
|
|
35
29
|
* many events name it. `announce()` is what `useMailLiveUpdates()`'s `onMessageCreated` calls; nothing else feeds it, so a
|
|
36
30
|
* page load, a list refetch and a reconnect never announce anything.
|
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
6
6
|
import { desktopPermission, getDesktopOfferDismissed, getNewMailPopupsEnabled, noticeFor, noticeSender, ownAddressesOf, requestDesktopPermission, setDesktopOfferDismissed, shouldAnnounce, } from "./newMailNotifications.js";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
/** How long a pop-up stays before it goes by itself (while it is neither hovered nor focused, and the tab is in view). */
|
|
7
|
+
import { getNotificationsSnapshot, notify } from "../notifications/store.js";
|
|
8
|
+
/** How long a new-mail pop-up stays before it goes by itself (while it is neither hovered nor focused, and the tab is in view). */
|
|
10
9
|
export const TOAST_DURATION_MS = 8000;
|
|
11
10
|
/** At most this many desktop notifications in `DESKTOP_BURST_WINDOW_MS`: a flood of mail is a few notifications, not a hundred. */
|
|
12
11
|
export const DESKTOP_BURST_LIMIT = 5;
|
|
@@ -21,7 +20,8 @@ function defaultOpen(href) {
|
|
|
21
20
|
window.location.href = href;
|
|
22
21
|
}
|
|
23
22
|
/**
|
|
24
|
-
* New-mail announcements: an in-app pop-up for each message that arrives (`
|
|
23
|
+
* New-mail announcements: an in-app pop-up for each message that arrives (a `mail` notification - see `notifications/store.ts`, which owns the
|
|
24
|
+
* stack, its limit of three and the clock), and - while the tab
|
|
25
25
|
* is in the background and the user has allowed it - a desktop notification with the same content, one per message however
|
|
26
26
|
* many events name it. `announce()` is what `useMailLiveUpdates()`'s `onMessageCreated` calls; nothing else feeds it, so a
|
|
27
27
|
* page load, a list refetch and a reconnect never announce anything.
|
|
@@ -30,7 +30,6 @@ function defaultOpen(href) {
|
|
|
30
30
|
* first pop-up, or from Settings. What the user answered is remembered in `localStorage` (see `newMailNotifications.ts`).
|
|
31
31
|
*/
|
|
32
32
|
export function useNewMailNotifications({ mailboxes, mailboxFolders, open = defaultOpen }) {
|
|
33
|
-
const [toasts, setToasts] = useState([]);
|
|
34
33
|
const [permission, setPermission] = useState("unsupported");
|
|
35
34
|
const [offerDismissed, setOfferDismissed] = useState(true);
|
|
36
35
|
const folders = useMemo(() => mailboxFolders.flatMap((entry) => entry.folders), [mailboxFolders]);
|
|
@@ -44,9 +43,6 @@ export function useNewMailNotifications({ mailboxes, mailboxFolders, open = defa
|
|
|
44
43
|
setPermission(desktopPermission());
|
|
45
44
|
setOfferDismissed(getDesktopOfferDismissed());
|
|
46
45
|
}, []);
|
|
47
|
-
const dismiss = useCallback((uid) => {
|
|
48
|
-
setToasts((previous) => previous.filter((toast) => toast.uid !== uid));
|
|
49
|
-
}, []);
|
|
50
46
|
const showDesktop = useCallback((notice) => {
|
|
51
47
|
const now = Date.now();
|
|
52
48
|
desktopShownRef.current = desktopShownRef.current.filter((at) => now - at < DESKTOP_BURST_WINDOW_MS);
|
|
@@ -70,6 +66,21 @@ export function useNewMailNotifications({ mailboxes, mailboxFolders, open = defa
|
|
|
70
66
|
// Some browsers refuse `new Notification()` outright (a mobile one wanting a service worker): the pop-up is enough.
|
|
71
67
|
}
|
|
72
68
|
}, []);
|
|
69
|
+
const enableDesktop = useCallback(async () => {
|
|
70
|
+
const answer = await requestDesktopPermission();
|
|
71
|
+
setPermission(answer);
|
|
72
|
+
// Answered either way, so the offer goes: on granted it has done its job, on denied it can't be made again.
|
|
73
|
+
setDesktopOfferDismissed(true);
|
|
74
|
+
setOfferDismissed(true);
|
|
75
|
+
}, []);
|
|
76
|
+
const declineDesktop = useCallback(() => {
|
|
77
|
+
setDesktopOfferDismissed(true);
|
|
78
|
+
setOfferDismissed(true);
|
|
79
|
+
}, []);
|
|
80
|
+
// The offer's current state and answers, for the stable `announce` below.
|
|
81
|
+
const offerDesktop = permission === "default" && !offerDismissed;
|
|
82
|
+
const offerRef = useRef({ offered: offerDesktop, enable: enableDesktop, decline: declineDesktop });
|
|
83
|
+
offerRef.current = { offered: offerDesktop, enable: enableDesktop, decline: declineDesktop };
|
|
73
84
|
const announce = useCallback((message) => {
|
|
74
85
|
if (!getNewMailPopupsEnabled() || announcedRef.current.has(message.uid)) {
|
|
75
86
|
return;
|
|
@@ -83,26 +94,35 @@ export function useNewMailNotifications({ mailboxes, mailboxFolders, open = defa
|
|
|
83
94
|
announcedRef.current.delete(announcedRef.current.values().next().value);
|
|
84
95
|
}
|
|
85
96
|
const notice = noticeFor(message);
|
|
86
|
-
|
|
97
|
+
const offer = offerRef.current;
|
|
98
|
+
notify({
|
|
99
|
+
id: `mail:${notice.uid}`,
|
|
100
|
+
kind: "mail",
|
|
101
|
+
title: notice.senderName || notice.senderAddress,
|
|
102
|
+
subtitle: notice.senderName ? `<${notice.senderAddress}>` : undefined,
|
|
103
|
+
message: notice.subject,
|
|
104
|
+
preview: notice.preview || undefined,
|
|
105
|
+
href: notice.href,
|
|
106
|
+
timeoutMs: TOAST_DURATION_MS,
|
|
107
|
+
// Actions make a notification sticky; a new-mail pop-up with the offer still goes by itself.
|
|
108
|
+
sticky: false,
|
|
109
|
+
// The offer is made once at a time: on a pop-up only while none of the pop-ups still showing carries it.
|
|
110
|
+
...(offer.offered && !getNotificationsSnapshot().visible.some((item) => item.kind === "mail" && item.actions.length > 0)
|
|
111
|
+
? {
|
|
112
|
+
hint: "Get a desktop notification when mail arrives while this tab is in the background.",
|
|
113
|
+
actions: [
|
|
114
|
+
{ label: "Turn on desktop notifications", onClick: () => void offer.enable() },
|
|
115
|
+
{ label: "Not now", onClick: offer.decline },
|
|
116
|
+
],
|
|
117
|
+
}
|
|
118
|
+
: {}),
|
|
119
|
+
});
|
|
87
120
|
if (desktopPermission() === "granted" && tabIsInBackground()) {
|
|
88
121
|
showDesktop(notice);
|
|
89
122
|
}
|
|
90
123
|
}, [showDesktop]);
|
|
91
|
-
const enableDesktop = useCallback(async () => {
|
|
92
|
-
const answer = await requestDesktopPermission();
|
|
93
|
-
setPermission(answer);
|
|
94
|
-
// Answered either way, so the offer goes: on granted it has done its job, on denied it can't be made again.
|
|
95
|
-
setDesktopOfferDismissed(true);
|
|
96
|
-
setOfferDismissed(true);
|
|
97
|
-
}, []);
|
|
98
|
-
const declineDesktop = useCallback(() => {
|
|
99
|
-
setDesktopOfferDismissed(true);
|
|
100
|
-
setOfferDismissed(true);
|
|
101
|
-
}, []);
|
|
102
124
|
return {
|
|
103
|
-
|
|
104
|
-
dismiss,
|
|
105
|
-
offerDesktop: permission === "default" && !offerDismissed,
|
|
125
|
+
offerDesktop,
|
|
106
126
|
enableDesktop,
|
|
107
127
|
declineDesktop,
|
|
108
128
|
announce,
|
|
@@ -46,8 +46,8 @@ import { matchRoute } from "./routes.js";
|
|
|
46
46
|
import { RouterContext, UNKNOWN_LOCATION, readWindowLocation } from "./routerContext.js";
|
|
47
47
|
export { useLocation, useLocationSearch, useNavigate } from "./routerContext.js";
|
|
48
48
|
function pickChromeProps(props) {
|
|
49
|
-
const { userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav } = props;
|
|
50
|
-
return { userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav };
|
|
49
|
+
const { userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, branding, appearance } = props;
|
|
50
|
+
return { userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, branding, appearance };
|
|
51
51
|
}
|
|
52
52
|
/** The anchor a click/pointer event is on or in, if it is a link. */
|
|
53
53
|
function linkOf(event) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The pop-up stack, and the one place the app's notifications (see `store.ts`) are drawn. Mounted once, in the persistent app frame, so
|
|
4
|
+
* it survives page changes and covers every app.
|
|
5
|
+
*
|
|
6
|
+
* **Where it sits.** Top right, *below* the header row: the stack is anchored to a zero-height `sticky` line placed right after the
|
|
7
|
+
* frame's header, so it starts under the header (and so under the account menu button, whatever height a branded header has) and, as
|
|
8
|
+
* the page scrolls, sticks just below the header (`--rr-header-h`, published by the title bar / `FrameBrandingHeader` - see `headerOffset.ts`;
|
|
9
|
+
* never less than 2.5 rem, which clears a full-screen compose sheet's own title bar on a phone).
|
|
10
|
+
* On a desktop the compose windows are anchored at the bottom right, the far end of the screen from this; it never covers the account menu,
|
|
11
|
+
* the header's actions or a compose window's title bar and Send button unless the window is stretched over most of the screen height.
|
|
12
|
+
* It sits above the compose sheet (z-index 60 against 50), so a pop-up is never hidden behind it. Its height is capped and scrolls.
|
|
13
|
+
*
|
|
14
|
+
* **Live regions.** Two always-mounted containers with `aria-live` (a live region has to exist before its content changes to be announced):
|
|
15
|
+
* errors in an assertive one, everything else in a polite one, and each pop-up carries the matching role - `alert` for an error, `status`
|
|
16
|
+
* for the rest - so it is announced accordingly, and the empty containers add no `alert`/`status` of their own to the page.
|
|
17
|
+
* `prefers-reduced-motion` switches the entrance animation off (`.rr-toast-in`).
|
|
18
|
+
*/
|
|
19
|
+
export default function NotificationCenter(): React.JSX.Element;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect } from "react";
|
|
7
|
+
import { HiOutlineCheckCircle, HiOutlineEnvelope, HiOutlineExclamationCircle, HiOutlineExclamationTriangle, HiOutlineInformationCircle, HiOutlineXMark, } from "react-icons/hi2";
|
|
8
|
+
import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
|
|
9
|
+
import { dismiss, setAllPaused, setPaused } from "./store.js";
|
|
10
|
+
import { useNotifications } from "./useNotifications.js";
|
|
11
|
+
import { useSystemErrorNotifications } from "./systemErrors.js";
|
|
12
|
+
const KIND_STYLE = {
|
|
13
|
+
mail: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineEnvelope, label: "New message" },
|
|
14
|
+
info: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineInformationCircle, label: "Information" },
|
|
15
|
+
success: { border: "border-l-success", icon: "text-success", Icon: HiOutlineCheckCircle, label: "Success" },
|
|
16
|
+
warning: { border: "border-l-warning", icon: "text-warning", Icon: HiOutlineExclamationTriangle, label: "Warning" },
|
|
17
|
+
error: { border: "border-l-danger", icon: "text-danger", Icon: HiOutlineExclamationCircle, label: "Error" },
|
|
18
|
+
};
|
|
19
|
+
const ACTION_CLASS = "rounded-sm px-2.5 py-1 text-xs font-semibold text-primary-dark hover:bg-surface-alt focus-visible:outline-2 focus-visible:outline-primary";
|
|
20
|
+
/** Stops every clock while the tab is out of view: nobody could have read what is on screen, and it is still there when they come back. */
|
|
21
|
+
function usePauseWhileHidden() {
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const update = () => setAllPaused(document.visibilityState === "hidden");
|
|
24
|
+
update();
|
|
25
|
+
document.addEventListener("visibilitychange", update);
|
|
26
|
+
return () => {
|
|
27
|
+
document.removeEventListener("visibilitychange", update);
|
|
28
|
+
setAllPaused(false);
|
|
29
|
+
};
|
|
30
|
+
}, []);
|
|
31
|
+
}
|
|
32
|
+
function ActionButton({ action, id }) {
|
|
33
|
+
function run() {
|
|
34
|
+
action.onClick?.();
|
|
35
|
+
if (!action.keepOpen) {
|
|
36
|
+
dismiss(id);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return action.href ? (_jsx("a", { href: action.href, onClick: run, className: ACTION_CLASS, children: action.label })) : (_jsx("button", { type: "button", onClick: run, className: ACTION_CLASS, children: action.label }));
|
|
40
|
+
}
|
|
41
|
+
/** The expandable, copyable technical lines behind a notification. */
|
|
42
|
+
function Details({ lines }) {
|
|
43
|
+
return (_jsxs("details", { className: "mt-1.5", children: [_jsx("summary", { className: "cursor-pointer text-xs font-semibold text-text-muted", children: "Technical details" }), _jsx("ul", { "aria-label": "Technical details", className: "mt-1.5 max-h-40 overflow-auto rounded-sm border border-border bg-surface-alt p-2 font-mono text-xs text-text select-text", children: lines.map((line, index) => (_jsx("li", { className: "whitespace-pre-wrap break-words", children: line }, index))) }), _jsx("div", { className: "mt-1.5", children: _jsx(CopyButton, { value: lines.join("\n"), label: "Copy technical details" }) })] }));
|
|
44
|
+
}
|
|
45
|
+
/** One pop-up. Its clock (kept by the store) stops while it is hovered or holds the keyboard focus; Escape dismisses it. */
|
|
46
|
+
function NotificationItem({ notification }) {
|
|
47
|
+
const { id, kind, title, subtitle, message, preview, hint, details, actions, href, count } = notification;
|
|
48
|
+
const style = KIND_STYLE[kind];
|
|
49
|
+
const body = (_jsxs(_Fragment, { children: [kind === "mail" && _jsx("span", { className: "block text-xs font-semibold uppercase tracking-wide text-primary-dark", children: style.label }), _jsxs("span", { className: "flex min-w-0 items-baseline gap-1.5 text-sm", children: [_jsx("span", { className: ["truncate font-bold", kind === "error" ? "text-danger" : ""].join(" "), children: title }), count > 1 && (_jsxs("span", { className: "shrink-0 rounded-pill bg-surface-alt px-1.5 text-xs font-semibold text-text-muted", children: [_jsxs("span", { "aria-hidden": "true", children: ["\u00D7", count] }), _jsxs("span", { className: "sr-only", children: [" (", count, " times)"] })] })), subtitle && _jsx("span", { className: "truncate text-xs text-text-muted", children: subtitle })] }), message && _jsx("span", { className: ["mt-0.5 block text-sm [overflow-wrap:anywhere]", kind === "mail" ? "truncate font-semibold" : ""].join(" "), children: message }), preview && _jsx("span", { className: "mt-0.5 block text-xs text-text-muted [overflow-wrap:anywhere]", children: preview })] }));
|
|
50
|
+
return (_jsxs("div", { "data-testid": "notification", role: kind === "error" ? "alert" : "status", "data-kind": kind, "data-notification-id": id, onMouseEnter: () => setPaused(id, true), onMouseLeave: () => setPaused(id, false), onFocus: () => setPaused(id, true), onBlur: (event) => {
|
|
51
|
+
// Focus moving between this pop-up's own controls is not leaving it.
|
|
52
|
+
if (!event.currentTarget.contains(event.relatedTarget)) {
|
|
53
|
+
setPaused(id, false);
|
|
54
|
+
}
|
|
55
|
+
}, onKeyDown: (event) => {
|
|
56
|
+
if (event.key === "Escape") {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
dismiss(id);
|
|
59
|
+
}
|
|
60
|
+
}, className: `rr-toast-in pointer-events-auto relative rounded-md border border-border border-l-4 ${style.border} bg-surface text-text shadow-lg`, children: [_jsxs("div", { className: "flex items-start gap-2 p-3 pr-10", children: [kind !== "mail" && _jsx(style.Icon, { size: 18, "aria-hidden": "true", className: `mt-0.5 shrink-0 ${style.icon}` }), _jsxs("div", { className: "min-w-0 flex-1", children: [href ? (_jsx("a", { href: href, onClick: () => dismiss(id), className: "-m-1 block rounded-md p-1 hover:bg-surface-alt focus-visible:outline-2 focus-visible:outline-primary", children: body })) : (body), hint && _jsx("p", { className: "mt-1.5 text-xs text-text-muted", children: hint }), details.length > 0 && _jsx(Details, { lines: details }), actions.length > 0 && (_jsx("div", { className: "-ml-2.5 mt-1.5 flex flex-wrap items-center gap-1", children: actions.map((action, index) => (_jsx(ActionButton, { action: action, id: id }, index))) }))] })] }), _jsx("button", { type: "button", onClick: () => dismiss(id), "aria-label": `Dismiss notification: ${title}`, className: "absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text focus-visible:outline-2 focus-visible:outline-primary", children: _jsx(HiOutlineXMark, { size: 16, "aria-hidden": "true" }) })] }));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The pop-up stack, and the one place the app's notifications (see `store.ts`) are drawn. Mounted once, in the persistent app frame, so
|
|
64
|
+
* it survives page changes and covers every app.
|
|
65
|
+
*
|
|
66
|
+
* **Where it sits.** Top right, *below* the header row: the stack is anchored to a zero-height `sticky` line placed right after the
|
|
67
|
+
* frame's header, so it starts under the header (and so under the account menu button, whatever height a branded header has) and, as
|
|
68
|
+
* the page scrolls, sticks just below the header (`--rr-header-h`, published by the title bar / `FrameBrandingHeader` - see `headerOffset.ts`;
|
|
69
|
+
* never less than 2.5 rem, which clears a full-screen compose sheet's own title bar on a phone).
|
|
70
|
+
* On a desktop the compose windows are anchored at the bottom right, the far end of the screen from this; it never covers the account menu,
|
|
71
|
+
* the header's actions or a compose window's title bar and Send button unless the window is stretched over most of the screen height.
|
|
72
|
+
* It sits above the compose sheet (z-index 60 against 50), so a pop-up is never hidden behind it. Its height is capped and scrolls.
|
|
73
|
+
*
|
|
74
|
+
* **Live regions.** Two always-mounted containers with `aria-live` (a live region has to exist before its content changes to be announced):
|
|
75
|
+
* errors in an assertive one, everything else in a polite one, and each pop-up carries the matching role - `alert` for an error, `status`
|
|
76
|
+
* for the rest - so it is announced accordingly, and the empty containers add no `alert`/`status` of their own to the page.
|
|
77
|
+
* `prefers-reduced-motion` switches the entrance animation off (`.rr-toast-in`).
|
|
78
|
+
*/
|
|
79
|
+
export default function NotificationCenter() {
|
|
80
|
+
const { visible, queued } = useNotifications();
|
|
81
|
+
usePauseWhileHidden();
|
|
82
|
+
useSystemErrorNotifications();
|
|
83
|
+
const errors = visible.filter((notification) => notification.kind === "error");
|
|
84
|
+
const others = visible.filter((notification) => notification.kind !== "error");
|
|
85
|
+
return (_jsx("div", { "data-testid": "notification-anchor", className: "sticky top-[max(var(--rr-header-h,0px),2.5rem)] z-[60] h-0 w-full", children: _jsxs("div", { "data-testid": "notification-stack",
|
|
86
|
+
// Never taller than the room above the open compose windows (`ComposeProvider` publishes their top edge as `--rr-compose-top`), so
|
|
87
|
+
// a stack of pop-ups scrolls inside its own box rather than covering a compose window's title bar and buttons.
|
|
88
|
+
// (The padding is room for the pop-ups' shadow, which the scroll box would otherwise cut off in a visible edge.)
|
|
89
|
+
className: "pointer-events-none absolute right-0 -top-1 flex max-h-[max(8rem,calc(var(--rr-compose-top,100vh)-max(var(--rr-header-h,0px),2.5rem)-2rem))] w-[25rem] max-w-[calc(100vw-0.5rem)] flex-col overflow-y-auto p-4", children: [_jsx("div", { "data-testid": "notification-errors", "aria-live": "assertive", className: "flex flex-col gap-2 [&:not(:empty)]:mb-2", children: errors.map((notification) => (_jsx(NotificationItem, { notification: notification }, notification.id))) }), _jsxs("div", { "data-testid": "notification-others", "aria-live": "polite", className: "flex flex-col gap-2", children: [others.map((notification) => (_jsx(NotificationItem, { notification: notification }, notification.id))), queued > 0 && (_jsxs("p", { className: "rr-solid pointer-events-auto self-end rounded-pill bg-surface px-2.5 py-0.5 text-xs text-text-muted shadow-md", children: [queued, " more waiting"] }))] })] }) }));
|
|
90
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface NotificationHistoryDialogProps {
|
|
3
|
+
open: boolean;
|
|
4
|
+
onClose: () => void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* "Recent notifications": the last few pop-ups, newest first, so nothing is lost when one expires. Opening it counts as having seen
|
|
8
|
+
* them (the unseen-error count on the account menu goes), but the errors that were unseen when it opened stay highlighted until it closes.
|
|
9
|
+
*/
|
|
10
|
+
export default function NotificationHistoryDialog({ open, onClose }: NotificationHistoryDialogProps): React.JSX.Element;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useRef } from "react";
|
|
7
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
8
|
+
import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
|
|
9
|
+
import { useNotifications } from "./useNotifications.js";
|
|
10
|
+
const KIND_LABEL = {
|
|
11
|
+
mail: "New message",
|
|
12
|
+
info: "Information",
|
|
13
|
+
success: "Done",
|
|
14
|
+
warning: "Warning",
|
|
15
|
+
error: "Error",
|
|
16
|
+
};
|
|
17
|
+
function HistoryItem({ entry, unseen }) {
|
|
18
|
+
return (_jsxs("li", { className: ["border-b border-border py-2.5 last:border-b-0", unseen ? "-mx-2 rounded-sm bg-danger-bg/60 px-2" : ""].join(" "), children: [_jsxs("div", { className: "flex items-baseline justify-between gap-3 text-xs text-text-muted", children: [_jsx("span", { className: ["font-semibold uppercase tracking-wide", entry.kind === "error" ? "text-danger" : ""].join(" "), children: KIND_LABEL[entry.kind] }), _jsx("time", { dateTime: new Date(entry.at).toISOString(), children: new Date(entry.at).toLocaleTimeString() })] }), _jsxs("p", { className: "mt-0.5 text-sm font-semibold [overflow-wrap:anywhere]", children: [entry.title, entry.count > 1 && _jsxs("span", { className: "ml-1.5 text-xs font-medium text-text-muted", children: ["\u00D7", entry.count] })] }), entry.message && _jsx("p", { className: "text-sm text-text-muted [overflow-wrap:anywhere]", children: entry.message }), entry.details.length > 0 && (_jsxs("details", { className: "mt-1", children: [_jsx("summary", { className: "cursor-pointer text-xs font-semibold text-text-muted", children: "Technical details" }), _jsx("ul", { "aria-label": "Technical details", className: "mt-1.5 max-h-40 overflow-auto rounded-sm border border-border bg-surface-alt p-2 font-mono text-xs select-text", children: entry.details.map((line, index) => (_jsx("li", { className: "whitespace-pre-wrap break-words", children: line }, index))) }), _jsx("div", { className: "mt-1.5", children: _jsx(CopyButton, { value: entry.details.join("\n"), label: "Copy technical details" }) })] }))] }));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* "Recent notifications": the last few pop-ups, newest first, so nothing is lost when one expires. Opening it counts as having seen
|
|
22
|
+
* them (the unseen-error count on the account menu goes), but the errors that were unseen when it opened stay highlighted until it closes.
|
|
23
|
+
*/
|
|
24
|
+
export default function NotificationHistoryDialog({ open, onClose }) {
|
|
25
|
+
const { history, markHistorySeen, clearHistory } = useNotifications();
|
|
26
|
+
const unseenAtOpenRef = useRef(new Set());
|
|
27
|
+
if (!open) {
|
|
28
|
+
unseenAtOpenRef.current = new Set(history.filter((entry) => entry.unseen && entry.kind === "error").map((entry) => entry.id));
|
|
29
|
+
}
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (open) {
|
|
32
|
+
markHistorySeen();
|
|
33
|
+
}
|
|
34
|
+
}, [open, markHistorySeen]);
|
|
35
|
+
return (_jsx(Modal, { open: open, onClose: onClose, title: "Recent notifications", children: history.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "Nothing yet. Errors, sent messages and other pop-ups from this session are listed here." })) : (_jsxs(_Fragment, { children: [_jsx("ul", { "aria-label": "Recent notifications", children: history.map((entry) => (_jsx(HistoryItem, { entry: entry, unseen: unseenAtOpenRef.current.has(entry.id) }, entry.id))) }), _jsx("div", { className: "mt-4", children: _jsx("button", { type: "button", onClick: clearHistory, className: "rounded-sm px-2.5 py-1 text-xs font-semibold text-primary-dark hover:bg-surface-alt", children: "Clear list" }) })] })) }));
|
|
36
|
+
}
|