@rapidmx/web-client 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +192 -88
- package/apps/shared/auth/accountUrl.ts +9 -0
- package/apps/shared/auth/adminAccess.ts +99 -0
- package/apps/shared/components/admin/elevation.ts +61 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
- package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +87 -24
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
- package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
- package/apps/shared/components/layout/AppShell.tsx +411 -288
- package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -126
- package/apps/shared/components/layout/UserMenu.tsx +303 -150
- package/apps/shared/components/mail/ConversationList.tsx +269 -253
- package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
- package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
- package/apps/shared/components/mail/MailAddress.tsx +88 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
- package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1383
- package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1700
- package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
- package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
- package/apps/shared/components/mail/compose/composePerf.ts +46 -0
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
- package/apps/shared/components/mail/layout/MailShell.tsx +476 -445
- package/apps/shared/components/mail/unreadStyle.tsx +75 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
- package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
- package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
- package/apps/shared/keyboard/dispatch.ts +124 -0
- package/apps/shared/keyboard/format.ts +89 -0
- package/apps/shared/keyboard/keymap.ts +114 -0
- package/apps/shared/keyboard/match.ts +44 -0
- package/apps/shared/keyboard/parse.ts +136 -0
- package/apps/shared/keyboard/platform.ts +34 -0
- package/apps/shared/keyboard/registry.ts +65 -0
- package/apps/shared/keyboard/targets.ts +79 -0
- package/apps/shared/keyboard/useShortcut.ts +50 -0
- package/apps/shared/keyboard/useShortcutProps.ts +17 -0
- package/apps/shared/mail/folderCounts.ts +302 -0
- package/apps/shared/mail/listSnapshots.ts +87 -0
- package/apps/shared/mail/mergeFirstPage.ts +47 -0
- package/apps/shared/mail/messageReadState.ts +85 -0
- package/apps/shared/mail/newMailNotifications.ts +183 -0
- package/apps/shared/mail/useMailConnection.ts +150 -0
- package/apps/shared/mail/useMailLiveUpdates.ts +232 -0
- package/apps/shared/mail/useMarkMessageRead.ts +47 -0
- package/apps/shared/mail/useNewMailNotifications.ts +163 -0
- package/apps/shared/mail/useUnreadTitle.ts +42 -0
- package/apps/shared/navigation/AppRouter.tsx +300 -0
- package/apps/shared/navigation/appHrefs.ts +23 -0
- package/apps/shared/navigation/frameContext.tsx +35 -0
- package/apps/shared/navigation/idle.ts +45 -0
- package/apps/shared/navigation/routerContext.tsx +83 -0
- package/apps/shared/navigation/routes.ts +72 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
- package/apps/shared/styles/app.css +28 -10
- package/apps/www/_routedPage.tsx +24 -0
- package/apps/www/_routes.ts +35 -0
- package/apps/www/calendar/index.tsx +55 -19
- package/apps/www/contacts/[uid].tsx +112 -107
- package/apps/www/contacts/index.tsx +584 -567
- package/apps/www/index.tsx +2280 -1854
- package/apps/www/messages/[uid].tsx +106 -101
- package/apps/www/settings/auto-reply/index.tsx +4 -1
- package/apps/www/settings/encryption/index.tsx +1252 -1249
- package/apps/www/settings/filters/[uid].tsx +4 -1
- package/apps/www/settings/filters/index.tsx +102 -99
- package/apps/www/settings/filters/new/index.tsx +138 -133
- package/apps/www/settings/labels/index.tsx +204 -201
- package/apps/www/settings/privacy/index.tsx +4 -1
- package/apps/www/settings/read-receipts/index.tsx +4 -1
- package/apps/www/settings/sharing/index.tsx +277 -274
- package/apps/www/settings/signatures/[uid].tsx +170 -167
- package/apps/www/settings/signatures/index.tsx +88 -85
- package/apps/www/settings/signatures/new/index.tsx +134 -129
- package/apps/www/tasks/index.tsx +18 -2
- package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
- package/dist/apps/shared/auth/accountUrl.js +8 -0
- package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
- package/dist/apps/shared/auth/adminAccess.js +89 -0
- package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
- package/dist/apps/shared/components/admin/elevation.js +56 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
- package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
- package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
- package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
- package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
- package/dist/apps/shared/components/layout/AppShell.js +73 -14
- package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
- package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
- package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +45 -8
- package/dist/apps/shared/components/layout/UserMenu.d.ts +28 -9
- package/dist/apps/shared/components/layout/UserMenu.js +87 -14
- package/dist/apps/shared/components/mail/ConversationList.js +9 -13
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +53 -28
- package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
- package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
- package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
- package/dist/apps/shared/components/mail/MailAddress.js +39 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +87 -36
- package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
- package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +122 -27
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
- package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
- package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +19 -4
- package/dist/apps/shared/components/mail/layout/MailShell.js +85 -85
- package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
- package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
- package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
- package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
- package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
- package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
- package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
- package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
- package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
- package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
- package/dist/apps/shared/keyboard/dispatch.js +109 -0
- package/dist/apps/shared/keyboard/format.d.ts +12 -0
- package/dist/apps/shared/keyboard/format.js +74 -0
- package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
- package/dist/apps/shared/keyboard/keymap.js +84 -0
- package/dist/apps/shared/keyboard/match.d.ts +20 -0
- package/dist/apps/shared/keyboard/match.js +31 -0
- package/dist/apps/shared/keyboard/parse.d.ts +31 -0
- package/dist/apps/shared/keyboard/parse.js +107 -0
- package/dist/apps/shared/keyboard/platform.d.ts +15 -0
- package/dist/apps/shared/keyboard/platform.js +22 -0
- package/dist/apps/shared/keyboard/registry.d.ts +39 -0
- package/dist/apps/shared/keyboard/registry.js +33 -0
- package/dist/apps/shared/keyboard/targets.d.ts +14 -0
- package/dist/apps/shared/keyboard/targets.js +67 -0
- package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
- package/dist/apps/shared/keyboard/useShortcut.js +35 -0
- package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
- package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
- package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
- package/dist/apps/shared/mail/folderCounts.js +212 -0
- package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
- package/dist/apps/shared/mail/listSnapshots.js +43 -0
- package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
- package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
- package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
- package/dist/apps/shared/mail/messageReadState.js +63 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
- package/dist/apps/shared/mail/newMailNotifications.js +138 -0
- package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
- package/dist/apps/shared/mail/useMailConnection.js +96 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +55 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.js +182 -0
- package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
- package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
- package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
- package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
- package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
- package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
- package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
- package/dist/apps/shared/navigation/AppRouter.js +242 -0
- package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
- package/dist/apps/shared/navigation/appHrefs.js +20 -0
- package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
- package/dist/apps/shared/navigation/frameContext.js +24 -0
- package/dist/apps/shared/navigation/idle.d.ts +14 -0
- package/dist/apps/shared/navigation/idle.js +43 -0
- package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
- package/dist/apps/shared/navigation/routerContext.js +56 -0
- package/dist/apps/shared/navigation/routes.d.ts +32 -0
- package/dist/apps/shared/navigation/routes.js +37 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
- package/dist/apps/shared/styles/app.css +28 -10
- package/dist/apps/www/_routedPage.d.ts +12 -0
- package/dist/apps/www/_routedPage.js +19 -0
- package/dist/apps/www/_routes.d.ts +11 -0
- package/dist/apps/www/_routes.js +29 -0
- package/dist/apps/www/calendar/index.d.ts +2 -2
- package/dist/apps/www/calendar/index.js +39 -8
- package/dist/apps/www/contacts/[uid].d.ts +3 -9
- package/dist/apps/www/contacts/[uid].js +6 -2
- package/dist/apps/www/contacts/index.d.ts +2 -2
- package/dist/apps/www/contacts/index.js +17 -4
- package/dist/apps/www/index.d.ts +2 -2
- package/dist/apps/www/index.js +361 -34
- package/dist/apps/www/messages/[uid].d.ts +3 -7
- package/dist/apps/www/messages/[uid].js +7 -4
- package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
- package/dist/apps/www/settings/auto-reply/index.js +3 -1
- package/dist/apps/www/settings/encryption/index.d.ts +2 -2
- package/dist/apps/www/settings/encryption/index.js +3 -1
- package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
- package/dist/apps/www/settings/filters/[uid].js +3 -1
- package/dist/apps/www/settings/filters/index.d.ts +2 -2
- package/dist/apps/www/settings/filters/index.js +3 -1
- package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
- package/dist/apps/www/settings/filters/new/index.js +6 -2
- package/dist/apps/www/settings/labels/index.d.ts +2 -2
- package/dist/apps/www/settings/labels/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.d.ts +2 -2
- package/dist/apps/www/settings/privacy/index.js +3 -1
- package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
- package/dist/apps/www/settings/read-receipts/index.js +3 -1
- package/dist/apps/www/settings/sharing/index.d.ts +2 -2
- package/dist/apps/www/settings/sharing/index.js +3 -1
- package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
- package/dist/apps/www/settings/signatures/[uid].js +3 -1
- package/dist/apps/www/settings/signatures/index.d.ts +2 -2
- package/dist/apps/www/settings/signatures/index.js +3 -1
- package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
- package/dist/apps/www/settings/signatures/new/index.js +6 -2
- package/dist/apps/www/tasks/index.d.ts +2 -2
- package/dist/apps/www/tasks/index.js +15 -3
- package/package.json +2 -2
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type MessageDetailPaneType from "./MessageDetailPane.js";
|
|
3
|
+
import type ConversationThreadPaneType from "./ConversationThreadPane.js";
|
|
4
|
+
/** Starts downloading the reading pane's code, for the page to call when it has nothing better to do. Never rejects. */
|
|
5
|
+
export declare function prefetchReadingPane(): void;
|
|
6
|
+
/** With nothing selected the pane is only its empty-state line, which needs none of the pane's code - so the chunk isn't
|
|
7
|
+
* requested until something is (or the page prefetches it). */
|
|
8
|
+
export declare function LazyMessageDetailPane(props: React.ComponentProps<typeof MessageDetailPaneType>): React.JSX.Element;
|
|
9
|
+
export declare function LazyConversationThreadPane(props: React.ComponentProps<typeof ConversationThreadPaneType>): React.JSX.Element;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
8
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
9
|
+
import Skeleton from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
10
|
+
/**
|
|
11
|
+
* The reading pane (`MessageDetailPane`, and the thread pane that stacks it) is only rendered once a message is selected, and
|
|
12
|
+
* it is the heaviest piece of the inbox after the compose window - message security, the body sanitizer, the labels and move
|
|
13
|
+
* menus - so it is a chunk of its own, downloaded when the browser is idle after the list has loaded (`prefetchReadingPane()`)
|
|
14
|
+
* and, at the latest, on the first selection. Nothing about the pane changes: these wrap the same components, with a skeleton
|
|
15
|
+
* while the chunk loads and a reload prompt if it can't.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately not `React.lazy()` + `<Suspense>`: a boundary that has shown its fallback holds the content back for up to 300 ms
|
|
18
|
+
* after the code arrives (React's fallback throttle), which would put a fifth of a second between a click and a pane whose
|
|
19
|
+
* code was already fetched. This reads the loaded component synchronously when there is one, so an already-loaded pane renders
|
|
20
|
+
* on the click's own frame, and it can retry a failed download.
|
|
21
|
+
*/
|
|
22
|
+
function lazyComponent(load) {
|
|
23
|
+
let loaded;
|
|
24
|
+
let loading;
|
|
25
|
+
/** Starts (once) loading the component's code. Rejects if it can't be downloaded - and then a later call tries again. */
|
|
26
|
+
function start() {
|
|
27
|
+
loading ?? (loading = load().then((module) => (loaded = module.default), (err) => {
|
|
28
|
+
loading = undefined;
|
|
29
|
+
throw err;
|
|
30
|
+
}));
|
|
31
|
+
return loading;
|
|
32
|
+
}
|
|
33
|
+
function Lazy(props) {
|
|
34
|
+
const [, setLoadedNow] = useState(0);
|
|
35
|
+
const [failed, setFailed] = useState(false);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (loaded) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
let cancelled = false;
|
|
41
|
+
start().then(() => !cancelled && setLoadedNow((n) => n + 1), () => !cancelled && setFailed(true));
|
|
42
|
+
return () => {
|
|
43
|
+
cancelled = true;
|
|
44
|
+
};
|
|
45
|
+
}, []);
|
|
46
|
+
if (loaded) {
|
|
47
|
+
const Component = loaded;
|
|
48
|
+
return _jsx(Component, { ...props });
|
|
49
|
+
}
|
|
50
|
+
return failed ? _jsx(ChunkFailed, {}) : _jsx(PaneSkeleton, {});
|
|
51
|
+
}
|
|
52
|
+
return { Lazy, start };
|
|
53
|
+
}
|
|
54
|
+
function PaneSkeleton() {
|
|
55
|
+
return (_jsxs("div", { role: "status", "aria-busy": "true", className: "flex-1 p-6 flex flex-col gap-3", children: [_jsx("span", { className: "sr-only", children: "Loading the message" }), _jsx(Skeleton, { width: "w-2/3", height: "h-6" }), _jsx(Skeleton, { width: "w-1/3" }), _jsx(Skeleton, { height: "h-40" })] }));
|
|
56
|
+
}
|
|
57
|
+
/** A chunk that can't be downloaded (offline, or replaced by a deploy since this page loaded) is the one failure a reading
|
|
58
|
+
* pane can't recover from by itself: say so, with the fix, rather than leaving a blank pane. */
|
|
59
|
+
function ChunkFailed() {
|
|
60
|
+
return (_jsx("div", { className: "flex-1 p-6", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: "This part of the page couldn\u2019t be loaded. Check your connection, then reload." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => window.location.reload(), children: "Reload" })] }) }));
|
|
61
|
+
}
|
|
62
|
+
const messageDetailPane = lazyComponent(() => import("./MessageDetailPane.js"));
|
|
63
|
+
const conversationThreadPane = lazyComponent(() => import("./ConversationThreadPane.js"));
|
|
64
|
+
/** Starts downloading the reading pane's code, for the page to call when it has nothing better to do. Never rejects. */
|
|
65
|
+
export function prefetchReadingPane() {
|
|
66
|
+
messageDetailPane.start().catch(() => undefined);
|
|
67
|
+
conversationThreadPane.start().catch(() => undefined);
|
|
68
|
+
}
|
|
69
|
+
/** With nothing selected the pane is only its empty-state line, which needs none of the pane's code - so the chunk isn't
|
|
70
|
+
* requested until something is (or the page prefetches it). */
|
|
71
|
+
export function LazyMessageDetailPane(props) {
|
|
72
|
+
if (!props.message) {
|
|
73
|
+
return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Select a message to read it." });
|
|
74
|
+
}
|
|
75
|
+
return _jsx(messageDetailPane.Lazy, { ...props });
|
|
76
|
+
}
|
|
77
|
+
export function LazyConversationThreadPane(props) {
|
|
78
|
+
if (!props.conversation) {
|
|
79
|
+
return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Select a conversation to read it." });
|
|
80
|
+
}
|
|
81
|
+
return _jsx(conversationThreadPane.Lazy, { ...props });
|
|
82
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { MailAddressLike } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
3
|
+
export interface MailAddressProps {
|
|
4
|
+
/** A message's `from`, a recipient, a conversation participant. */
|
|
5
|
+
recipient: MailAddressLike;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A sender or recipient as one compact line for a dense row: the name, when there is one, with the real address in muted
|
|
10
|
+
* type after it - `Jean-Philippe <jp@example.com>` - or the bare address without a name. The address is never left out,
|
|
11
|
+
* and when the row is too narrow for both the name gives way first (it shrinks a thousand times faster), then the
|
|
12
|
+
* address's own local part - the `@domain`, which is what says who really sent it, keeps its room to the end. The full
|
|
13
|
+
* `Name <address>` is the tooltip and, once, the accessible text (the two visible halves are hidden from assistive
|
|
14
|
+
* technology), so a screen reader hears one address, not fragments.
|
|
15
|
+
*/
|
|
16
|
+
export default function MailAddress({ recipient, className }: MailAddressProps): React.JSX.Element;
|
|
17
|
+
/** How many recipients a `RecipientLine` shows before folding the rest behind "and N more". */
|
|
18
|
+
export declare const RECIPIENT_LINE_LIMIT = 3;
|
|
19
|
+
/**
|
|
20
|
+
* A `To`/`Cc` line of a message header: every recipient as `Name <address>` in the text itself (never hover-only), wrapping
|
|
21
|
+
* onto more lines as needed. A long list shows its first `RECIPIENT_LINE_LIMIT` and folds the rest behind a button -
|
|
22
|
+
* "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty.
|
|
23
|
+
*/
|
|
24
|
+
export declare function RecipientLine({ label, recipients }: {
|
|
25
|
+
label: string;
|
|
26
|
+
recipients: MailAddressLike[];
|
|
27
|
+
}): React.JSX.Element | null;
|
|
@@ -0,0 +1,39 @@
|
|
|
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 React, { useState } from "react";
|
|
7
|
+
import { formatMailAddress, splitMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
8
|
+
/**
|
|
9
|
+
* A sender or recipient as one compact line for a dense row: the name, when there is one, with the real address in muted
|
|
10
|
+
* type after it - `Jean-Philippe <jp@example.com>` - or the bare address without a name. The address is never left out,
|
|
11
|
+
* and when the row is too narrow for both the name gives way first (it shrinks a thousand times faster), then the
|
|
12
|
+
* address's own local part - the `@domain`, which is what says who really sent it, keeps its room to the end. The full
|
|
13
|
+
* `Name <address>` is the tooltip and, once, the accessible text (the two visible halves are hidden from assistive
|
|
14
|
+
* technology), so a screen reader hears one address, not fragments.
|
|
15
|
+
*/
|
|
16
|
+
export default function MailAddress({ recipient, className }) {
|
|
17
|
+
const { name, address } = splitMailAddress(recipient);
|
|
18
|
+
const full = formatMailAddress(recipient);
|
|
19
|
+
const at = address.lastIndexOf("@");
|
|
20
|
+
const local = at > 0 ? address.slice(0, at) : address;
|
|
21
|
+
const domain = at > 0 ? address.slice(at) : "";
|
|
22
|
+
return (_jsxs("span", { className: ["flex min-w-0 items-baseline gap-1.5", className].filter(Boolean).join(" "), title: full, children: [_jsx("span", { className: "sr-only", children: full }), name && (_jsx("span", { "aria-hidden": "true", className: "truncate min-w-0", style: { flexShrink: 1000 }, children: name })), _jsxs("span", { "aria-hidden": "true", className: ["flex min-w-0 items-baseline", name ? "text-xs font-normal text-text-muted" : ""].filter(Boolean).join(" "), children: [name && _jsx("span", { children: "<" }), _jsx("span", { className: "truncate min-w-0", children: local }), domain && _jsx("span", { className: "shrink-0", children: domain }), name && _jsx("span", { children: ">" })] })] }));
|
|
23
|
+
}
|
|
24
|
+
/** How many recipients a `RecipientLine` shows before folding the rest behind "and N more". */
|
|
25
|
+
export const RECIPIENT_LINE_LIMIT = 3;
|
|
26
|
+
/**
|
|
27
|
+
* A `To`/`Cc` line of a message header: every recipient as `Name <address>` in the text itself (never hover-only), wrapping
|
|
28
|
+
* onto more lines as needed. A long list shows its first `RECIPIENT_LINE_LIMIT` and folds the rest behind a button -
|
|
29
|
+
* "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty.
|
|
30
|
+
*/
|
|
31
|
+
export function RecipientLine({ label, recipients }) {
|
|
32
|
+
const [expanded, setExpanded] = useState(false);
|
|
33
|
+
if (recipients.length === 0) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const folded = recipients.length > RECIPIENT_LINE_LIMIT;
|
|
37
|
+
const shown = folded && !expanded ? recipients.slice(0, RECIPIENT_LINE_LIMIT) : recipients;
|
|
38
|
+
return (_jsxs("p", { className: "text-sm text-text-muted break-words", children: [_jsx("span", { children: label }), " ", shown.map((recipient, index) => (_jsxs(React.Fragment, { children: [index > 0 && ", ", _jsx("span", { children: formatMailAddress(recipient) })] }, index))), folded && (_jsxs(_Fragment, { children: [" ", _jsx("button", { type: "button", "aria-expanded": expanded, onClick: () => setExpanded((open) => !open), className: "text-xs font-semibold text-primary-dark underline hover:no-underline", children: expanded ? "Show fewer" : `and ${recipients.length - RECIPIENT_LINE_LIMIT} more` })] }))] }));
|
|
39
|
+
}
|
|
@@ -45,6 +45,9 @@ export interface MailSelectionBarProps {
|
|
|
45
45
|
* `version`s the first has already superseded. */
|
|
46
46
|
busy: boolean;
|
|
47
47
|
error: string | null;
|
|
48
|
+
/** The keyboard acts on this selection (Delete, Ctrl+Q, Ctrl+U, Insert - registered by the page): Mark read, Mark unread, Flag and
|
|
49
|
+
* Delete name their shortcut in the tooltip and `aria-keyshortcuts`. */
|
|
50
|
+
shortcuts?: boolean;
|
|
48
51
|
}
|
|
49
52
|
/**
|
|
50
53
|
* The header that replaces the list's own toolbar while select mode is on: how many rows are selected,
|
|
@@ -56,4 +59,4 @@ export interface MailSelectionBarProps {
|
|
|
56
59
|
* target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
|
|
57
60
|
* of actions doesn't shift around between folders.
|
|
58
61
|
*/
|
|
59
|
-
export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, error, }: MailSelectionBarProps): React.JSX.Element;
|
|
62
|
+
export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, error, shortcuts, }: MailSelectionBarProps): React.JSX.Element;
|
|
@@ -7,6 +7,9 @@ import { useState } from "react";
|
|
|
7
7
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
8
8
|
import LabelMenuButton from "./labelMenu.js";
|
|
9
9
|
import MoveToFolderDialog, { MOVE_TARGET_TYPES } from "./MoveToFolderDialog.js";
|
|
10
|
+
import { ariaKeyShortcuts, withHint } from "../../keyboard/format.js";
|
|
11
|
+
import { SHORTCUTS } from "../../keyboard/keymap.js";
|
|
12
|
+
import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
|
|
10
13
|
function actionClassName() {
|
|
11
14
|
return "px-2 py-1 rounded-md text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent";
|
|
12
15
|
}
|
|
@@ -20,7 +23,12 @@ function actionClassName() {
|
|
|
20
23
|
* target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
|
|
21
24
|
* of actions doesn't shift around between folders.
|
|
22
25
|
*/
|
|
23
|
-
export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, error, }) {
|
|
26
|
+
export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, error, shortcuts, }) {
|
|
27
|
+
const env = useKeyEnvironment();
|
|
28
|
+
/** `title` and `aria-keyshortcuts` for an action the keyboard also does; `reason` (why it is disabled) wins the tooltip. */
|
|
29
|
+
const hint = (label, shortcut, reason) => shortcuts
|
|
30
|
+
? { title: reason ?? withHint(label, shortcut, env), "aria-keyshortcuts": ariaKeyShortcuts(shortcut, env) }
|
|
31
|
+
: { title: reason };
|
|
24
32
|
const selectedCount = totals?.selected ?? selected.length;
|
|
25
33
|
const listedCount = totals?.listed ?? listed.length;
|
|
26
34
|
// Counted on the rows that were ticked, but *emptied* on the messages: a ticked conversation whose
|
|
@@ -42,7 +50,7 @@ export default function MailSelectionBar({ selected, listed, totals, onSelectAll
|
|
|
42
50
|
// partially applied, and is left exactly as it is unless the reader touches that row.
|
|
43
51
|
const appliedToAll = labels.filter((label) => !none && selected.every((m) => m.labelUids?.includes(label.uid)));
|
|
44
52
|
const appliedToSome = labels.filter((label) => selected.some((m) => m.labelUids?.includes(label.uid)) && !appliedToAll.includes(label));
|
|
45
|
-
return (_jsxs("div", { className: "border-b border-border", children: [_jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: mailboxUid, folders: folders, currentFolderUid: currentFolderUid, count: selected.length, onMove: onMoveTo, onFolderCreated: onFolderCreated }), _jsxs("div", { className: "flex items-center gap-2 px-3 py-1.5 bg-surface-alt", children: [_jsxs("span", { "aria-live": "polite", className: "text-sm font-semibold text-text", children: [selectedCount, totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : "", " selected"] }), _jsx("button", { type: "button", onClick: onSelectAll, disabled: allSelected || listedCount === 0, className: actionClassName(), children: "Select all" }), _jsx("button", { type: "button", onClick: onClearSelection, disabled: selectedCount === 0, className: actionClassName(), children: "Clear" }), _jsx("button", { type: "button", onClick: onCancel, className: `${actionClassName()} ml-auto`, children: "Cancel" })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-0.5 px-1.5 py-1", children: [_jsx("button", { type: "button", onClick: () => onSetRead(true), disabled: none || busy, className: actionClassName(), children: "Mark read" }), _jsx("button", { type: "button", onClick: () => onSetRead(false), disabled: none || busy, className: actionClassName(), children: "Mark unread" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(true), disabled: none || busy, className: actionClassName(), children: "Flag" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(false), disabled: none || busy, className: actionClassName(), children: "Unflag" }), _jsx("button", { type: "button", onClick: onArchive, disabled: none || busy || !!archiveReason, title: archiveReason, className: actionClassName(), children: "Archive" }), _jsx(LabelMenuButton, { "aria-label": "Apply label", label: "Apply label", className: "text-sm", labels: labels, mailboxUid: mailboxUid, onLabelCreated: onLabelCreated, applied: appliedToAll.map((label) => label.uid), partial: appliedToSome.map((label) => label.uid), onCommit: onApplyLabels, busy: busy, disabled: none || busy, note: selected.length === 1
|
|
53
|
+
return (_jsxs("div", { className: "border-b border-border", children: [_jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: mailboxUid, folders: folders, currentFolderUid: currentFolderUid, count: selected.length, onMove: onMoveTo, onFolderCreated: onFolderCreated }), _jsxs("div", { className: "flex items-center gap-2 px-3 py-1.5 bg-surface-alt", children: [_jsxs("span", { "aria-live": "polite", className: "text-sm font-semibold text-text", children: [selectedCount, totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : "", " selected"] }), _jsx("button", { type: "button", onClick: onSelectAll, disabled: allSelected || listedCount === 0, className: actionClassName(), children: "Select all" }), _jsx("button", { type: "button", onClick: onClearSelection, disabled: selectedCount === 0, className: actionClassName(), children: "Clear" }), _jsx("button", { type: "button", onClick: onCancel, className: `${actionClassName()} ml-auto`, children: "Cancel" })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-0.5 px-1.5 py-1", children: [_jsx("button", { type: "button", onClick: () => onSetRead(true), disabled: none || busy, className: actionClassName(), ...hint("Mark read", SHORTCUTS.mail.markRead), children: "Mark read" }), _jsx("button", { type: "button", onClick: () => onSetRead(false), disabled: none || busy, className: actionClassName(), ...hint("Mark unread", SHORTCUTS.mail.markUnread), children: "Mark unread" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(true), disabled: none || busy, className: actionClassName(), ...hint("Flag", SHORTCUTS.mail.flag), children: "Flag" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(false), disabled: none || busy, className: actionClassName(), children: "Unflag" }), _jsx("button", { type: "button", onClick: onArchive, disabled: none || busy || !!archiveReason, title: archiveReason, className: actionClassName(), children: "Archive" }), _jsx(LabelMenuButton, { "aria-label": "Apply label", label: "Apply label", className: "text-sm", labels: labels, mailboxUid: mailboxUid, onLabelCreated: onLabelCreated, applied: appliedToAll.map((label) => label.uid), partial: appliedToSome.map((label) => label.uid), onCommit: onApplyLabels, busy: busy, disabled: none || busy, note: selected.length === 1
|
|
46
54
|
? "Ticked labels are applied, unticked ones removed."
|
|
47
|
-
: "Ticked labels are applied to every selected message and unticked ones removed from all of them; a dash means only some have that label, and leaving it alone keeps it that way.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }), _jsx("button", { type: "button", onClick: () => setMovePrompt(true), disabled: none || busy, className: actionClassName(), children: "Move to" }), _jsx("button", { type: "button", onClick: onReportJunk, disabled: none || busy || !!junkReason, title: junkReason, className: actionClassName(), children: "Report junk" }), _jsx("button", { type: "button", onClick: onDelete, disabled: none || busy || !!deleteReason,
|
|
55
|
+
: "Ticked labels are applied to every selected message and unticked ones removed from all of them; a dash means only some have that label, and leaving it alone keeps it that way.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }), _jsx("button", { type: "button", onClick: () => setMovePrompt(true), disabled: none || busy, className: actionClassName(), children: "Move to" }), _jsx("button", { type: "button", onClick: onReportJunk, disabled: none || busy || !!junkReason, title: junkReason, className: actionClassName(), children: "Report junk" }), _jsx("button", { type: "button", onClick: onDelete, disabled: none || busy || !!deleteReason, className: actionClassName(), ...hint("Delete", SHORTCUTS.mail.delete, deleteReason), children: "Delete" })] }), error && (_jsx("div", { className: "px-3 pb-2", children: _jsx(Alert, { children: error }) }))] }));
|
|
48
56
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Attachment, Folder, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
3
3
|
import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
4
|
-
import { MessageSecurityResult } from "@rapidmx/react-shared/crypto/messageSecurity.js";
|
|
4
|
+
import type { MessageSecurityResult } from "@rapidmx/react-shared/crypto/messageSecurity.js";
|
|
5
5
|
/** The detail line under a `verified_at_first_open` badge. */
|
|
6
6
|
export declare function verifiedAtFirstOpenMessage(result: MessageSecurityResult): string;
|
|
7
7
|
export interface SenderNameCheck {
|
|
@@ -90,6 +90,12 @@ export interface MessageDetailPaneProps {
|
|
|
90
90
|
* subject becomes a heading under the thread's own, since a document has one `h1` and the thread's is
|
|
91
91
|
* the conversation. Everything else - the badges, the actions, the body - is identical. */
|
|
92
92
|
inThread?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Registers this pane's keyboard shortcuts - Reply, Reply all, Forward, Archive, Move to - so they act on this message. Only the message
|
|
95
|
+
* the keyboard is acting on should set it: the one pane beside the list, or the opened message of a thread (each expanded message of a
|
|
96
|
+
* thread is a pane, and two of them must not both claim Ctrl+R). Also puts the shortcut in those buttons' tooltips.
|
|
97
|
+
*/
|
|
98
|
+
shortcuts?: boolean;
|
|
93
99
|
}
|
|
94
100
|
/**
|
|
95
101
|
* A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
|
|
@@ -11,7 +11,6 @@ import { buildForwardQuote, buildReplyQuote, buildReplyRecipients, buildReplyThr
|
|
|
11
11
|
import { sanitizeMessageBodyHtml } from "@rapidmx/react-shared/mail/messageBodySanitizer.js";
|
|
12
12
|
import MoveToFolderDialog from "./MoveToFolderDialog.js";
|
|
13
13
|
import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
14
|
-
import { evaluateMessageSecurity, evaluateMessageSecurityWithSeal, } from "@rapidmx/react-shared/crypto/messageSecurity.js";
|
|
15
14
|
import { extractAddresses } from "@rapidmx/react-shared/crypto/mime.js";
|
|
16
15
|
import { SignerKeyConflictError, signingKeyFingerprints, trustSigner } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
17
16
|
import { isLikelyMailingList } from "@rapidmx/react-shared/crypto/composeSecurity.js";
|
|
@@ -20,10 +19,16 @@ import { currentVerificationSeal, getVaultGeneration, sendVerificationSeal } fro
|
|
|
20
19
|
import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
|
|
21
20
|
import KeyChangeReview from "../contacts/KeyChangeReview.js";
|
|
22
21
|
import { KEY_CHANGE_STALE_MESSAGE, sameFingerprint } from "../contacts/contactKeys.js";
|
|
23
|
-
import { useCompose } from "./compose/ComposeContext.js";
|
|
24
|
-
import { loadOriginalMessage } from "./compose/quotedBody.js";
|
|
22
|
+
import { prefetchComposeWindow, useCompose } from "./compose/ComposeContext.js";
|
|
23
|
+
import { loadOriginalMessage, prefetchOriginalMessage } from "./compose/quotedBody.js";
|
|
25
24
|
import { formatRecipient } from "./compose/recipients.js";
|
|
25
|
+
import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
26
|
+
import { RecipientLine } from "./MailAddress.js";
|
|
26
27
|
import { useMailShell } from "./layout/MailShell.js";
|
|
28
|
+
import { ariaKeyShortcuts, withHint } from "../../keyboard/format.js";
|
|
29
|
+
import { SHORTCUTS } from "../../keyboard/keymap.js";
|
|
30
|
+
import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
|
|
31
|
+
import { useShortcut } from "../../keyboard/useShortcut.js";
|
|
27
32
|
import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
|
|
28
33
|
import { moveLocalEntity } from "../../search/localIndexRpcClient.js";
|
|
29
34
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
@@ -129,8 +134,9 @@ function downloadMimeAttachment(attachment) {
|
|
|
129
134
|
* A plain `<button>` rather than react-shared's `Button`, whose padding and minimum width are sized for a
|
|
130
135
|
* text label. Ordinary DOM order, so the keyboard reaches these in the order they are read.
|
|
131
136
|
*/
|
|
132
|
-
function IconAction({ icon, label, onClick, disabled, busy, }) {
|
|
133
|
-
|
|
137
|
+
function IconAction({ icon, label, onClick, disabled, busy, onPrefetch, shortcut, }) {
|
|
138
|
+
const env = useKeyEnvironment();
|
|
139
|
+
return (_jsx("button", { type: "button", "aria-label": label, title: shortcut ? withHint(label, shortcut, env) : label, "aria-keyshortcuts": shortcut ? ariaKeyShortcuts(shortcut, env) : undefined, "aria-busy": busy || undefined, disabled: disabled, onClick: onClick, onPointerEnter: onPrefetch, onFocus: onPrefetch, className: "inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent", children: icon }));
|
|
134
140
|
}
|
|
135
141
|
function SecurityIndicator({ security }) {
|
|
136
142
|
const { label, className } = SECURITY_INDICATOR[security.state];
|
|
@@ -193,7 +199,7 @@ export default function MessageDetailPane(props) {
|
|
|
193
199
|
function needsRawSecurityEvaluation(message) {
|
|
194
200
|
return !!message.encrypted || message.hasAttachments;
|
|
195
201
|
}
|
|
196
|
-
function MessageDetailContent({ message: messageProp, attachments, backHref, isSentItems, onRecalled, isOutbox, draftsFolderUid, onScheduledSendCanceled, folders, onMoved, onFolderCreated, onReceiptHandled, onArchived, labels, onLabelsChanged, onLabelCreated, inThread, }) {
|
|
202
|
+
function MessageDetailContent({ message: messageProp, attachments, backHref, isSentItems, onRecalled, isOutbox, draftsFolderUid, onScheduledSendCanceled, folders, onMoved, onFolderCreated, onReceiptHandled, onArchived, labels, onLabelsChanged, onLabelCreated, inThread, shortcuts, }) {
|
|
197
203
|
// A copy re-read from the server after an Outbox action was refused (409/403) or a send lease ran out - see
|
|
198
204
|
// `reloadMessage()`. A prop copy newer by `version` wins; an equal-version reload is kept, since claiming a send
|
|
199
205
|
// lease need not bump `version`.
|
|
@@ -237,7 +243,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
237
243
|
const [keyChangeNotice, setKeyChangeNotice] = useState(null);
|
|
238
244
|
// "Now", for deciding whether a scheduled send's lease is still live - advanced when the lease runs out.
|
|
239
245
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
240
|
-
const { mailboxes } = useMailShell();
|
|
246
|
+
const { mailboxes, trackMessageChange } = useMailShell();
|
|
241
247
|
const { requestUnlock } = useUnlockPrompt();
|
|
242
248
|
// Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
|
|
243
249
|
// effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
|
|
@@ -291,6 +297,9 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
291
297
|
if (cancelled) {
|
|
292
298
|
return;
|
|
293
299
|
}
|
|
300
|
+
// The S/MIME code (PKI.js and the ASN.1/X.509 libraries) loads here, only for a message that is signed or
|
|
301
|
+
// encrypted - it is over half a megabyte, and the pane opens plain messages without it.
|
|
302
|
+
const messageSecurity = await import("@rapidmx/react-shared/crypto/messageSecurity.js");
|
|
294
303
|
const [primaryAddress, ...aliasAddresses] = readerAddressesKey ? readerAddressesKey.split(" ") : [];
|
|
295
304
|
const ownAddresses = readerAddressesKey.toLowerCase().split(" ");
|
|
296
305
|
const ownKeys = ownAddresses.includes(senderAddress.toLowerCase()) ? (readerMailbox.keys ?? []) : [];
|
|
@@ -308,7 +317,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
308
317
|
...(sealContext.keyState?.previous ?? []),
|
|
309
318
|
...ownKeys,
|
|
310
319
|
];
|
|
311
|
-
result = await evaluateMessageSecurityWithSeal(rawMime, unlocked, pinned, primaryAddress, {
|
|
320
|
+
result = await messageSecurity.evaluateMessageSecurityWithSeal(rawMime, unlocked, pinned, primaryAddress, {
|
|
312
321
|
mailboxUid: message.mailboxUid,
|
|
313
322
|
messageUid: message.uid,
|
|
314
323
|
...currentVerificationSeal(message),
|
|
@@ -316,14 +325,14 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
316
325
|
signerKeys,
|
|
317
326
|
}).catch(() =>
|
|
318
327
|
// Only a lock while sealing throws: evaluate as locked (the lock itself re-evaluates too).
|
|
319
|
-
evaluateMessageSecurity(rawMime, undefined, pinned, primaryAddress));
|
|
328
|
+
messageSecurity.evaluateMessageSecurity(rawMime, undefined, pinned, primaryAddress));
|
|
320
329
|
if (result.sealToWrite) {
|
|
321
330
|
// Best effort and in the background: never awaited, never shown.
|
|
322
331
|
void sendVerificationSeal(message.uid, result.sealToWrite);
|
|
323
332
|
}
|
|
324
333
|
}
|
|
325
334
|
else {
|
|
326
|
-
result = await evaluateMessageSecurity(rawMime, unlocked, pinned, primaryAddress);
|
|
335
|
+
result = await messageSecurity.evaluateMessageSecurity(rawMime, unlocked, pinned, primaryAddress);
|
|
327
336
|
}
|
|
328
337
|
// Only one reader address can be checked per evaluation: a message sent to one of this mailbox's
|
|
329
338
|
// aliases isn't "not addressed to you", so each alias is tried before saying so.
|
|
@@ -331,7 +340,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
331
340
|
if (!result.notAddressedToReader) {
|
|
332
341
|
break;
|
|
333
342
|
}
|
|
334
|
-
const viaAlias = await evaluateMessageSecurity(rawMime, unlocked, pinned, alias);
|
|
343
|
+
const viaAlias = await messageSecurity.evaluateMessageSecurity(rawMime, unlocked, pinned, alias);
|
|
335
344
|
result = { ...result, notAddressedToReader: viaAlias.notAddressedToReader };
|
|
336
345
|
}
|
|
337
346
|
const unpinned = contactPins.loaded && pins.length === 0;
|
|
@@ -440,46 +449,72 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
440
449
|
const mailbox = readerMailbox ?? (await getMailbox(message.mailboxUid).catch(() => undefined));
|
|
441
450
|
return mailbox ? [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])] : [];
|
|
442
451
|
}
|
|
452
|
+
/** Starts fetching what Reply/Reply All/Forward will need (the compose window's code, the original's body) as the
|
|
453
|
+
* pointer or keyboard reaches one of their buttons, so the click itself finds it already on its way or here. */
|
|
454
|
+
function prefetchReply() {
|
|
455
|
+
prefetchComposeWindow();
|
|
456
|
+
prefetchOriginalMessage(message);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Opens the compose window at once from what is already in memory - the message's own subject and sender, and the
|
|
460
|
+
* mailbox it was read in - and fetches what needs the network meanwhile: the original's body to quote, and for Reply All
|
|
461
|
+
* the recipients its headers name. Both reach the window through `OpenComposeInput.pending` when they arrive, so the
|
|
462
|
+
* window is on screen on the click's own frame rather than after two or three sequential requests.
|
|
463
|
+
*/
|
|
443
464
|
async function handleReplyOrForward(kind) {
|
|
444
465
|
setPreparingCompose(true);
|
|
445
466
|
try {
|
|
446
|
-
const [original, own] = await Promise.all([
|
|
447
|
-
loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
|
|
448
|
-
kind === "forward" ? [] : ownAddresses(),
|
|
449
|
-
]);
|
|
450
467
|
const encrypt = !!message.encrypted;
|
|
451
468
|
// What makes the new message part of this one's thread rather than a conversation of its own:
|
|
452
469
|
// the server composes the MIME from the recipients, subject and HTML alone, so nothing else
|
|
453
470
|
// recovers what is being replied to. A forward carries it for the same reason - it continues
|
|
454
471
|
// the thread it came from, which is where its recipient will file the reply to it.
|
|
455
472
|
const threading = buildReplyThreading(message);
|
|
473
|
+
const format = (list) => list.map((r) => formatRecipient(r)).join(", ");
|
|
474
|
+
// Known without a request when the shell has already listed the mailbox; otherwise fetched with the body, and
|
|
475
|
+
// the window's recipients are corrected by `late` below if that changes who they are.
|
|
476
|
+
const knownOwn = readerAddressesKey ? readerAddressesKey.split(" ") : undefined;
|
|
477
|
+
// Never rejects: loadOriginalMessage() and ownAddresses() don't, and the rest is pure.
|
|
478
|
+
const late = (async () => {
|
|
479
|
+
const [original, own] = await Promise.all([
|
|
480
|
+
loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
|
|
481
|
+
kind === "forward" ? [] : (knownOwn ?? ownAddresses()),
|
|
482
|
+
]);
|
|
483
|
+
if (kind === "forward") {
|
|
484
|
+
return { quotedHtml: buildForwardQuote(message, original.body) };
|
|
485
|
+
}
|
|
486
|
+
// The message's own recipients are only the envelope recipient of a delivered message, so Reply All
|
|
487
|
+
// answers whoever its headers actually name, with anything the record holds that they don't.
|
|
488
|
+
const known = new Set((original.recipients ?? []).map((r) => r.address.trim().toLowerCase()));
|
|
489
|
+
const recipients = buildReplyRecipients({ ...message, recipients: [...(original.recipients ?? []), ...message.recipients.filter((r) => !known.has(r.address.trim().toLowerCase()))] }, own, kind === "replyAll");
|
|
490
|
+
return { quotedHtml: buildReplyQuote(message, original.body), to: format(recipients.to), cc: format(recipients.cc) };
|
|
491
|
+
})();
|
|
456
492
|
if (kind === "forward") {
|
|
457
493
|
openCompose({
|
|
458
494
|
mailboxUid: message.mailboxUid,
|
|
459
495
|
subject: forwardSubject(message.subject),
|
|
460
|
-
quotedHtml: buildForwardQuote(message, original.body),
|
|
461
496
|
signatureContext: "reply_forward",
|
|
462
497
|
encrypt,
|
|
463
498
|
threading,
|
|
499
|
+
pending: late,
|
|
464
500
|
});
|
|
465
|
-
return;
|
|
466
501
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
502
|
+
else {
|
|
503
|
+
const first = buildReplyRecipients(message, knownOwn ?? [], kind === "replyAll");
|
|
504
|
+
openCompose({
|
|
505
|
+
mailboxUid: message.mailboxUid,
|
|
506
|
+
to: format(first.to),
|
|
507
|
+
cc: first.cc.length > 0 ? format(first.cc) : undefined,
|
|
508
|
+
subject: replySubject(message.subject),
|
|
509
|
+
signatureContext: "reply_forward",
|
|
510
|
+
suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
|
|
511
|
+
encrypt,
|
|
512
|
+
threading,
|
|
513
|
+
pending: late,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
// Held until the quote is in, so a second click can't open a second window for the same message meanwhile.
|
|
517
|
+
await late;
|
|
483
518
|
}
|
|
484
519
|
finally {
|
|
485
520
|
setPreparingCompose(false);
|
|
@@ -515,6 +550,8 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
515
550
|
const updated = await cancelScheduledSend(message, draftsFolderUid);
|
|
516
551
|
// Keeps a `folder:`-scoped Tier 2 local search from still finding it in its old folder.
|
|
517
552
|
void moveLocalEntity(updated.mailboxUid, updated.uid, updated.folderUid);
|
|
553
|
+
// Out of Outbox, into Drafts: both folders' badges.
|
|
554
|
+
trackMessageChange(message, updated).settle();
|
|
518
555
|
onScheduledSendCanceled?.(updated);
|
|
519
556
|
}
|
|
520
557
|
catch (err) {
|
|
@@ -539,6 +576,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
539
576
|
try {
|
|
540
577
|
const updated = await archiveMessage(message.uid);
|
|
541
578
|
void moveLocalEntity(updated.mailboxUid, updated.uid, updated.folderUid);
|
|
579
|
+
trackMessageChange(message, updated).settle();
|
|
542
580
|
onArchived?.(updated);
|
|
543
581
|
}
|
|
544
582
|
catch (err) {
|
|
@@ -583,8 +621,21 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
583
621
|
*/
|
|
584
622
|
async function handleMove(folderUid) {
|
|
585
623
|
const updated = await moveMessage(message, folderUid);
|
|
624
|
+
// Move to, Delete and Report junk are all this: the source folder's badge goes down, the target's up.
|
|
625
|
+
trackMessageChange(message, updated).settle();
|
|
586
626
|
onMoved?.(updated);
|
|
587
627
|
}
|
|
628
|
+
// Keyboard shortcuts for the message this pane shows. They call the same handlers as the buttons below, and exist only where the
|
|
629
|
+
// button does (Archive not for Drafts/Outbox, Move to only with folders to move to). A shortcut pressed while its action is already
|
|
630
|
+
// running is consumed and does nothing, exactly as the disabled button would: a browser must not act on Ctrl+R meanwhile.
|
|
631
|
+
const archivable = !inOutbox && message.folderUid !== draftsFolderUid;
|
|
632
|
+
const movable = !!folders && folders.length > 0;
|
|
633
|
+
const keyboard = !!shortcuts;
|
|
634
|
+
useShortcut(SHORTCUTS.mail.reply, () => void (!preparingCompose && handleReplyOrForward("reply")), { enabled: keyboard });
|
|
635
|
+
useShortcut(SHORTCUTS.mail.replyAll, () => void (!preparingCompose && handleReplyOrForward("replyAll")), { enabled: keyboard });
|
|
636
|
+
useShortcut(SHORTCUTS.mail.forward, () => void (!preparingCompose && handleReplyOrForward("forward")), { enabled: keyboard });
|
|
637
|
+
useShortcut(SHORTCUTS.mail.archive, () => void (!archiving && handleArchive()), { enabled: keyboard && archivable });
|
|
638
|
+
useShortcut(SHORTCUTS.mail.move, () => setMovePrompt(true), { enabled: keyboard && movable });
|
|
588
639
|
// Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
|
|
589
640
|
// is loaded — same real invariant as every other handler above.
|
|
590
641
|
async function handleReceipt(type, action) {
|
|
@@ -627,8 +678,8 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
627
678
|
const senderAddress = (signatureShown ? extractAddresses(security.protectedHeaders?.from)[0] : undefined) ?? message.from.address;
|
|
628
679
|
const senderName = message.from.displayName;
|
|
629
680
|
const senderNameCheck = checkSenderName(senderName, senderAddress);
|
|
630
|
-
|
|
631
|
-
const senderLabel =
|
|
681
|
+
// Always `Name <address>` (or the bare address): a name alone hides who a message is really from.
|
|
682
|
+
const senderLabel = formatMailAddress({ displayName: senderName, address: senderAddress });
|
|
632
683
|
// Offered only for a valid signature from a certificate nobody pinned for this sender - never to replace a pin.
|
|
633
684
|
// A recorded signing-key conflict for this sender is resolved from the contact, never by trusting another key.
|
|
634
685
|
const pendingConflict = senderUnpinned && senderKeyState?.conflict !== undefined;
|
|
@@ -668,7 +719,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
668
719
|
source: recordedConflict?.source,
|
|
669
720
|
}, certificate: keyChanged.signerCertificate, canReject: recordedConflict !== undefined, canResolve: canUpdateMailbox, onResolved: (action) => refreshAfterKeyChange(action === "reject" ? KEPT_CURRENT_SIGNING_KEY_MESSAGE : null), onPinnedKeyChanged: () => refreshAfterKeyChange(KEY_CHANGE_STALE_MESSAGE) })] })), security?.state === "signature_failed" && !keyChanged && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: security.signatureFailureReason
|
|
670
721
|
? SIGNATURE_FAILURE_MESSAGE[security.signatureFailureReason]
|
|
671
|
-
: GENERIC_SIGNATURE_FAILURE_MESSAGE })), verifiedOrSealed && !security.protectedHeaders && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The signature covers this message\u2019s content and attachments only. Its Subject, To and Cc weren\u2019t signed, so they could have been changed after it was sent." })), security?.notAddressedToReader && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The recipients this message was signed or encrypted for don\u2019t include this mailbox - it may have been forwarded or re-sent to you unchanged, or you were Bcc\u2019d." })), _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["From ", senderLabel, " \u00B7 ", new Date(message.receivedDate).toLocaleString()] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })),
|
|
722
|
+
: GENERIC_SIGNATURE_FAILURE_MESSAGE })), verifiedOrSealed && !security.protectedHeaders && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The signature covers this message\u2019s content and attachments only. Its Subject, To and Cc weren\u2019t signed, so they could have been changed after it was sent." })), security?.notAddressedToReader && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The recipients this message was signed or encrypted for don\u2019t include this mailbox - it may have been forwarded or re-sent to you unchanged, or you were Bcc\u2019d." })), _jsxs("p", { className: "text-sm text-text-muted mt-1 break-words", children: ["From ", _jsx("span", { className: "font-medium text-text", children: senderLabel }), " \u00B7", " ", new Date(message.receivedDate).toLocaleString()] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc") }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc") }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc") }), _jsxs("div", { className: "flex flex-wrap gap-1 mt-3", children: [_jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true" }), label: "Reply", shortcut: keyboard ? SHORTCUTS.mail.reply : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("reply") }), _jsx(IconAction, { icon: _jsxs("span", { className: "inline-flex items-center", "aria-hidden": "true", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16 }), _jsx(HiOutlineArrowUturnLeft, { size: 16, className: "-ml-2.5" })] }), label: "Reply All", shortcut: keyboard ? SHORTCUTS.mail.replyAll : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("replyAll") }), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnRight, { size: 16, "aria-hidden": "true" }), label: "Forward", shortcut: keyboard ? SHORTCUTS.mail.forward : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("forward") }), archivable && (_jsx(IconAction, { icon: _jsx(HiOutlineArchiveBox, { size: 16, "aria-hidden": "true" }), label: "Archive", shortcut: keyboard ? SHORTCUTS.mail.archive : undefined, busy: archiving, disabled: archiving, onClick: handleArchive })), movable && (_jsx(IconAction, { icon: _jsx(HiOutlineFolderArrowDown, { size: 16, "aria-hidden": "true" }), label: "Move to", shortcut: keyboard ? SHORTCUTS.mail.move : undefined, onClick: () => setMovePrompt(true) })), labels && labels.length > 0 && (_jsx(LabelMenuButton, { "aria-label": "Labels", label: "Labels", className: "border border-border py-1.5", labels: labels, mailboxUid: message.mailboxUid, onLabelCreated: onLabelCreated, applied: currentLabelsMessage.labelUids ?? [], onCommit: (labelUids) => void handleApplyLabels(labelUids), busy: savingLabels, note: "Ticked labels are applied to this message and unticked ones removed.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }))] }), archiveError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: archiveError }) })), labelsError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: labelsError }) })), labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (_jsx("div", { className: "flex flex-wrap items-center gap-1.5 mt-2", children: currentLabelsMessage
|
|
672
723
|
.labelUids.map((uid) => labels.find((l) => l.uid === uid))
|
|
673
724
|
.filter((l) => !!l)
|
|
674
725
|
.map((l) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt", children: [_jsx("span", { className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }, l.uid))) })), ["delivery", "read"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { NewMailNotice } from "../../mail/newMailNotifications.js";
|
|
3
|
+
export interface NewMailToastsProps {
|
|
4
|
+
/** The pop-ups to show, oldest first. */
|
|
5
|
+
toasts: NewMailNotice[];
|
|
6
|
+
onDismiss: (uid: string) => void;
|
|
7
|
+
/** Show the "Turn on desktop notifications" offer in the first pop-up. */
|
|
8
|
+
offerDesktop: boolean;
|
|
9
|
+
onEnableDesktop: () => void;
|
|
10
|
+
onDeclineDesktop: () => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The new-mail pop-ups, stacked at the top right (`fixed`, above everything). The container is always rendered - a live region
|
|
14
|
+
* has to exist before its content changes to be announced - and is a polite `status` region, so an arrival is read out without
|
|
15
|
+
* interrupting. Each pop-up shows the sender's name and address, the subject and a short preview, as text; clicking it opens the
|
|
16
|
+
* message; it has a dismiss button and goes by itself after a few seconds (see `ToastItem`). Motion is CSS-only and switched off
|
|
17
|
+
* by `prefers-reduced-motion` (`.rr-toast-in` in `app.css`).
|
|
18
|
+
*/
|
|
19
|
+
export default function NewMailToasts({ toasts, onDismiss, offerDesktop, onEnableDesktop, onDeclineDesktop }: NewMailToastsProps): React.JSX.Element;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
import { HiOutlineXMark } from "react-icons/hi2";
|
|
8
|
+
import { TOAST_DURATION_MS } from "../../mail/useNewMailNotifications.js";
|
|
9
|
+
/** Whether the tab is out of view - a pop-up's clock stops meanwhile, so it is still there when the reader comes back. */
|
|
10
|
+
function useTabHidden() {
|
|
11
|
+
const [hidden, setHidden] = useState(false);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const update = () => setHidden(document.visibilityState === "hidden");
|
|
14
|
+
update();
|
|
15
|
+
document.addEventListener("visibilitychange", update);
|
|
16
|
+
return () => document.removeEventListener("visibilitychange", update);
|
|
17
|
+
}, []);
|
|
18
|
+
return hidden;
|
|
19
|
+
}
|
|
20
|
+
/** One pop-up. Its 8 seconds only run while it is neither hovered nor holding keyboard focus, and the tab is in view. */
|
|
21
|
+
function ToastItem({ toast, tabHidden, onDismiss, offer }) {
|
|
22
|
+
const [hovered, setHovered] = useState(false);
|
|
23
|
+
const [focused, setFocused] = useState(false);
|
|
24
|
+
const remainingRef = useRef(TOAST_DURATION_MS);
|
|
25
|
+
const paused = tabHidden || hovered || focused;
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (paused) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const startedAt = Date.now();
|
|
31
|
+
const timer = setTimeout(() => onDismiss(toast.uid), remainingRef.current);
|
|
32
|
+
return () => {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
remainingRef.current = Math.max(0, remainingRef.current - (Date.now() - startedAt));
|
|
35
|
+
};
|
|
36
|
+
}, [paused, toast.uid, onDismiss]);
|
|
37
|
+
const sender = toast.senderName || toast.senderAddress;
|
|
38
|
+
return (_jsxs("div", { "data-testid": "new-mail-toast", onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), onFocus: () => setFocused(true), onBlur: (event) => {
|
|
39
|
+
// Focus moving between this pop-up's own controls is not leaving it.
|
|
40
|
+
if (!event.currentTarget.contains(event.relatedTarget)) {
|
|
41
|
+
setFocused(false);
|
|
42
|
+
}
|
|
43
|
+
}, className: "rr-toast-in pointer-events-auto relative rounded-md border border-border border-l-4 border-l-primary bg-surface text-text shadow-modal", children: [_jsxs("a", { href: toast.href, onClick: () => onDismiss(toast.uid), className: "block rounded-md p-3 pr-10 hover:bg-surface-alt focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary", children: [_jsx("span", { className: "block text-xs font-semibold uppercase tracking-wide text-primary-dark", children: "New message" }), _jsxs("span", { className: "mt-0.5 flex min-w-0 items-baseline gap-1.5 text-sm", children: [_jsx("span", { className: "truncate font-bold", children: sender }), toast.senderName && _jsxs("span", { className: "truncate text-xs text-text-muted", children: ["<", toast.senderAddress, ">"] })] }), _jsx("span", { className: "block truncate text-sm font-semibold", children: toast.subject }), toast.preview && _jsx("span", { className: "mt-0.5 block text-xs text-text-muted [overflow-wrap:anywhere]", children: toast.preview })] }), _jsx("button", { type: "button", onClick: () => onDismiss(toast.uid), "aria-label": `Dismiss notification: ${toast.subject}`, 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" }) }), offer && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 border-t border-border px-3 py-2 text-xs text-text-muted", children: [_jsx("span", { className: "basis-full", children: "Get a desktop notification when mail arrives while this tab is in the background." }), _jsx("button", { type: "button", onClick: offer.onEnable, className: "rounded-sm bg-primary px-2.5 py-1 text-xs font-semibold text-text-on-primary hover:bg-primary-dark", children: "Turn on desktop notifications" }), _jsx("button", { type: "button", onClick: offer.onDecline, className: "rounded-sm px-2 py-1 text-xs font-medium hover:bg-surface-alt hover:text-text", children: "Not now" })] }))] }));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The new-mail pop-ups, stacked at the top right (`fixed`, above everything). The container is always rendered - a live region
|
|
47
|
+
* has to exist before its content changes to be announced - and is a polite `status` region, so an arrival is read out without
|
|
48
|
+
* interrupting. Each pop-up shows the sender's name and address, the subject and a short preview, as text; clicking it opens the
|
|
49
|
+
* message; it has a dismiss button and goes by itself after a few seconds (see `ToastItem`). Motion is CSS-only and switched off
|
|
50
|
+
* by `prefers-reduced-motion` (`.rr-toast-in` in `app.css`).
|
|
51
|
+
*/
|
|
52
|
+
export default function NewMailToasts({ toasts, onDismiss, offerDesktop, onEnableDesktop, onDeclineDesktop }) {
|
|
53
|
+
const tabHidden = useTabHidden();
|
|
54
|
+
return (_jsx("div", { role: "status", "aria-live": "polite", "aria-label": "New mail", "data-testid": "new-mail-toasts", className: "pointer-events-none fixed right-4 top-4 z-50 flex w-[22rem] max-w-[calc(100vw-2rem)] flex-col gap-2", children: toasts.map((toast, index) => (_jsx(ToastItem, { toast: toast, tabHidden: tabHidden, onDismiss: onDismiss, offer: offerDesktop && index === 0 ? { onEnable: onEnableDesktop, onDecline: onDeclineDesktop } : undefined }, toast.uid))) }));
|
|
55
|
+
}
|