@rapidmx/web-client 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/settings/DomainDnsSetup.tsx +2 -1
- 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/KeyEnrollmentGate.tsx +398 -389
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
- package/apps/shared/components/layout/UserMenu.tsx +303 -185
- package/apps/shared/components/mail/ConversationList.tsx +269 -262
- package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
- package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
- package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1387
- 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 -1736
- 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/composePerf.ts +46 -0
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
- package/apps/shared/components/mail/layout/MailShell.tsx +476 -472
- 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/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 -224
- 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 -1917
- 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/settings/DomainDnsSetup.js +1 -1
- 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/KeyEnrollmentGate.js +10 -9
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
- package/dist/apps/shared/components/layout/UserMenu.d.ts +19 -3
- package/dist/apps/shared/components/layout/UserMenu.js +52 -5
- package/dist/apps/shared/components/mail/ConversationList.js +4 -11
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +52 -27
- 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/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 +83 -34
- 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 +98 -22
- 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/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 +12 -4
- package/dist/apps/shared/components/mail/layout/MailShell.js +76 -96
- 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/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 +13 -6
- package/dist/apps/shared/mail/useMailLiveUpdates.js +28 -26
- 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 +301 -35
- 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
|
@@ -11,6 +11,7 @@ import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.j
|
|
|
11
11
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
12
12
|
import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
13
13
|
import AppShell from "../../layout/AppShell.js";
|
|
14
|
+
import { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
|
|
14
15
|
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
15
16
|
const ContactsShellContext = createContext({ mailboxes: [] });
|
|
16
17
|
/** Reads the mailbox/folder Contacts is currently showing, as resolved by the enclosing `ContactsShell`. */
|
|
@@ -24,7 +25,7 @@ export function useContactsShell() {
|
|
|
24
25
|
* sidebar is therefore just a mailbox switcher (when the caller has more than one); the actual contact
|
|
25
26
|
* list/detail UI is `apps/www/contacts/index.tsx`'s own content, rendered as `children` here.
|
|
26
27
|
*/
|
|
27
|
-
export default function ContactsShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }) {
|
|
28
|
+
export default function ContactsShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }) {
|
|
28
29
|
const [status, setStatus] = useState("checking");
|
|
29
30
|
const [error, setError] = useState(null);
|
|
30
31
|
const [mailboxes, setMailboxes] = useState([]);
|
|
@@ -32,9 +33,18 @@ export default function ContactsShell({ userUid, authServerUrl, impersonating, i
|
|
|
32
33
|
const [folderError, setFolderError] = useState(null);
|
|
33
34
|
const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
|
|
34
35
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
36
|
+
// Read from the router's location, so a mailbox change (a link, or `navigate()`) takes effect without a page load - and in an
|
|
37
|
+
// effect, not during render, so the server render and the hydrating render agree.
|
|
38
|
+
const search = useLocationSearch();
|
|
39
|
+
const navigate = useNavigate();
|
|
35
40
|
useEffect(() => {
|
|
36
|
-
setRequestedMailboxUid(new URLSearchParams(
|
|
37
|
-
}, []);
|
|
41
|
+
setRequestedMailboxUid(new URLSearchParams(search).get("mailboxUid"));
|
|
42
|
+
}, [search]);
|
|
43
|
+
// Choosing a folder (or mailbox) changes the URL without a page load now, so the drawer that held the choice - which used to go
|
|
44
|
+
// with the page - is closed here.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
setDrawerOpen(false);
|
|
47
|
+
}, [search]);
|
|
38
48
|
useEffect(() => {
|
|
39
49
|
if (!userUid) {
|
|
40
50
|
return;
|
|
@@ -85,9 +95,9 @@ export default function ContactsShell({ userUid, authServerUrl, impersonating, i
|
|
|
85
95
|
// `id`/its `<label>`'s `htmlFor` need a distinct value per instance to avoid a duplicate-id
|
|
86
96
|
// label-association break.
|
|
87
97
|
const sidebarContent = (idPrefix) => (_jsxs(_Fragment, { children: [mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold uppercase tracking-wide text-text-muted mb-1", htmlFor: `${idPrefix}-contacts-mailbox-switcher`, children: "Mailbox" }), _jsx("select", { id: `${idPrefix}-contacts-mailbox-switcher`, className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: mailboxUid, onChange: (e) => {
|
|
88
|
-
|
|
98
|
+
navigate(`/contacts?mailboxUid=${encodeURIComponent(e.target.value)}`);
|
|
89
99
|
}, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), folderError && _jsx(Alert, { children: folderError })] }));
|
|
90
100
|
inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Mailbox", children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [(mailboxes.length > 1 || folderError) && (_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open mailbox switcher", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) })), _jsx(ContactsShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
91
101
|
}
|
|
92
|
-
return (_jsx(AppShell, { active: "contacts", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, pluginNav: pluginNav, children: inner }));
|
|
102
|
+
return (_jsx(AppShell, { active: "contacts", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner }));
|
|
93
103
|
}
|
|
@@ -27,8 +27,13 @@ export interface AppShellProps extends PluginNavProps {
|
|
|
27
27
|
impersonating?: boolean;
|
|
28
28
|
/** Where `mailApi.ts`'s `stopImpersonating()` should call — see `MailShell`'s original doc comment. */
|
|
29
29
|
impersonationBaseUrl?: string;
|
|
30
|
-
/** `true` when the caller's JWT carries a trusted role — shows an "Admin" item in the user menu below.
|
|
30
|
+
/** `true` when the caller's JWT carries a trusted role — shows an "Admin Console" item in the user menu below. A token
|
|
31
|
+
* only carries one once elevated, so a real administrator with an ordinary session is `false` here; the user menu
|
|
32
|
+
* asks auth-server about them separately (see `UserMenu`'s `detectAdmin`). */
|
|
31
33
|
trusted?: boolean;
|
|
34
|
+
/** The role names the server treats as trusted (its `trusted_roles` config, injected via the route's `fetchProps`) -
|
|
35
|
+
* what that lookup looks for. Absent means the server's own default, `["admin"]`. */
|
|
36
|
+
trustedRoles?: string[];
|
|
32
37
|
}
|
|
33
38
|
export interface AppDef {
|
|
34
39
|
id: AppShellApp;
|
|
@@ -39,11 +44,31 @@ export interface AppDef {
|
|
|
39
44
|
export declare const APPS: AppDef[];
|
|
40
45
|
/** `APPS` followed by the plugins' `appRail` items (generic icon), core ids winning - see `mergePluginNavItems`. */
|
|
41
46
|
export declare function appRailItems(pluginNav?: PluginNav): NavItem[];
|
|
47
|
+
/** What only the persistent frame (`AppRouter`) passes to the chrome it keeps mounted. */
|
|
48
|
+
export interface AppChromeProps extends AppShellProps {
|
|
49
|
+
/** Changes whenever the router shows a different page: the chrome then moves focus to the content, scrolls to its top,
|
|
50
|
+
* sets the document title and announces the page. Absent outside the router, when nothing does. */
|
|
51
|
+
routeKey?: string;
|
|
52
|
+
/** The router is loading the next page's code - the content is `aria-busy`. */
|
|
53
|
+
busy?: boolean;
|
|
54
|
+
/** A screen that takes over the window (`FrameTakeover`) is showing: the rail, header, banner and footer are hidden - not
|
|
55
|
+
* unmounted, so what is inside them (and the page in the content region) keeps its state. */
|
|
56
|
+
hideChrome?: boolean;
|
|
57
|
+
}
|
|
42
58
|
/**
|
|
43
59
|
* The persistent chrome shared by every webmail app (Mail, Calendar, Contacts, Tasks): a left icon rail for
|
|
44
|
-
* switching apps
|
|
45
|
-
* client-side router), a header with the current app's name and `UserMenu`, and the impersonation banner.
|
|
60
|
+
* switching apps, a header with the current app's name and `UserMenu`, and the impersonation banner.
|
|
46
61
|
* Each app's own shell (e.g. `MailShell`) renders its own contextual sidebar + content as `children`, inside
|
|
47
62
|
* the area to the right of the icon rail and below the header.
|
|
63
|
+
*
|
|
64
|
+
* Rendered by `AppRouter`, once, for the life of the page, so that moving between apps replaces only `children` - see
|
|
65
|
+
* `AppShell` below for what a page's own shell renders instead.
|
|
66
|
+
*/
|
|
67
|
+
export declare function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, routeKey, busy, hideChrome, children, }: PropsWithChildren<AppChromeProps>): React.JSX.Element;
|
|
68
|
+
/**
|
|
69
|
+
* What a page's own shell (`MailShell`, `CalendarShell`, ...) renders around its content. Outside the client-side router it
|
|
70
|
+
* is the whole `AppChrome`, as it always was. Inside it (`AppRouter`) the chrome is already mounted above the page - and stays
|
|
71
|
+
* mounted as the page is replaced - so this is only its children; everything it would have been given comes from the router
|
|
72
|
+
* frame instead (the props are the same for every page, and `active` is the route's).
|
|
48
73
|
*/
|
|
49
|
-
export default function AppShell(
|
|
74
|
+
export default function AppShell(props: PropsWithChildren<AppShellProps>): React.JSX.Element;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
///////////////////////////////////////////////////////////////////////////////
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
@@ -22,6 +22,16 @@ import { authApiFetch } from "@rapidmx/react-shared/util/api.js";
|
|
|
22
22
|
import { destroyUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
23
23
|
import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
|
|
24
24
|
import { mergePluginNavItems } from "../../plugins/pluginNav.js";
|
|
25
|
+
import { useInAppFrame } from "../../navigation/frameContext.js";
|
|
26
|
+
import { APP_HREFS } from "../../navigation/appHrefs.js";
|
|
27
|
+
import { useNavigate } from "../../navigation/routerContext.js";
|
|
28
|
+
import { GlobalShortcuts } from "../../keyboard/GlobalShortcuts.js";
|
|
29
|
+
import { ShortcutProvider } from "../../keyboard/ShortcutProvider.js";
|
|
30
|
+
import ShortcutsDialog from "../../keyboard/ShortcutsDialog.js";
|
|
31
|
+
import { inboxUnreadTotal } from "../../mail/folderCounts.js";
|
|
32
|
+
import { MailConnectionContext, useMailConnection } from "../../mail/useMailConnection.js";
|
|
33
|
+
import { useUnreadTitle } from "../../mail/useUnreadTitle.js";
|
|
34
|
+
import NewMailToasts from "../mail/NewMailToasts.js";
|
|
25
35
|
/** How long sign-out waits for auth-server's logout before navigating anyway. */
|
|
26
36
|
export const LOGOUT_TIMEOUT_MS = 3000;
|
|
27
37
|
/**
|
|
@@ -46,10 +56,10 @@ async function logOutOfAuthServer(authServerUrl) {
|
|
|
46
56
|
}
|
|
47
57
|
}
|
|
48
58
|
export const APPS = [
|
|
49
|
-
{ id: "mail", href:
|
|
50
|
-
{ id: "calendar", href:
|
|
51
|
-
{ id: "contacts", href:
|
|
52
|
-
{ id: "tasks", href:
|
|
59
|
+
{ id: "mail", href: APP_HREFS.mail, label: "Mail", icon: HiOutlineEnvelope },
|
|
60
|
+
{ id: "calendar", href: APP_HREFS.calendar, label: "Calendar", icon: HiOutlineCalendarDays },
|
|
61
|
+
{ id: "contacts", href: APP_HREFS.contacts, label: "Contacts", icon: HiOutlineUsers },
|
|
62
|
+
{ id: "tasks", href: APP_HREFS.tasks, label: "Tasks", icon: HiOutlineClipboardDocumentList },
|
|
53
63
|
];
|
|
54
64
|
/** Ids plugin `appRail` items can't take besides `APPS`' own - `"settings"` has no rail icon but is still a
|
|
55
65
|
* core `active` value. */
|
|
@@ -60,15 +70,26 @@ export function appRailItems(pluginNav) {
|
|
|
60
70
|
}
|
|
61
71
|
/**
|
|
62
72
|
* The persistent chrome shared by every webmail app (Mail, Calendar, Contacts, Tasks): a left icon rail for
|
|
63
|
-
* switching apps
|
|
64
|
-
* client-side router), a header with the current app's name and `UserMenu`, and the impersonation banner.
|
|
73
|
+
* switching apps, a header with the current app's name and `UserMenu`, and the impersonation banner.
|
|
65
74
|
* Each app's own shell (e.g. `MailShell`) renders its own contextual sidebar + content as `children`, inside
|
|
66
75
|
* the area to the right of the icon rail and below the header.
|
|
76
|
+
*
|
|
77
|
+
* Rendered by `AppRouter`, once, for the life of the page, so that moving between apps replaces only `children` - see
|
|
78
|
+
* `AppShell` below for what a page's own shell renders instead.
|
|
67
79
|
*/
|
|
68
|
-
export
|
|
80
|
+
export function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, routeKey, busy, hideChrome, children, }) {
|
|
69
81
|
const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
|
|
82
|
+
// The keyboard shortcuts dialog: opened by `?`/Ctrl+/ (`GlobalShortcuts`) and by the account menu's item.
|
|
83
|
+
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
|
70
84
|
const signingOutRef = useRef(false);
|
|
71
85
|
const { branding, iconSrc } = useBranding();
|
|
86
|
+
// The mailboxes, their folders and the one push connection live here, in the frame that stays mounted as the router swaps pages - so
|
|
87
|
+
// new-mail pop-ups, the folder counters and the tab title's unread count work in Calendar, Contacts, Tasks and Settings too, and moving
|
|
88
|
+
// between apps never opens a second socket (the server allows ten per user). `MailShell` reads them from `MailConnectionContext`. Outside
|
|
89
|
+
// the router (`AppShell` rendered as a page's own chrome: tests, plugin pages) it stays off, and a Mail shell owns its connection itself.
|
|
90
|
+
const inFrame = useInAppFrame();
|
|
91
|
+
const navigate = useNavigate();
|
|
92
|
+
const mail = useMailConnection({ userUid, enabled: inFrame, open: navigate });
|
|
72
93
|
useRedirectIfUnauthenticated(userUid, authServerUrl);
|
|
73
94
|
// Mounted here, not scoped to Mail/Settings (the only shells that actually read unlocked keys),
|
|
74
95
|
// specifically so activity in *any* app resets the idle clock - see that hook's own doc comment.
|
|
@@ -151,16 +172,54 @@ export default function AppShell({ active, userUid, authServerUrl, impersonating
|
|
|
151
172
|
window.location.href = "/admin";
|
|
152
173
|
}
|
|
153
174
|
}
|
|
175
|
+
// What the persistent frame (`AppRouter`) needs on top of a page that never changes: the document title follows the page, and
|
|
176
|
+
// after the router replaces the page the keyboard focus, the scroll position and a screen reader are told about it - a
|
|
177
|
+
// page load did all of that by itself. Nothing here runs for a page shown outside the router (`routeKey` is undefined).
|
|
178
|
+
const contentRef = useRef(null);
|
|
179
|
+
const [announcement, setAnnouncement] = useState("");
|
|
180
|
+
const firstRouteKeyRef = useRef(routeKey);
|
|
181
|
+
const pageLabel = active === "settings" ? "Settings" : appRailItems(pluginNav).find((app) => app.id === active)?.label;
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (routeKey !== undefined) {
|
|
184
|
+
const brand = branding?.title || branding?.companyName || "RapidMX";
|
|
185
|
+
document.title = pageLabel ? `${brand}: ${pageLabel}` : brand;
|
|
186
|
+
}
|
|
187
|
+
}, [routeKey, pageLabel, branding]);
|
|
188
|
+
// After the title effect above, so a page change (which rewrites the title) is followed by the unread count going back in front of it.
|
|
189
|
+
useUnreadTitle(inboxUnreadTotal(mail.mailboxFolders, mail.folderCounts.counts), {
|
|
190
|
+
enabled: inFrame,
|
|
191
|
+
resetKey: `${routeKey}|${pageLabel}|${branding?.title}|${branding?.companyName}`,
|
|
192
|
+
});
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (routeKey === undefined || routeKey === firstRouteKeyRef.current) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
setAnnouncement(pageLabel ?? "");
|
|
198
|
+
contentRef.current?.focus({ preventScroll: true });
|
|
199
|
+
window.scrollTo(0, 0);
|
|
200
|
+
}, [routeKey]);
|
|
154
201
|
if (!userUid) {
|
|
155
202
|
return _jsx("div", { className: "min-h-screen" });
|
|
156
203
|
}
|
|
157
204
|
const apps = appRailItems(pluginNav);
|
|
158
205
|
// The header title: "settings" has no rail item, everything else is labelled by its own rail item.
|
|
159
206
|
const activeLabel = active === "settings" ? "Settings" : apps.find((app) => app.id === active)?.label;
|
|
160
|
-
return (_jsx(UnlockPromptProvider, { children: _jsx(ComposeProvider, { userUid: userUid, trusted: !!trusted && !impersonating, children: _jsxs("div", { className: "min-h-screen flex flex-col bg-surface-alt", children: [_jsx(BrandingHeader, { branding: branding }), impersonating && (_jsxs("div", { className: "h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4", children: [_jsxs("span", { children: ["You are viewing as ", _jsx("strong", { children: userUid }), "."] }), _jsx("button", { type: "button", onClick: handleStopImpersonating, disabled: stoppingImpersonation, className: "underline hover:no-underline disabled:opacity-60", children: stoppingImpersonation ? "Returning to admin…" : "Return to admin" })] })), _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Apps", className: "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1", children: [_jsx("img", { src: iconSrc, width: "96", height: "96", alt: "", className: "mb-3" }), apps.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
207
|
+
return (_jsx(ShortcutProvider, { children: _jsxs(MailConnectionContext.Provider, { value: inFrame ? mail : null, children: [_jsx(GlobalShortcuts, { authServerUrl: authServerUrl, onToggleHelp: () => setShortcutsOpen((open) => !open) }), _jsx(ShortcutsDialog, { open: shortcutsOpen, onClose: () => setShortcutsOpen(false) }), _jsx(UnlockPromptProvider, { children: _jsx(ComposeProvider, { userUid: userUid, trusted: !!trusted && !impersonating, children: _jsxs("div", { className: "min-h-screen flex flex-col bg-surface-alt", children: [!hideChrome && _jsx(BrandingHeader, { branding: branding }), impersonating && !hideChrome && (_jsxs("div", { className: "h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4", children: [_jsxs("span", { children: ["You are viewing as ", _jsx("strong", { children: userUid }), "."] }), _jsx("button", { type: "button", onClick: handleStopImpersonating, disabled: stoppingImpersonation, className: "underline hover:no-underline disabled:opacity-60", children: stoppingImpersonation ? "Returning to admin…" : "Return to admin" })] })), _jsxs("div", { className: "flex-1 flex min-h-0", children: [!hideChrome && (_jsxs("nav", { "aria-label": "Apps", className: "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1", children: [_jsx("img", { src: iconSrc, width: "96", height: "96", alt: "", className: "mb-3" }), apps.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
|
|
208
|
+
"w-10 h-10 flex items-center justify-center rounded-sm",
|
|
209
|
+
id === active
|
|
210
|
+
? "bg-primary/10 text-primary-dark"
|
|
211
|
+
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
212
|
+
].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] })), !hideChrome && _jsx(BottomTabBar, { apps: apps, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [!hideChrome && (_jsxs("header", { className: "h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeLabel }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut,
|
|
213
|
+
// An impersonating administrator acts as the impersonated user, so the console link is hidden then.
|
|
214
|
+
showAdminLink: !!trusted && !impersonating, detectAdmin: !trusted && !impersonating, trustedRoles: trustedRoles, showSettingsLink: true, showNotificationSettings: true, onShowShortcuts: () => setShortcutsOpen(true) })] })), _jsx("div", { ref: contentRef, id: "app-content", tabIndex: -1, "aria-busy": busy || undefined, className: ["flex-1 flex min-h-0 outline-none", hideChrome ? "" : "pb-14 md:pb-0"].join(" "), children: children })] })] }), routeKey !== undefined && (_jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement })), !hideChrome && _jsx(BrandingFooter, { branding: branding })] }) }) }), inFrame && (_jsx(NewMailToasts, { toasts: mail.notifications.toasts, onDismiss: mail.notifications.dismiss, offerDesktop: mail.notifications.offerDesktop, onEnableDesktop: () => void mail.notifications.enableDesktop(), onDeclineDesktop: mail.notifications.declineDesktop }))] }) }));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* What a page's own shell (`MailShell`, `CalendarShell`, ...) renders around its content. Outside the client-side router it
|
|
218
|
+
* is the whole `AppChrome`, as it always was. Inside it (`AppRouter`) the chrome is already mounted above the page - and stays
|
|
219
|
+
* mounted as the page is replaced - so this is only its children; everything it would have been given comes from the router
|
|
220
|
+
* frame instead (the props are the same for every page, and `active` is the route's).
|
|
221
|
+
*/
|
|
222
|
+
export default function AppShell(props) {
|
|
223
|
+
const inFrame = useInAppFrame();
|
|
224
|
+
return inFrame ? _jsx(_Fragment, { children: props.children }) : _jsx(AppChrome, { ...props });
|
|
166
225
|
}
|
|
@@ -10,6 +10,7 @@ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
|
10
10
|
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
11
11
|
import { enrollKey, getKeyVault, VaultAlreadyInitializedError } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
12
12
|
import { UnopenableKeysNotice, unlockErrorMessage } from "./UnlockPromptProvider.js";
|
|
13
|
+
import { FrameTakeover } from "../../navigation/frameContext.js";
|
|
13
14
|
import { RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock } from "./RecoveryCodeUnlock.js";
|
|
14
15
|
import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, getUnlockedKeys, unlockWithPassword, } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
15
16
|
import { buildAad, generateMasterKey, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
|
|
@@ -170,22 +171,22 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
|
|
|
170
171
|
}
|
|
171
172
|
if ((status === "unlock" || status === "unlocking") && blocking) {
|
|
172
173
|
const unlocking = status === "unlocking";
|
|
173
|
-
return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Unlock your mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: unlockMode === "password"
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
174
|
+
return (_jsx(FrameTakeover, { children: _jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Unlock your mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: unlockMode === "password"
|
|
175
|
+
? "Enter your encryption password to unlock signing and reading protected mail this session."
|
|
176
|
+
: "Enter one of your recovery codes to unlock signing and reading protected mail this session. Each code works once." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleUnlock, children: [unlockMode === "password" ? (_jsx(FormField, { label: "Encryption password", htmlFor: "key-unlock-password", children: _jsx("input", { id: "key-unlock-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: password, onChange: (e) => setPassword(e.target.value), disabled: unlocking, autoComplete: "current-password" }) })) : (_jsx(FormField, { label: "Recovery code", htmlFor: "key-unlock-recovery-code", children: _jsx("input", { id: "key-unlock-recovery-code", type: "text", className: "w-full text-sm font-mono border border-border rounded-sm py-1.5 px-2 bg-surface", value: recoveryCode, onChange: (e) => setRecoveryCode(e.target.value), disabled: unlocking, autoComplete: "off", autoCapitalize: "characters", spellCheck: false }) })), _jsx(Button, { type: "submit", loading: unlocking, disabled: unlocking, children: "Unlock" })] }), _jsx("div", { className: "mt-3", children: _jsx(UnlockModeToggle, { mode: unlockMode, disabled: unlocking, onChange: (next) => {
|
|
177
|
+
setUnlockMode(next);
|
|
178
|
+
setError(null);
|
|
179
|
+
} }) })] }) }) }));
|
|
179
180
|
}
|
|
180
181
|
if (status === "setup_password" || status === "enrolling") {
|
|
181
182
|
const enrolling = status === "enrolling";
|
|
182
|
-
return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Protect your mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Choose a password to protect your encryption keys. This is separate from your sign-in password and is never sent to the server." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSetPassword, children: [_jsx(FormField, { label: "Encryption password", htmlFor: "key-enrollment-password", children: _jsx("input", { id: "key-enrollment-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: password, onChange: (e) => setPassword(e.target.value), disabled: enrolling, autoComplete: "new-password" }) }), _jsx(FormField, { label: "Confirm password", htmlFor: "key-enrollment-password-confirm", children: _jsx("input", { id: "key-enrollment-password-confirm", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), disabled: enrolling, autoComplete: "new-password" }) }), _jsx(Button, { type: "submit", loading: enrolling, disabled: enrolling, children: "Continue" })] })] }) }));
|
|
183
|
+
return (_jsx(FrameTakeover, { children: _jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Protect your mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Choose a password to protect your encryption keys. This is separate from your sign-in password and is never sent to the server." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSetPassword, children: [_jsx(FormField, { label: "Encryption password", htmlFor: "key-enrollment-password", children: _jsx("input", { id: "key-enrollment-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: password, onChange: (e) => setPassword(e.target.value), disabled: enrolling, autoComplete: "new-password" }) }), _jsx(FormField, { label: "Confirm password", htmlFor: "key-enrollment-password-confirm", children: _jsx("input", { id: "key-enrollment-password-confirm", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), disabled: enrolling, autoComplete: "new-password" }) }), _jsx(Button, { type: "submit", loading: enrolling, disabled: enrolling, children: "Continue" })] })] }) }) }));
|
|
183
184
|
}
|
|
184
185
|
if (status === "already_set_up") {
|
|
185
|
-
return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Encryption is already set up" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "This mailbox\u2019s encryption was set up in another tab or on another device while this page was open, so nothing was changed here. Reload the page and unlock with the password chosen there." }), _jsx(Button, { type: "button", onClick: () => window.location.reload(), children: "Reload" })] }) }));
|
|
186
|
+
return (_jsx(FrameTakeover, { children: _jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Encryption is already set up" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "This mailbox\u2019s encryption was set up in another tab or on another device while this page was open, so nothing was changed here. Reload the page and unlock with the password chosen there." }), _jsx(Button, { type: "button", onClick: () => window.location.reload(), children: "Reload" })] }) }) }));
|
|
186
187
|
}
|
|
187
188
|
if (status === "show_recovery_codes") {
|
|
188
|
-
return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Save your recovery codes" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "If you lose your password, these codes are the only way to recover your encrypted mail. Each code can be used once. Store them somewhere safe \u2014 they will not be shown again." }), _jsx("ul", { className: "grid grid-cols-2 gap-2 mb-3 font-mono text-sm", children: recoveryCodes.map((code) => (_jsx("li", { className: "bg-surface-alt rounded-sm py-1.5 px-2 text-center", children: code }, code))) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mb-5", onClick: handleCopyCodes, children: codesCopied ? "Copied" : "Copy codes to clipboard" }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: codesSaved, onChange: (e) => setCodesSaved(e.target.checked) }), "I have saved these recovery codes in a safe place."] }), _jsx(Button, { type: "button", disabled: !codesSaved, onClick: () => setStatus("ready"), children: "Continue" })] }) }));
|
|
189
|
+
return (_jsx(FrameTakeover, { children: _jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Save your recovery codes" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "If you lose your password, these codes are the only way to recover your encrypted mail. Each code can be used once. Store them somewhere safe \u2014 they will not be shown again." }), _jsx("ul", { className: "grid grid-cols-2 gap-2 mb-3 font-mono text-sm", children: recoveryCodes.map((code) => (_jsx("li", { className: "bg-surface-alt rounded-sm py-1.5 px-2 text-center", children: code }, code))) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mb-5", onClick: handleCopyCodes, children: codesCopied ? "Copied" : "Copy codes to clipboard" }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: codesSaved, onChange: (e) => setCodesSaved(e.target.checked) }), "I have saved these recovery codes in a safe place."] }), _jsx(Button, { type: "button", disabled: !codesSaved, onClick: () => setStatus("ready"), children: "Continue" })] }) }) }));
|
|
189
190
|
}
|
|
190
191
|
return (_jsxs(_Fragment, { children: [children, unopenableKeys && _jsx(UnopenableKeysNotice, { fingerprints: unopenableKeys, onDismiss: () => setUnopenableKeys(null) }), followUp && _jsx(RecoveryFollowUpModal, { followUp: followUp, onDone: () => setFollowUp(null) })] }));
|
|
191
192
|
}
|
|
@@ -8,6 +8,7 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
|
8
8
|
import { autoProvisionMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
9
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
10
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
|
+
import { FrameTakeover } from "../../navigation/frameContext.js";
|
|
11
12
|
/** What the "Retry" screen says: the server couldn't read its provisioning policy (503) ... */
|
|
12
13
|
const RETRY_POLICY_TEXT = "We couldn\u2019t check whether a mailbox can be set up for you. Please try again.";
|
|
13
14
|
/** ... or couldn't reach the identity service (auth-server) it looks the caller's username up in (502). */
|
|
@@ -114,5 +115,6 @@ export default function MailboxProvisioning() {
|
|
|
114
115
|
else {
|
|
115
116
|
content = (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-lg font-bold uppercase tracking-wide", children: "No mailbox available" }), reason && _jsx("p", { className: "text-sm", children: reason }), _jsx("p", { className: "text-sm text-text-muted", children: "Ask an administrator to create one for you." })] }));
|
|
116
117
|
}
|
|
117
|
-
|
|
118
|
+
// Inside the client-side router's persistent frame this hides the frame's rail and header, as replacing the whole shell used to.
|
|
119
|
+
return (_jsx(FrameTakeover, { children: _jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8 flex flex-col items-center gap-4 text-center", children: [_jsx("img", { src: "/images/wordmark.png", height: "128", alt: "" }), content] }) }) }));
|
|
118
120
|
}
|
|
@@ -5,22 +5,38 @@ export interface UserMenuProps {
|
|
|
5
5
|
* the "Account" item links into, if configured. Without it the menu shows the bare uid and has no "Account" item. */
|
|
6
6
|
authServerUrl?: string;
|
|
7
7
|
onSignOut: () => void;
|
|
8
|
-
/** Shows an "Admin" item linking to `/admin`, above "Sign Out" — pass only for a trusted-role caller
|
|
8
|
+
/** Shows an "Admin Console" item linking to `/admin`, above "Sign Out" — pass only for a trusted-role caller
|
|
9
|
+
* (the JWT carries the role, i.e. an elevated session). See `detectAdmin` for everyone else. */
|
|
9
10
|
showAdminLink?: boolean;
|
|
11
|
+
/** Asks auth-server whether the caller holds a trusted role (`GET /api/users/me`, once per sitting - see
|
|
12
|
+
* `lookUpAdminAccess()`) and shows the "Admin Console" item if so. Needed because a token that isn't elevated
|
|
13
|
+
* carries no trusted role, so an administrator with an ordinary session is not `showAdminLink`. Does nothing
|
|
14
|
+
* without `authServerUrl`, and a failed lookup leaves the item hidden. Pass `false` while impersonating. */
|
|
15
|
+
detectAdmin?: boolean;
|
|
16
|
+
/** The role names the server treats as trusted (its `trusted_roles` config) - what `detectAdmin` looks for.
|
|
17
|
+
* Defaults to `["admin"]`, the server's own default. */
|
|
18
|
+
trustedRoles?: readonly string[];
|
|
10
19
|
/** Shows a "Settings" item, above "Admin"/"Sign Out" — an account-scoped surface (like "Admin"), not
|
|
11
20
|
* a content-scoped one, so it lives here rather than as a 5th `AppShell` rail icon. Links straight to
|
|
12
21
|
* `/settings/auto-reply`, the only settings section that exists today; repoint this at a real
|
|
13
22
|
* `/settings` landing page once a second section (Mail Filters, Signatures) makes one worth building. */
|
|
14
23
|
showSettingsLink?: boolean;
|
|
24
|
+
/** Shows the new-mail notification controls, above "Admin Console"/"Sign Out": a "New mail pop-ups" on/off switch and, while
|
|
25
|
+
* the browser hasn't been asked, "Turn on desktop notifications". Both are per browser (see `newMailNotifications.ts`).
|
|
26
|
+
* For the shells that announce new mail (Mail); the consoles have nothing to announce. */
|
|
27
|
+
showNotificationSettings?: boolean;
|
|
28
|
+
/** Shows a "Keyboard shortcuts" item that calls this - opening the help dialog (`?` and Ctrl+/ do too). For the shells that have the
|
|
29
|
+
* keyboard layer (`AppChrome`); the consoles don't offer it. */
|
|
30
|
+
onShowShortcuts?: () => void;
|
|
15
31
|
}
|
|
16
32
|
/**
|
|
17
33
|
* The top-right "who am I" menu shared by every shell (`AppShell`, `AdminShell`, `EscrowShell`): an avatar-button
|
|
18
34
|
* trigger that opens a dropdown showing the caller's avatar/name, an "Account" link to auth-server's account page,
|
|
19
|
-
* an optional "Settings" and "Admin" link, and "Sign Out". This service has no local user directory (see
|
|
35
|
+
* an optional "Settings" and "Admin Console" link, and "Sign Out". This service has no local user directory (see
|
|
20
36
|
* `.claude/NOTES.md`), so the name and avatar come from auth-server, and the displayed name (and the initials badge)
|
|
21
37
|
* falls back down a chain: the profile's name (`GET /api/profiles/me`), else the caller's username - their first
|
|
22
38
|
* verified `name` alias (`GET /api/aliases?type=name`, asked for only when the profile gave no name) - else the bare
|
|
23
39
|
* uid. Either lookup can fail (auth-server's CORS list not including this origin, or no profile document at all - a
|
|
24
40
|
* 404 for some accounts) and neither ever surfaces as an error. The avatar image comes from the profile alone.
|
|
25
41
|
*/
|
|
26
|
-
export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, showSettingsLink }: UserMenuProps): React.JSX.Element;
|
|
42
|
+
export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, detectAdmin, trustedRoles, showSettingsLink, showNotificationSettings, onShowShortcuts, }: UserMenuProps): React.JSX.Element;
|
|
@@ -4,7 +4,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
import { HiOutlineShieldCheck } from "react-icons/hi2";
|
|
7
8
|
import { formatProfileName, getMyProfile, getMyUsername, profileInitials } from "@rapidmx/react-shared/auth/profileApi.js";
|
|
9
|
+
import { accountUrlOf } from "../../auth/accountUrl.js";
|
|
10
|
+
import { DEFAULT_TRUSTED_ROLES, lookUpAdminAccess } from "../../auth/adminAccess.js";
|
|
11
|
+
import { ariaKeyShortcuts } from "../../keyboard/format.js";
|
|
12
|
+
import { SHORTCUTS } from "../../keyboard/keymap.js";
|
|
13
|
+
import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
|
|
14
|
+
import { SETTINGS_HREF } from "../../navigation/appHrefs.js";
|
|
15
|
+
import { desktopPermission, getNewMailPopupsEnabled, requestDesktopPermission, setDesktopOfferDismissed, setNewMailPopupsEnabled, } from "../../mail/newMailNotifications.js";
|
|
8
16
|
function Avatar({ profile, initials, large }) {
|
|
9
17
|
const sizeClass = large ? "w-10 h-10 text-base" : "w-8 h-8 text-sm";
|
|
10
18
|
if (profile?.avatar) {
|
|
@@ -15,15 +23,19 @@ function Avatar({ profile, initials, large }) {
|
|
|
15
23
|
/**
|
|
16
24
|
* The top-right "who am I" menu shared by every shell (`AppShell`, `AdminShell`, `EscrowShell`): an avatar-button
|
|
17
25
|
* trigger that opens a dropdown showing the caller's avatar/name, an "Account" link to auth-server's account page,
|
|
18
|
-
* an optional "Settings" and "Admin" link, and "Sign Out". This service has no local user directory (see
|
|
26
|
+
* an optional "Settings" and "Admin Console" link, and "Sign Out". This service has no local user directory (see
|
|
19
27
|
* `.claude/NOTES.md`), so the name and avatar come from auth-server, and the displayed name (and the initials badge)
|
|
20
28
|
* falls back down a chain: the profile's name (`GET /api/profiles/me`), else the caller's username - their first
|
|
21
29
|
* verified `name` alias (`GET /api/aliases?type=name`, asked for only when the profile gave no name) - else the bare
|
|
22
30
|
* uid. Either lookup can fail (auth-server's CORS list not including this origin, or no profile document at all - a
|
|
23
31
|
* 404 for some accounts) and neither ever surfaces as an error. The avatar image comes from the profile alone.
|
|
24
32
|
*/
|
|
25
|
-
export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, showSettingsLink }) {
|
|
33
|
+
export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, detectAdmin, trustedRoles = DEFAULT_TRUSTED_ROLES, showSettingsLink, showNotificationSettings, onShowShortcuts, }) {
|
|
34
|
+
const env = useKeyEnvironment();
|
|
26
35
|
const [open, setOpen] = useState(false);
|
|
36
|
+
const [detectedAdmin, setDetectedAdmin] = useState(false);
|
|
37
|
+
const [popups, setPopups] = useState(true);
|
|
38
|
+
const [permission, setPermission] = useState("unsupported");
|
|
27
39
|
const [profile, setProfile] = useState(undefined);
|
|
28
40
|
const [username, setUsername] = useState(undefined);
|
|
29
41
|
const containerRef = useRef(null);
|
|
@@ -56,6 +68,39 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
56
68
|
cancelled = true;
|
|
57
69
|
};
|
|
58
70
|
}, [authServerUrl]);
|
|
71
|
+
// Keyed on the joined names, not the array: a caller's fresh `["admin"]` each render must not re-ask.
|
|
72
|
+
const trustedRolesKey = JSON.stringify(trustedRoles);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!detectAdmin || !authServerUrl || showAdminLink) {
|
|
75
|
+
setDetectedAdmin(false);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
let cancelled = false;
|
|
79
|
+
void lookUpAdminAccess(authServerUrl, userUid, JSON.parse(trustedRolesKey)).then((admin) => {
|
|
80
|
+
if (!cancelled) {
|
|
81
|
+
setDetectedAdmin(admin === true);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
return () => {
|
|
85
|
+
cancelled = true;
|
|
86
|
+
};
|
|
87
|
+
}, [detectAdmin, authServerUrl, userUid, showAdminLink, trustedRolesKey]);
|
|
88
|
+
// Read fresh each time the menu opens - another tab may have changed either since - and only on the client.
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (open && showNotificationSettings) {
|
|
91
|
+
setPopups(getNewMailPopupsEnabled());
|
|
92
|
+
setPermission(desktopPermission());
|
|
93
|
+
}
|
|
94
|
+
}, [open, showNotificationSettings]);
|
|
95
|
+
function togglePopups() {
|
|
96
|
+
setNewMailPopupsEnabled(!popups);
|
|
97
|
+
setPopups(!popups);
|
|
98
|
+
}
|
|
99
|
+
async function enableDesktopNotifications() {
|
|
100
|
+
// Asked from this click, as browsers require; a "no" (or "yes") answers the first pop-up's offer as well.
|
|
101
|
+
setPermission(await requestDesktopPermission());
|
|
102
|
+
setDesktopOfferDismissed(true);
|
|
103
|
+
}
|
|
59
104
|
useEffect(() => {
|
|
60
105
|
if (!open) {
|
|
61
106
|
return;
|
|
@@ -79,7 +124,9 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
79
124
|
}, [open]);
|
|
80
125
|
const name = formatProfileName(profile) ?? username ?? userUid;
|
|
81
126
|
const initials = profileInitials(profile, userUid, username);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
127
|
+
const accountUrl = accountUrlOf(authServerUrl);
|
|
128
|
+
return (_jsxs("div", { className: "relative", ref: containerRef, children: [_jsxs("button", { type: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "true", "aria-expanded": open, "aria-label": "Account menu", className: "flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt", children: [_jsx(Avatar, { profile: profile, initials: initials }), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 20 20", fill: "none", className: `text-text-muted transition-transform ${open ? "rotate-180" : ""}`, "aria-hidden": "true", children: _jsx("path", { d: "M5 7.5L10 12.5L15 7.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open && (_jsxs("div", { role: "menu", className: "absolute right-0 top-full mt-2 w-60 bg-surface border border-border rounded-md shadow-modal py-1.5 z-10", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), accountUrl && (_jsx("a", { role: "menuitem", href: accountUrl, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Account" })), showSettingsLink && (_jsx("a", { role: "menuitem", href: SETTINGS_HREF, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Settings" })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": popups, type: "button", onClick: togglePopups, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx("span", { children: "New mail pop-ups" }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsx("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: "Turn on desktop notifications" })), onShowShortcuts && (_jsx("button", { role: "menuitem", type: "button", "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.global.help, env), onClick: () => {
|
|
129
|
+
setOpen(false);
|
|
130
|
+
onShowShortcuts();
|
|
131
|
+
}, className: "block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: "Keyboard shortcuts" })), (showAdminLink || detectedAdmin) && (_jsxs("a", { role: "menuitem", href: "/admin", className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineShieldCheck, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Admin Console"] })), _jsx("button", { role: "menuitem", type: "button", onClick: onSignOut, className: "block w-full text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Sign Out" })] }))] }));
|
|
85
132
|
}
|
|
@@ -9,6 +9,7 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
|
9
9
|
import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
10
10
|
import MailAddress from "./MailAddress.js";
|
|
11
11
|
import { listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
12
|
+
import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "./unreadStyle.js";
|
|
12
13
|
/** Every participant as `Name <address>`, for the tooltip and the accessible text of the "+N" a crowded row folds them into. */
|
|
13
14
|
function participantList(conversation) {
|
|
14
15
|
return conversation.participants.map((participant) => formatMailAddress(participant)).join(", ");
|
|
@@ -77,18 +78,10 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
|
|
|
77
78
|
const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
|
|
78
79
|
const panelId = `conversation-messages-${id}`;
|
|
79
80
|
const ticked = selectedConversationIds?.has(id) ?? false;
|
|
80
|
-
return (_jsxs("li", { children: [_jsxs("div", { className: [
|
|
81
|
-
"flex items-stretch border-b border-border",
|
|
82
|
-
conversation.latestMessageUid === selectedUid || ticked
|
|
83
|
-
? "bg-primary/10"
|
|
84
|
-
: "hover:bg-surface-alt",
|
|
85
|
-
].join(" "), children: [selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: "shrink-0 px-2 text-text-muted hover:text-text", children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("button", { type: "button", onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["flex-1 min-w-0 text-left pr-4 py-3", unread ? "font-semibold" : ""].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: conversation.participants[0] ?? conversation.latestFrom, className: "flex-1" }), conversation.participants.length > 1 && (_jsxs("span", { className: "text-xs text-text-muted shrink-0 font-normal", title: participantList(conversation), children: ["+", conversation.participants.length - 1, _jsxs("span", { className: "sr-only", children: [" more: ", participantList(conversation)] })] })), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: "text-sm truncate", children: conversation.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/10 text-primary-dark font-semibold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] })] }), _jsxs("ul", { id: panelId, hidden: !isExpanded, children: [errorsById[id] && (_jsx("li", { role: "alert", className: "pl-8 pr-4 py-2 text-xs text-danger border-b border-border", children: errorsById[id] })), loadingIds.has(id) && (_jsx("li", { className: "pl-8 pr-4 py-2 text-xs text-text-muted border-b border-border", children: "Loading messages\u2026" })), children?.map((fetched) => {
|
|
81
|
+
return (_jsxs("li", { children: [_jsxs("div", { "data-message-uid": conversation.latestMessageUid, "data-unread": unread ? "true" : undefined, className: rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: unread }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: ["shrink-0 px-2 text-text-muted hover:text-text", ROW_FOCUS_CLASS].join(" "), children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["flex-1 min-w-0 text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: unread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: conversation.participants[0] ?? conversation.latestFrom, className: ["flex-1", senderClass(unread)].join(" ") }), conversation.participants.length > 1 && (_jsxs("span", { className: "text-xs text-text-muted shrink-0 font-normal", title: participantList(conversation), children: ["+", conversation.participants.length - 1, _jsxs("span", { className: "sr-only", children: [" more: ", participantList(conversation)] })] })), _jsx("span", { className: ["text-xs shrink-0", dateClass(unread)].join(" "), children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: ["text-sm truncate", subjectClass(unread)].join(" "), children: conversation.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/15 text-primary-dark font-bold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] })] }), _jsxs("ul", { id: panelId, hidden: !isExpanded, children: [errorsById[id] && (_jsx("li", { role: "alert", className: "pl-8 pr-4 py-2 text-xs text-danger border-b border-border", children: errorsById[id] })), loadingIds.has(id) && (_jsx("li", { className: "pl-8 pr-4 py-2 text-xs text-text-muted border-b border-border", children: "Loading messages\u2026" })), children?.map((fetched) => {
|
|
86
82
|
const message = messageOverrides?.[fetched.uid] ?? fetched;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
message.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt",
|
|
90
|
-
message.flags.read ? "" : "font-semibold",
|
|
91
|
-
].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: message.bodyPreview }), message.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" })), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }) }, message.uid));
|
|
83
|
+
const messageUnread = isUnread(message);
|
|
84
|
+
return (_jsxs("li", { "data-message-uid": message.uid, "data-unread": messageUnread ? "true" : undefined, className: rowClass({ unread: messageUnread, selected: message.uid === selectedUid }), children: [_jsx(UnreadBar, { unread: messageUnread }), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, message.uid), className: ["w-full text-left pl-8 pr-4 py-2", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: messageUnread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(messageUnread) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(messageUnread)].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: message.bodyPreview }), message.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" })), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] })] }, message.uid));
|
|
92
85
|
})] })] }, id));
|
|
93
86
|
}) }));
|
|
94
87
|
}
|
|
@@ -41,6 +41,9 @@ export interface ConversationThreadPaneProps {
|
|
|
41
41
|
onLabelCreated?: (label: Label) => void;
|
|
42
42
|
/** A folder created from a message's own Move to prompt, for the folder sidebar to pick up. */
|
|
43
43
|
onFolderCreated?: (folder: Folder) => void;
|
|
44
|
+
/** Registers the keyboard shortcuts (Reply, Reply all, Forward, Archive, Move to) of the message that was opened - see `MessageDetailPane`'s
|
|
45
|
+
* `shortcuts`. The caller turns it off while it is doing something else with the keyboard (select mode). */
|
|
46
|
+
shortcuts?: boolean;
|
|
44
47
|
}
|
|
45
48
|
/**
|
|
46
49
|
* The reading pane for the conversation list: the whole thread, **newest at the top**, opened at the message
|
|
@@ -59,4 +62,4 @@ export interface ConversationThreadPaneProps {
|
|
|
59
62
|
* single-message pane, and each acts on the message it belongs to. A collapsed message mounts none of
|
|
60
63
|
* that - mounting a body iframe per message up front would be wasteful in a long thread.
|
|
61
64
|
*/
|
|
62
|
-
export default function ConversationThreadPane({ conversation, mailboxUid, selectedUid, folders, labels, onMessagePatched, onMessageRemoved, onLabelCreated, onFolderCreated, }: ConversationThreadPaneProps): React.JSX.Element;
|
|
65
|
+
export default function ConversationThreadPane({ conversation, mailboxUid, selectedUid, folders, labels, onMessagePatched, onMessageRemoved, onLabelCreated, onFolderCreated, shortcuts, }: ConversationThreadPaneProps): React.JSX.Element;
|