@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
|
@@ -3,19 +3,26 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { createContext,
|
|
6
|
+
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
7
7
|
import { HiOutlineBars3 } from "react-icons/hi2";
|
|
8
|
-
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
8
|
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
10
|
-
import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
11
9
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
12
10
|
import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
13
11
|
import AppShell from "../../layout/AppShell.js";
|
|
12
|
+
import { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
|
|
14
13
|
import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
|
|
15
14
|
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
16
|
-
import { useCompose } from "../compose/ComposeContext.js";
|
|
15
|
+
import { prefetchComposeWindow, useCompose } from "../compose/ComposeContext.js";
|
|
17
16
|
import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
|
|
18
|
-
import { NO_LIVE_UPDATES
|
|
17
|
+
import { NO_LIVE_UPDATES } from "../../../mail/useMailLiveUpdates.js";
|
|
18
|
+
import { FOLDER_ORDER, MAILBOX_LIST_LIMIT, MailConnectionContext, useMailConnection } from "../../../mail/useMailConnection.js";
|
|
19
|
+
import { badgeFor, badgeLabel, countOfFolder, inboxUnreadTotal, } from "../../../mail/folderCounts.js";
|
|
20
|
+
import { useUnreadTitle } from "../../../mail/useUnreadTitle.js";
|
|
21
|
+
import NewMailToasts from "../NewMailToasts.js";
|
|
22
|
+
import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
|
|
23
|
+
import { SHORTCUTS } from "../../../keyboard/keymap.js";
|
|
24
|
+
import { useKeyEnvironment } from "../../../keyboard/ShortcutProvider.js";
|
|
25
|
+
import { useShortcut } from "../../../keyboard/useShortcut.js";
|
|
19
26
|
/** The well-known mail folder types an "aggregate" pseudo-folder can merge across every accessible
|
|
20
27
|
* mailbox - a fixed, small set: `outbox` is deliberately excluded (transient per-mailbox send-queue
|
|
21
28
|
* state, not a "merge across mailboxes" concept), and there's no aggregate concept for `calendar`/
|
|
@@ -25,11 +32,13 @@ export const AGGREGATE_FOLDER_TYPES = ["inbox", "sent_items", "drafts", "deleted
|
|
|
25
32
|
function isAggregateFolderType(value) {
|
|
26
33
|
return !!value && AGGREGATE_FOLDER_TYPES.includes(value);
|
|
27
34
|
}
|
|
35
|
+
const NO_TRACKER = { settle: () => undefined, revert: () => undefined };
|
|
28
36
|
const MailShellContext = createContext({
|
|
29
37
|
mailboxes: [],
|
|
30
38
|
mailboxFolders: [],
|
|
31
39
|
onFolderCreated: () => undefined,
|
|
32
40
|
live: NO_LIVE_UPDATES,
|
|
41
|
+
trackMessageChange: () => NO_TRACKER,
|
|
33
42
|
});
|
|
34
43
|
/** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
|
|
35
44
|
export function useMailShell() {
|
|
@@ -44,15 +53,6 @@ const FOLDER_LABELS = {
|
|
|
44
53
|
archive: "Archive",
|
|
45
54
|
deleted_items: "Deleted Items",
|
|
46
55
|
};
|
|
47
|
-
/** Well-known folders sort first, in Gmail/Outlook's conventional order; anything else (incl. `user`) sorts after, alphabetically. */
|
|
48
|
-
const FOLDER_ORDER = ["inbox", "drafts", "outbox", "sent_items", "junk", "archive", "deleted_items"];
|
|
49
|
-
/**
|
|
50
|
-
* A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see
|
|
51
|
-
* `CalendarShell`/`ContactsShell`/`TasksShell`), not Mail — `listFolders()` returns every well-known
|
|
52
|
-
* folder for the mailbox regardless of which app owns it, so Mail's own folder tree must filter down
|
|
53
|
-
* to just the mail ones itself, or those other apps' folders leak into this sidebar.
|
|
54
|
-
*/
|
|
55
|
-
const MAIL_FOLDER_TYPES = new Set([...FOLDER_ORDER, "user"]);
|
|
56
56
|
function folderSortKey(folder) {
|
|
57
57
|
const idx = FOLDER_ORDER.indexOf(folder.type);
|
|
58
58
|
return idx === -1 ? FOLDER_ORDER.length : idx;
|
|
@@ -60,18 +60,23 @@ function folderSortKey(folder) {
|
|
|
60
60
|
function sortedFoldersOf(folders) {
|
|
61
61
|
return [...folders].sort((a, b) => folderSortKey(a) - folderSortKey(b) || a.name.localeCompare(b.name));
|
|
62
62
|
}
|
|
63
|
-
/**
|
|
64
|
-
function
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
function aggregateUnreadCount(mailboxFolders, type, unreadCounts) {
|
|
68
|
-
return mailboxFolders.reduce((total, mf) => {
|
|
63
|
+
/** The badge an "All Mailboxes" entry shows: the folders of that type, summed across every mailbox, under the same rules as a single folder's. */
|
|
64
|
+
function aggregateBadge(mailboxFolders, type, counts) {
|
|
65
|
+
const sum = mailboxFolders.reduce((total, mf) => {
|
|
69
66
|
const folder = mf.folders.find((f) => f.type === type);
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
const count = folder ? countOfFolder(folder, counts) : { unread: 0, total: 0 };
|
|
68
|
+
return { unread: total.unread + count.unread, total: total.total + count.total };
|
|
69
|
+
}, { unread: 0, total: 0 });
|
|
70
|
+
return badgeFor(type, sum);
|
|
72
71
|
}
|
|
73
|
-
/**
|
|
74
|
-
|
|
72
|
+
/** A folder's badge: an accent pill with the unread count, or - for the folders that show how many they hold - plain muted text. */
|
|
73
|
+
function FolderBadgeChip({ badge }) {
|
|
74
|
+
return (_jsxs("span", { className: [
|
|
75
|
+
"text-xs rounded-pill py-0.5 px-1.5",
|
|
76
|
+
badge.kind === "unread" ? "font-bold bg-primary/15 text-primary-dark" : "font-medium text-text-muted",
|
|
77
|
+
].join(" "), children: [_jsx("span", { "aria-hidden": "true", children: badge.value }), _jsxs("span", { className: "sr-only", children: [" ", badgeLabel(badge)] })] }));
|
|
78
|
+
}
|
|
79
|
+
export { MAILBOX_LIST_LIMIT };
|
|
75
80
|
/**
|
|
76
81
|
* A separate component (not inlined into `MailShell`'s own render) so `useCompose()` resolves against
|
|
77
82
|
* `ComposeProvider` correctly: that provider is rendered *inside* the `AppShell` that `MailShell` itself
|
|
@@ -80,12 +85,25 @@ export const MAILBOX_LIST_LIMIT = 100;
|
|
|
80
85
|
* its own descendants creates. This button, rendered as part of `AppShell`'s `children`, sits correctly
|
|
81
86
|
* inside that subtree.
|
|
82
87
|
*
|
|
83
|
-
* Opens with
|
|
84
|
-
*
|
|
88
|
+
* Opens with the caller's own mailbox (the shell has already listed them), so the window can start on its
|
|
89
|
+
* signature, draft and encryption lookups straight away instead of first asking which mailbox that is; the
|
|
90
|
+
* compose window's own From field is where the sender is chosen. Fetches the window's code as the pointer or
|
|
91
|
+
* keyboard reaches the button, so the click finds it already here.
|
|
85
92
|
*/
|
|
86
|
-
function ComposeButton() {
|
|
93
|
+
function ComposeButton({ mailboxUid }) {
|
|
87
94
|
const { openCompose } = useCompose();
|
|
88
|
-
|
|
95
|
+
const env = useKeyEnvironment();
|
|
96
|
+
return (_jsx("button", { type: "button", title: withHint("Compose", SHORTCUTS.mail.create, env), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.mail.create, env), onClick: () => openCompose({ mailboxUid }), onPointerEnter: prefetchComposeWindow, onFocus: prefetchComposeWindow, className: "block text-center w-full py-2.5 px-4 rounded-sm font-semibold text-sm bg-primary text-white hover:bg-primary-dark", children: "Compose" }));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Mail's keyboard shortcuts that don't belong to a list or a message: "New message". A component of its own for the reason `ComposeButton`
|
|
100
|
+
* is - `useCompose()` only resolves below `ComposeProvider`, i.e. inside the `AppShell` this shell renders - and mounted once, whatever number
|
|
101
|
+
* of Compose buttons (the sidebar, the folders drawer) are on screen. Opens the same window, for the same mailbox, as the button.
|
|
102
|
+
*/
|
|
103
|
+
function MailShortcuts({ mailboxUid }) {
|
|
104
|
+
const { openCompose } = useCompose();
|
|
105
|
+
useShortcut(SHORTCUTS.mail.create, () => openCompose({ mailboxUid }));
|
|
106
|
+
return null;
|
|
89
107
|
}
|
|
90
108
|
/**
|
|
91
109
|
* Mail's own contextual sidebar (every accessible mailbox's own folder tree, plus a merged "All Mailboxes"
|
|
@@ -103,36 +121,33 @@ function ComposeButton() {
|
|
|
103
121
|
* aggregate section) simultaneously, matching the shared-mailbox feature's own "display as a separate set
|
|
104
122
|
* of folders" requirement.
|
|
105
123
|
*/
|
|
106
|
-
export default function MailShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
const
|
|
124
|
+
export default function MailShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }) {
|
|
125
|
+
// The mailboxes, their folders and the live connection come from the persistent app frame when there is one (`AppChrome` owns them, so
|
|
126
|
+
// they survive Mail -> Calendar -> Mail and the push socket, pop-ups, counters and tab title work in every app). A page rendered
|
|
127
|
+
// outside the frame - a test, a plugin page - has no frame to ask, so this shell runs the same hook for itself, and renders the pop-ups.
|
|
128
|
+
const navigate = useNavigate();
|
|
129
|
+
const hosted = useContext(MailConnectionContext);
|
|
130
|
+
const own = useMailConnection({ userUid, enabled: !hosted, open: navigate });
|
|
131
|
+
const { status, error, mailboxes, mailboxFolders, foldersLoading, onFolderCreated, live, folderCounts, notifications } = hosted ?? own;
|
|
112
132
|
const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
|
|
113
133
|
const [requestedFolderUid, setRequestedFolderUid] = useState(null);
|
|
114
134
|
const [requestedAggregateType, setRequestedAggregateType] = useState(null);
|
|
115
135
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
136
|
+
// The selection lives in the URL, and the router (`AppRouter`) changes the URL without a page load when a folder link is
|
|
137
|
+
// clicked - so it is read from the router's location, which updates, rather than once from `window.location`. Still read
|
|
138
|
+
// in an effect, never during the first render, so the server render and the hydrating render agree.
|
|
139
|
+
const search = useLocationSearch();
|
|
116
140
|
useEffect(() => {
|
|
117
|
-
const params = new URLSearchParams(
|
|
141
|
+
const params = new URLSearchParams(search);
|
|
118
142
|
setRequestedMailboxUid(params.get("mailboxUid"));
|
|
119
143
|
setRequestedFolderUid(params.get("folderUid"));
|
|
120
144
|
setRequestedAggregateType(params.get("aggregate"));
|
|
121
|
-
}, []);
|
|
145
|
+
}, [search]);
|
|
146
|
+
// Choosing a folder (or mailbox) changes the URL without a page load now, so the drawer that held the choice - which used to go
|
|
147
|
+
// with the page - is closed here.
|
|
122
148
|
useEffect(() => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
listMailboxes({ limit: MAILBOX_LIST_LIMIT })
|
|
127
|
-
.then((result) => {
|
|
128
|
-
setMailboxes(result);
|
|
129
|
-
setStatus("ready");
|
|
130
|
-
})
|
|
131
|
-
.catch((err) => {
|
|
132
|
-
setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
|
|
133
|
-
setStatus("error");
|
|
134
|
-
});
|
|
135
|
-
}, [userUid]);
|
|
149
|
+
setDrawerOpen(false);
|
|
150
|
+
}, [search]);
|
|
136
151
|
const aggregateFolderType = isAggregateFolderType(requestedAggregateType)
|
|
137
152
|
? requestedAggregateType
|
|
138
153
|
: undefined;
|
|
@@ -140,29 +155,6 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
140
155
|
? undefined
|
|
141
156
|
: (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
|
|
142
157
|
mailboxes[0]?.uid;
|
|
143
|
-
// Fans out one listFolders() call per accessible mailbox in parallel - each call catches its own
|
|
144
|
-
// failure into an MailboxFolders.error rather than letting Promise.all reject, so one mailbox's fetch
|
|
145
|
-
// failure renders that section's own inline Alert instead of blanking out every other mailbox's
|
|
146
|
-
// folder tree.
|
|
147
|
-
useEffect(() => {
|
|
148
|
-
if (mailboxes.length === 0) {
|
|
149
|
-
// Deliberately leaves foldersLoading as-is: this also runs once before listMailboxes() has
|
|
150
|
-
// resolved, and clearing it here would briefly render an empty sidebar the moment mailboxes
|
|
151
|
-
// arrive, before their folders do. A genuinely mailbox-less caller gets MailboxProvisioning.
|
|
152
|
-
setMailboxFolders([]);
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
setFoldersLoading(true);
|
|
156
|
-
void Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid)
|
|
157
|
-
.then((result) => ({ mailbox, folders: result.filter((f) => MAIL_FOLDER_TYPES.has(f.type)) }))
|
|
158
|
-
.catch((err) => ({
|
|
159
|
-
mailbox,
|
|
160
|
-
folders: [],
|
|
161
|
-
error: err instanceof ApiRequestError ? err.message : "Could not load folders.",
|
|
162
|
-
}))))
|
|
163
|
-
.then(setMailboxFolders)
|
|
164
|
-
.finally(() => setFoldersLoading(false));
|
|
165
|
-
}, [mailboxes]);
|
|
166
158
|
const selectedMailboxFolders = mailboxFolders.find((mf) => mf.mailbox.uid === mailboxUid)?.folders ?? [];
|
|
167
159
|
const folderUid = aggregateFolderType
|
|
168
160
|
? undefined
|
|
@@ -175,23 +167,11 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
175
167
|
// mailbox may still need that mailbox's own folder view opened directly to unlock/decrypt it.
|
|
176
168
|
const defaultMailboxUid = mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
|
|
177
169
|
const activeMailboxUid = mailboxUid ?? defaultMailboxUid;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
setMailboxFolders((prev) => prev.map((entry) =>
|
|
185
|
-
// Not one already there: another client's create event can arrive after (or twice, or alongside) our own.
|
|
186
|
-
entry.mailbox.uid === folder.mailboxUid && !entry.folders.some((existing) => existing.uid === folder.uid)
|
|
187
|
-
? { ...entry, folders: [...entry.folders, folder] }
|
|
188
|
-
: entry));
|
|
189
|
-
}, []);
|
|
190
|
-
// New mail without a reload: push events for every folder, plus a safety-net poll. Held in state beside `mailboxFolders`
|
|
191
|
-
// rather than written into it, so a refreshed unread count never looks like a change of folders to the list on screen
|
|
192
|
-
// (which reloads, and forgets its selection, whenever the folders do).
|
|
193
|
-
const { live, unreadCounts } = useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated });
|
|
194
|
-
const contextValue = useMemo(() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live }), [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live]);
|
|
170
|
+
const counts = folderCounts.counts;
|
|
171
|
+
const trackMessageChange = folderCounts.track;
|
|
172
|
+
// `(3) Acme: Mail` in the tab strip while there is unread mail in an Inbox - kept by the frame when there is one.
|
|
173
|
+
useUnreadTitle(inboxUnreadTotal(mailboxFolders, counts), { enabled: !hosted });
|
|
174
|
+
const contextValue = useMemo(() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live, trackMessageChange }), [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live, trackMessageChange]);
|
|
195
175
|
// A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
|
|
196
176
|
// mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all. Checks
|
|
197
177
|
// `mailboxes.length` directly (not `!mailboxUid`) since `mailboxUid` is legitimately undefined in
|
|
@@ -212,24 +192,24 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
212
192
|
// A function, not a plain JSX constant — it's rendered twice (desktop `<aside>` + mobile
|
|
213
193
|
// `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
|
|
214
194
|
// unmounted).
|
|
215
|
-
const sidebarContent = () => (_jsxs(_Fragment, { children: [_jsx("div", { className: "p-3", children: _jsx(ComposeButton, {}) }), foldersLoading ? (_jsx("div", { className: "flex-1 overflow-y-auto px-3 pb-3", children: _jsx(SkeletonList, { count: 6, className: "pt-1" }) })) : (_jsxs("nav", { className: "flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3", children: [mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5", children: "All Mailboxes" }), _jsx("div", { className: "flex flex-col gap-0.5", children: AGGREGATE_FOLDER_TYPES.map((type) => {
|
|
216
|
-
const
|
|
195
|
+
const sidebarContent = () => (_jsxs(_Fragment, { children: [_jsx("div", { className: "p-3", children: _jsx(ComposeButton, { mailboxUid: defaultMailboxUid }) }), foldersLoading ? (_jsx("div", { className: "flex-1 overflow-y-auto px-3 pb-3", children: _jsx(SkeletonList, { count: 6, className: "pt-1" }) })) : (_jsxs("nav", { className: "flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3", children: [mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5", children: "All Mailboxes" }), _jsx("div", { className: "flex flex-col gap-0.5", children: AGGREGATE_FOLDER_TYPES.map((type) => {
|
|
196
|
+
const badge = aggregateBadge(mailboxFolders, type, counts);
|
|
217
197
|
return (_jsxs("a", { href: `/?aggregate=${encodeURIComponent(type)}`, className: [
|
|
218
198
|
"flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
|
|
219
199
|
aggregateFolderType === type
|
|
220
200
|
? "bg-primary/10 text-primary-dark font-semibold"
|
|
221
201
|
: "text-text hover:bg-surface-alt",
|
|
222
|
-
].join(" "), children: [_jsx("span", { children: FOLDER_LABELS[type] }),
|
|
202
|
+
].join(" "), children: [_jsx("span", { className: badge?.kind === "unread" ? "font-semibold" : undefined, children: FOLDER_LABELS[type] }), badge && _jsx(FolderBadgeChip, { badge: badge })] }, type));
|
|
223
203
|
}) })] })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsxs("div", { children: [_jsxs("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5 truncate", children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }), mailboxError && (_jsx("div", { className: "px-2.5 pb-1", children: _jsx(Alert, { children: mailboxError }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: sortedFoldersOf(folders).map((folder) => {
|
|
224
|
-
const
|
|
204
|
+
const badge = badgeFor(folder.type, countOfFolder(folder, counts));
|
|
225
205
|
return (_jsxs("a", { href: `/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`, className: [
|
|
226
206
|
"flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
|
|
227
207
|
folder.uid === folderUid
|
|
228
208
|
? "bg-primary/10 text-primary-dark font-semibold"
|
|
229
209
|
: "text-text hover:bg-surface-alt",
|
|
230
|
-
].join(" "), children: [_jsx("span", { children: FOLDER_LABELS[folder.type] ?? folder.name }),
|
|
210
|
+
].join(" "), children: [_jsx("span", { className: badge?.kind === "unread" ? "font-semibold" : undefined, children: FOLDER_LABELS[folder.type] ?? folder.name }), badge && _jsx(FolderBadgeChip, { badge: badge })] }, folder.uid));
|
|
231
211
|
}) })] }, mailbox.uid)))] }))] }));
|
|
232
|
-
inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent() }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", children: _jsx("div", { className: "flex flex-col", children: sidebarContent() }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_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 folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
212
|
+
inner = (_jsxs(_Fragment, { children: [_jsx(MailShortcuts, { mailboxUid: defaultMailboxUid }), _jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent() }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", children: _jsx("div", { className: "flex flex-col", children: sidebarContent() }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_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 folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] }), !hosted && (_jsx(NewMailToasts, { toasts: notifications.toasts, onDismiss: notifications.dismiss, offerDesktop: notifications.offerDesktop, onEnableDesktop: () => void notifications.enableDesktop(), onDeclineDesktop: notifications.declineDesktop }))] }));
|
|
233
213
|
}
|
|
234
214
|
// Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
|
|
235
215
|
// resolved yet - KeyEnrollmentGate itself passes `children` through untouched until a real
|
|
@@ -251,5 +231,5 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
251
231
|
canProvision: !impersonating && activeMailbox?.ownerUserUid === userUid, children: [_jsx(LocalIndexLifecycle, { mailboxUid: activeMailboxUid, mailboxKeys: activeMailbox?.keys, folders: mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? [],
|
|
252
232
|
// A full page may be truncated - pruning against it would delete indexes of accessible mailboxes
|
|
253
233
|
// beyond the limit, so it's only trusted as the complete list when it came back short.
|
|
254
|
-
accessibleMailboxUids: status === "ready" && mailboxes.length < MAILBOX_LIST_LIMIT ? mailboxes.map((mb) => mb.uid) : undefined }), _jsx(AppShell, { active: "mail", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, pluginNav: pluginNav, children: inner })] }));
|
|
234
|
+
accessibleMailboxUids: status === "ready" && mailboxes.length < MAILBOX_LIST_LIMIT ? mailboxes.map((mb) => mb.uid) : undefined }), _jsx(AppShell, { active: "mail", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner })] }));
|
|
255
235
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
3
|
+
/**
|
|
4
|
+
* How an unread message looks in every list of mail (the message list, the conversation list and its child rows, the
|
|
5
|
+
* thread pane's message headers, the folder badges), in one place so they can't drift apart. Modelled on Outlook:
|
|
6
|
+
*
|
|
7
|
+
* **Unread**: an accent bar down the row's left edge, a faint accent tint on the row, the sender and subject in bold and the
|
|
8
|
+
* date in the accent colour - plus a visually-hidden "Unread" in the row's text, so it never relies on colour alone.
|
|
9
|
+
*
|
|
10
|
+
* **Read**: normal weight, the sender and preview muted.
|
|
11
|
+
*
|
|
12
|
+
* **Hover, selected and focused** are their own states and look nothing like unread: hover is a plain grey (an accent tint on
|
|
13
|
+
* an unread row, so the row doesn't seem to lose its state), the open row has a stronger accent fill and an inset outline, and
|
|
14
|
+
* keyboard focus is a solid outline drawn inside the row.
|
|
15
|
+
*
|
|
16
|
+
* All of it is built from the theme's own tokens (`primary`, `text`, `text-muted`, `surface-alt`), so it follows light and
|
|
17
|
+
* dark mode and a branding stylesheet's palette.
|
|
18
|
+
*/
|
|
19
|
+
/** Whether a message is unread: `flags.read` isn't `true`. A message with no `read` flag at all counts as unread. */
|
|
20
|
+
export declare function isUnread(message: Pick<Message, "flags"> | undefined | null): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The classes of a row's outer element (`li`, or the row's wrapping `div`): the positioning the bar needs, and its one
|
|
23
|
+
* background - the open row's accent fill and inset outline, else an unread row's faint tint (a little stronger on hover),
|
|
24
|
+
* else a read row's plain grey hover. One element owns all of it so the states can't fight over the same property.
|
|
25
|
+
*/
|
|
26
|
+
export declare function rowClass(state: {
|
|
27
|
+
unread: boolean;
|
|
28
|
+
selected: boolean;
|
|
29
|
+
}, extra?: string): string;
|
|
30
|
+
/** Keyboard focus on a row's button: a solid outline drawn inside the row, unlike anything unread/selected/hover does. */
|
|
31
|
+
export declare const ROW_FOCUS_CLASS = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary";
|
|
32
|
+
/** The sender line: bold and full-strength while unread, normal and muted once read. */
|
|
33
|
+
export declare function senderClass(unread: boolean): string;
|
|
34
|
+
/** The subject line. */
|
|
35
|
+
export declare function subjectClass(unread: boolean): string;
|
|
36
|
+
/** The date beside the sender: the accent colour while unread. */
|
|
37
|
+
export declare function dateClass(unread: boolean): string;
|
|
38
|
+
/** The accent bar down a row's left edge - only for an unread row. The row's outer element must be `relative`. */
|
|
39
|
+
export declare function UnreadBar({ unread }: {
|
|
40
|
+
unread: boolean;
|
|
41
|
+
}): React.JSX.Element | null;
|
|
42
|
+
/** "Unread." in the row's text for assistive technology (and for anything that reads the DOM without styles). */
|
|
43
|
+
export declare function UnreadLabel({ unread }: {
|
|
44
|
+
unread: boolean;
|
|
45
|
+
}): React.JSX.Element | null;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* How an unread message looks in every list of mail (the message list, the conversation list and its child rows, the
|
|
4
|
+
* thread pane's message headers, the folder badges), in one place so they can't drift apart. Modelled on Outlook:
|
|
5
|
+
*
|
|
6
|
+
* **Unread**: an accent bar down the row's left edge, a faint accent tint on the row, the sender and subject in bold and the
|
|
7
|
+
* date in the accent colour - plus a visually-hidden "Unread" in the row's text, so it never relies on colour alone.
|
|
8
|
+
*
|
|
9
|
+
* **Read**: normal weight, the sender and preview muted.
|
|
10
|
+
*
|
|
11
|
+
* **Hover, selected and focused** are their own states and look nothing like unread: hover is a plain grey (an accent tint on
|
|
12
|
+
* an unread row, so the row doesn't seem to lose its state), the open row has a stronger accent fill and an inset outline, and
|
|
13
|
+
* keyboard focus is a solid outline drawn inside the row.
|
|
14
|
+
*
|
|
15
|
+
* All of it is built from the theme's own tokens (`primary`, `text`, `text-muted`, `surface-alt`), so it follows light and
|
|
16
|
+
* dark mode and a branding stylesheet's palette.
|
|
17
|
+
*/
|
|
18
|
+
/** Whether a message is unread: `flags.read` isn't `true`. A message with no `read` flag at all counts as unread. */
|
|
19
|
+
export function isUnread(message) {
|
|
20
|
+
return !!message && message.flags.read !== true;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The classes of a row's outer element (`li`, or the row's wrapping `div`): the positioning the bar needs, and its one
|
|
24
|
+
* background - the open row's accent fill and inset outline, else an unread row's faint tint (a little stronger on hover),
|
|
25
|
+
* else a read row's plain grey hover. One element owns all of it so the states can't fight over the same property.
|
|
26
|
+
*/
|
|
27
|
+
export function rowClass(state, extra = "") {
|
|
28
|
+
return [
|
|
29
|
+
"relative border-b border-border",
|
|
30
|
+
state.selected
|
|
31
|
+
? "bg-primary/20 ring-1 ring-inset ring-primary/50"
|
|
32
|
+
: state.unread
|
|
33
|
+
? "bg-primary/[0.07] hover:bg-primary/10"
|
|
34
|
+
: "hover:bg-surface-alt",
|
|
35
|
+
extra,
|
|
36
|
+
]
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.join(" ");
|
|
39
|
+
}
|
|
40
|
+
/** Keyboard focus on a row's button: a solid outline drawn inside the row, unlike anything unread/selected/hover does. */
|
|
41
|
+
export const ROW_FOCUS_CLASS = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary";
|
|
42
|
+
/** The sender line: bold and full-strength while unread, normal and muted once read. */
|
|
43
|
+
export function senderClass(unread) {
|
|
44
|
+
return unread ? "font-bold text-text" : "font-normal text-text-muted";
|
|
45
|
+
}
|
|
46
|
+
/** The subject line. */
|
|
47
|
+
export function subjectClass(unread) {
|
|
48
|
+
return unread ? "font-semibold text-text" : "font-normal text-text";
|
|
49
|
+
}
|
|
50
|
+
/** The date beside the sender: the accent colour while unread. */
|
|
51
|
+
export function dateClass(unread) {
|
|
52
|
+
return unread ? "font-semibold text-primary-dark" : "font-normal text-text-muted";
|
|
53
|
+
}
|
|
54
|
+
/** The accent bar down a row's left edge - only for an unread row. The row's outer element must be `relative`. */
|
|
55
|
+
export function UnreadBar({ unread }) {
|
|
56
|
+
return unread ? _jsx("span", { "aria-hidden": "true", "data-unread-bar": true, className: "absolute inset-y-0 left-0 w-1 bg-primary" }) : null;
|
|
57
|
+
}
|
|
58
|
+
/** "Unread." in the row's text for assistive technology (and for anything that reads the DOM without styles). */
|
|
59
|
+
export function UnreadLabel({ unread }) {
|
|
60
|
+
return unread ? _jsx("span", { className: "sr-only", children: "Unread. " }) : null;
|
|
61
|
+
}
|
|
@@ -38,4 +38,4 @@ export declare function useSettingsShell(): SettingsShellContextValue;
|
|
|
38
38
|
* settings sections (`SETTINGS_SECTIONS`) — full-page navigation between them, same as the top-level
|
|
39
39
|
* app rail, since this framework has no client-side router.
|
|
40
40
|
*/
|
|
41
|
-
export default function SettingsShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }: PropsWithChildren<SettingsShellProps>): React.JSX.Element;
|
|
41
|
+
export default function SettingsShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }: PropsWithChildren<SettingsShellProps>): React.JSX.Element;
|
|
@@ -11,6 +11,7 @@ import { listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
|
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
|
import { mergePluginNavItems } from "../../../plugins/pluginNav.js";
|
|
16
17
|
/** Every settings section with its own sidebar entry. */
|
|
@@ -46,15 +47,24 @@ export function useSettingsShell() {
|
|
|
46
47
|
* settings sections (`SETTINGS_SECTIONS`) — full-page navigation between them, same as the top-level
|
|
47
48
|
* app rail, since this framework has no client-side router.
|
|
48
49
|
*/
|
|
49
|
-
export default function SettingsShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }) {
|
|
50
|
+
export default function SettingsShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }) {
|
|
50
51
|
const [status, setStatus] = useState("checking");
|
|
51
52
|
const [error, setError] = useState(null);
|
|
52
53
|
const [mailboxes, setMailboxes] = useState([]);
|
|
53
54
|
const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
|
|
54
55
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
56
|
+
// Read from the router's location, so a mailbox change (a link, or `navigate()`) takes effect without a page load - and in an
|
|
57
|
+
// effect, not during render, so the server render and the hydrating render agree.
|
|
58
|
+
const search = useLocationSearch();
|
|
59
|
+
const navigate = useNavigate();
|
|
55
60
|
useEffect(() => {
|
|
56
|
-
setRequestedMailboxUid(new URLSearchParams(
|
|
57
|
-
}, []);
|
|
61
|
+
setRequestedMailboxUid(new URLSearchParams(search).get("mailboxUid"));
|
|
62
|
+
}, [search]);
|
|
63
|
+
// Choosing a folder (or mailbox) changes the URL without a page load now, so the drawer that held the choice - which used to go
|
|
64
|
+
// with the page - is closed here.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
setDrawerOpen(false);
|
|
67
|
+
}, [search]);
|
|
58
68
|
useEffect(() => {
|
|
59
69
|
if (!userUid) {
|
|
60
70
|
return;
|
|
@@ -94,7 +104,7 @@ export default function SettingsShell({ active, userUid, authServerUrl, imperson
|
|
|
94
104
|
// `SettingsShellProps.active`), but a plugin page's section is missing when the
|
|
95
105
|
// server didn't send its nav item, so that falls back to the current path.
|
|
96
106
|
const href = sections.find((s) => s.id === active)?.href ?? window.location.pathname;
|
|
97
|
-
|
|
107
|
+
navigate(`${href}?mailboxUid=${encodeURIComponent(e.target.value)}`);
|
|
98
108
|
}, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsx("nav", { "aria-label": "Settings sections", className: "flex flex-col gap-0.5", children: sections.map((section) => (_jsx("a", {
|
|
99
109
|
// `inner` (this whole branch) is only ever computed once `status === "ready"`
|
|
100
110
|
// and `mailboxUid` has already resolved truthy — a falsy `mailboxUid` returns
|
|
@@ -109,5 +119,5 @@ export default function SettingsShell({ active, userUid, authServerUrl, imperson
|
|
|
109
119
|
].join(" "), children: section.label }, section.id))) })] }));
|
|
110
120
|
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: "Settings", children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [_jsx("button", { type: "button", className: "md:hidden m-3 self-start w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open settings menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(SettingsShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
111
121
|
}
|
|
112
|
-
return (_jsx(AppShell, { active: "settings", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, pluginNav: pluginNav, children: inner }));
|
|
122
|
+
return (_jsx(AppShell, { active: "settings", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner }));
|
|
113
123
|
}
|
|
@@ -21,4 +21,4 @@ export declare function useTasksShell(): TasksShellContextValue;
|
|
|
21
21
|
* (rather than a single generic "SingleFolderShell" parameterized by type) so each app's sidebar can
|
|
22
22
|
* grow its own app-specific controls later without threading extra props through a shared one.
|
|
23
23
|
*/
|
|
24
|
-
export default function TasksShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }: PropsWithChildren<TasksShellProps>): React.JSX.Element;
|
|
24
|
+
export default function TasksShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }: PropsWithChildren<TasksShellProps>): React.JSX.Element;
|
|
@@ -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 TasksShellContext = createContext({ mailboxes: [] });
|
|
16
17
|
/** Reads the mailbox/folder Tasks is currently showing, as resolved by the enclosing `TasksShell`. */
|
|
@@ -24,7 +25,7 @@ export function useTasksShell() {
|
|
|
24
25
|
* (rather than a single generic "SingleFolderShell" parameterized by type) so each app's sidebar can
|
|
25
26
|
* grow its own app-specific controls later without threading extra props through a shared one.
|
|
26
27
|
*/
|
|
27
|
-
export default function TasksShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }) {
|
|
28
|
+
export default function TasksShell({ 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 TasksShell({ userUid, authServerUrl, impersonating, impe
|
|
|
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;
|
|
@@ -84,9 +94,9 @@ export default function TasksShell({ userUid, authServerUrl, impersonating, impe
|
|
|
84
94
|
// comment: it's rendered twice (desktop `<aside>` + mobile `Drawer`), possibly simultaneously
|
|
85
95
|
// mounted, so the `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct value per instance.
|
|
86
96
|
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}-tasks-mailbox-switcher`, children: "Mailbox" }), _jsx("select", { id: `${idPrefix}-tasks-mailbox-switcher`, className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: mailboxUid, onChange: (e) => {
|
|
87
|
-
|
|
97
|
+
navigate(`/tasks?mailboxUid=${encodeURIComponent(e.target.value)}`);
|
|
88
98
|
}, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), folderError && _jsx(Alert, { children: folderError })] }));
|
|
89
99
|
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(TasksShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
90
100
|
}
|
|
91
|
-
return (_jsx(AppShell, { active: "tasks", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, pluginNav: pluginNav, children: inner }));
|
|
101
|
+
return (_jsx(AppShell, { active: "tasks", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner }));
|
|
92
102
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface GlobalShortcutsProps {
|
|
2
|
+
/** auth-server's base URL - "Go to Account" is offered only when it is set. */
|
|
3
|
+
authServerUrl?: string;
|
|
4
|
+
/** Opens the keyboard shortcuts dialog, or closes it if it is open. */
|
|
5
|
+
onToggleHelp: () => void;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The shortcuts that work in every view: the "Go to ..." navigation set and the keyboard help. Mounted once by `AppChrome`, next to the
|
|
9
|
+
* `ShortcutProvider`, so it works from Settings and every app alike. Renders nothing.
|
|
10
|
+
*
|
|
11
|
+
* In-app places go through the client-side router (`useNavigate()`); Account is another origin, so it is a plain navigation. Asking for the
|
|
12
|
+
* page you are already on does nothing - but the key is still taken, so the browser doesn't act on it either.
|
|
13
|
+
*/
|
|
14
|
+
export declare function GlobalShortcuts({ authServerUrl, onToggleHelp }: GlobalShortcutsProps): null;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { accountUrlOf } from "../auth/accountUrl.js";
|
|
6
|
+
import { APP_HREFS, SETTINGS_HREF, isSettingsPath } from "../navigation/appHrefs.js";
|
|
7
|
+
import { useNavigate } from "../navigation/routerContext.js";
|
|
8
|
+
import { SHORTCUTS } from "./keymap.js";
|
|
9
|
+
import { useShortcut } from "./useShortcut.js";
|
|
10
|
+
/**
|
|
11
|
+
* The shortcuts that work in every view: the "Go to ..." navigation set and the keyboard help. Mounted once by `AppChrome`, next to the
|
|
12
|
+
* `ShortcutProvider`, so it works from Settings and every app alike. Renders nothing.
|
|
13
|
+
*
|
|
14
|
+
* In-app places go through the client-side router (`useNavigate()`); Account is another origin, so it is a plain navigation. Asking for the
|
|
15
|
+
* page you are already on does nothing - but the key is still taken, so the browser doesn't act on it either.
|
|
16
|
+
*/
|
|
17
|
+
export function GlobalShortcuts({ authServerUrl, onToggleHelp }) {
|
|
18
|
+
const navigate = useNavigate();
|
|
19
|
+
const accountUrl = accountUrlOf(authServerUrl);
|
|
20
|
+
/** A handler that goes to `href` unless the address bar's path says it is already there. */
|
|
21
|
+
const goTo = (href, isHere) => () => {
|
|
22
|
+
if (!isHere(window.location.pathname)) {
|
|
23
|
+
navigate(href);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const isPath = (path) => (pathname) => pathname === path;
|
|
27
|
+
useShortcut(SHORTCUTS.global.mail, goTo(APP_HREFS.mail, isPath(APP_HREFS.mail)));
|
|
28
|
+
useShortcut(SHORTCUTS.global.calendar, goTo(APP_HREFS.calendar, isPath(APP_HREFS.calendar)));
|
|
29
|
+
useShortcut(SHORTCUTS.global.contacts, goTo(APP_HREFS.contacts, isPath(APP_HREFS.contacts)));
|
|
30
|
+
useShortcut(SHORTCUTS.global.tasks, goTo(APP_HREFS.tasks, isPath(APP_HREFS.tasks)));
|
|
31
|
+
useShortcut(SHORTCUTS.global.settings, goTo(SETTINGS_HREF, isSettingsPath));
|
|
32
|
+
useShortcut(SHORTCUTS.global.account, () => {
|
|
33
|
+
window.location.href = accountUrl;
|
|
34
|
+
}, { enabled: !!accountUrl });
|
|
35
|
+
useShortcut(SHORTCUTS.global.help, onToggleHelp);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React, { PropsWithChildren } from "react";
|
|
2
|
+
import { KeyEnvironment } from "./platform.js";
|
|
3
|
+
import { Registration, ShortcutRegistry } from "./registry.js";
|
|
4
|
+
/**
|
|
5
|
+
* The web client's one keyboard layer. Mounted once, by `AppChrome` (the frame that stays mounted as the router swaps pages), so shortcuts
|
|
6
|
+
* work in every view - mail, calendar, contacts, tasks, settings - and a page change only changes which handlers are registered.
|
|
7
|
+
*
|
|
8
|
+
* One `keydown` listener on `document` (bubbling, so what a widget handles itself and `preventDefault()`s is left alone) hands each key to
|
|
9
|
+
* `dispatchKeyEvent()`. Views claim keys with `useShortcut()`. Outside a provider - an admin page, a component rendered on its own - that
|
|
10
|
+
* hook does nothing.
|
|
11
|
+
*
|
|
12
|
+
* The platform (macOS, the desktop client) is read after mount, never during render, so the server render and hydration agree.
|
|
13
|
+
*/
|
|
14
|
+
export declare function ShortcutProvider({ children }: PropsWithChildren): React.JSX.Element;
|
|
15
|
+
/** The registry, or `null` outside a `ShortcutProvider`. */
|
|
16
|
+
export declare function useShortcutRegistry(): ShortcutRegistry | null;
|
|
17
|
+
/** The platform as the keyboard layer sees it - `SERVER_ENVIRONMENT` until the client has mounted, and outside a provider. */
|
|
18
|
+
export declare function useKeyEnvironment(): KeyEnvironment;
|
|
19
|
+
/** Everything registered right now, updating as views mount and unmount - what the help dialog lists. Empty outside a provider. */
|
|
20
|
+
export declare function useRegisteredShortcuts(): readonly Registration[];
|