@rapidmx/web-client 0.24.0 → 0.25.1
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/apps/shared/components/admin/layout/AdminShell.tsx +39 -4
- package/apps/shared/components/admin/layout/railPreference.ts +25 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +6 -2
- package/apps/shared/components/layout/ThemeSwitch.tsx +3 -2
- package/apps/shared/components/layout/UserMenu.tsx +30 -10
- package/apps/shared/components/mail/layout/MailShell.tsx +13 -5
- package/apps/shared/components/mail/layout/folderIcons.tsx +54 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +32 -13
- package/apps/shared/navigation/appHrefs.ts +3 -3
- package/dist/apps/shared/components/admin/layout/AdminShell.js +21 -4
- package/dist/apps/shared/components/admin/layout/railPreference.d.ts +6 -0
- package/dist/apps/shared/components/admin/layout/railPreference.js +24 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +3 -2
- package/dist/apps/shared/components/layout/ThemeSwitch.js +2 -2
- package/dist/apps/shared/components/layout/UserMenu.d.ts +1 -2
- package/dist/apps/shared/components/layout/UserMenu.js +4 -4
- package/dist/apps/shared/components/mail/layout/MailShell.js +5 -4
- package/dist/apps/shared/components/mail/layout/folderIcons.d.ts +8 -0
- package/dist/apps/shared/components/mail/layout/folderIcons.js +25 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -15
- package/dist/apps/shared/navigation/appHrefs.d.ts +3 -3
- package/dist/apps/shared/navigation/appHrefs.js +3 -3
- package/dist/lib/components/pickers/TimeZonePicker.js +14 -5
- package/dist/lib/util/timeZone.d.ts +1 -1
- package/dist/lib/util/timeZone.js +22 -9
- package/lib/components/pickers/TimeZonePicker.tsx +14 -9
- package/lib/util/timeZone.ts +24 -9
- package/package.json +1 -1
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
HiOutlineUserGroup,
|
|
25
25
|
HiOutlineWrenchScrewdriver,
|
|
26
26
|
HiOutlineBars3,
|
|
27
|
+
HiOutlineChevronDoubleLeft,
|
|
28
|
+
HiOutlineChevronDoubleRight,
|
|
27
29
|
} from "react-icons/hi2";
|
|
28
30
|
import { apiFetch, ApiRequestError } from "../../../../../lib/util/api.js";
|
|
29
31
|
import { getSetupStatus } from "../../../../../lib/admin/setupApi.js";
|
|
@@ -35,6 +37,7 @@ import Drawer from "../../../../../lib/components/overlays/Drawer.js";
|
|
|
35
37
|
import type { NavItem } from "../../../../../lib/components/navigation/BottomTabBar.js";
|
|
36
38
|
import { FrameBrandingFooter, useBrandingHtml } from "../../layout/BrandingChrome.js";
|
|
37
39
|
import RailIcon from "../../layout/RailIcon.js";
|
|
40
|
+
import { readRailExpanded, writeRailExpanded } from "./railPreference.js";
|
|
38
41
|
import AppearanceProvider from "../../../appearance/AppearanceProvider.js";
|
|
39
42
|
import UserMenu from "../../layout/UserMenu.js";
|
|
40
43
|
import {
|
|
@@ -192,6 +195,15 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
|
|
|
192
195
|
const [status, setStatus] = useState<Status>("checking");
|
|
193
196
|
const [error, setError] = useState<string | null>(null);
|
|
194
197
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
198
|
+
// The side rail shows icons alone, or icons with their labels. Read after the first render: the page is drawn on the server first, which cannot know.
|
|
199
|
+
const [railExpanded, setRailExpanded] = useState(false);
|
|
200
|
+
useEffect(() => setRailExpanded(readRailExpanded()), []);
|
|
201
|
+
function toggleRail() {
|
|
202
|
+
setRailExpanded((expanded) => {
|
|
203
|
+
writeRailExpanded(!expanded);
|
|
204
|
+
return !expanded;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
195
207
|
// `branding` only feeds the footer below: the admin-configured header is for the webmail and public pages, not the
|
|
196
208
|
// console. `useBranding()` is still what injects the custom stylesheet and supplies the rail's icon.
|
|
197
209
|
const { branding, iconSrc } = useBranding();
|
|
@@ -309,7 +321,10 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
|
|
|
309
321
|
<div className="flex-1 flex min-h-0">
|
|
310
322
|
<nav
|
|
311
323
|
aria-label="Admin sections"
|
|
312
|
-
className=
|
|
324
|
+
className={[
|
|
325
|
+
"hidden md:flex shrink-0 bg-surface border-r border-border flex-col pb-3 gap-1",
|
|
326
|
+
railExpanded ? "w-56 items-stretch" : "w-16 items-center",
|
|
327
|
+
].join(" ")}
|
|
313
328
|
>
|
|
314
329
|
<RailIcon src={iconSrc} />
|
|
315
330
|
{navItems.map(({ id, href, label, icon: Icon }) => (
|
|
@@ -318,17 +333,37 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
|
|
|
318
333
|
href={href}
|
|
319
334
|
aria-label={label}
|
|
320
335
|
aria-current={id === active ? "page" : undefined}
|
|
321
|
-
title={label}
|
|
336
|
+
title={railExpanded ? undefined : label}
|
|
322
337
|
className={[
|
|
323
|
-
"
|
|
338
|
+
"h-10 flex items-center rounded-sm",
|
|
339
|
+
railExpanded ? "gap-3 px-3 mx-2 text-sm font-medium" : "w-10 justify-center",
|
|
324
340
|
id === active
|
|
325
341
|
? "bg-primary/10 text-primary-dark"
|
|
326
342
|
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
327
343
|
].join(" ")}
|
|
328
344
|
>
|
|
329
|
-
<Icon size={20} aria-hidden="true" />
|
|
345
|
+
<Icon size={20} aria-hidden="true" className="shrink-0" />
|
|
346
|
+
{railExpanded && <span className="truncate">{label}</span>}
|
|
330
347
|
</a>
|
|
331
348
|
))}
|
|
349
|
+
<button
|
|
350
|
+
type="button"
|
|
351
|
+
aria-expanded={railExpanded}
|
|
352
|
+
aria-label={railExpanded ? "Collapse the menu" : "Expand the menu"}
|
|
353
|
+
title={railExpanded ? "Collapse the menu" : "Expand the menu"}
|
|
354
|
+
onClick={toggleRail}
|
|
355
|
+
className={[
|
|
356
|
+
"mt-auto h-10 flex items-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text",
|
|
357
|
+
railExpanded ? "gap-3 px-3 mx-2 text-sm font-medium" : "w-10 justify-center",
|
|
358
|
+
].join(" ")}
|
|
359
|
+
>
|
|
360
|
+
{railExpanded ? (
|
|
361
|
+
<HiOutlineChevronDoubleLeft size={20} aria-hidden="true" className="shrink-0" />
|
|
362
|
+
) : (
|
|
363
|
+
<HiOutlineChevronDoubleRight size={20} aria-hidden="true" className="shrink-0" />
|
|
364
|
+
)}
|
|
365
|
+
{railExpanded && <span>Collapse</span>}
|
|
366
|
+
</button>
|
|
332
367
|
</nav>
|
|
333
368
|
{/* Below `md` the sections are a menu that slides in from the left: ten or so of them don't fit a bar along the bottom. */}
|
|
334
369
|
<Drawer open={menuOpen} onClose={() => setMenuOpen(false)} title="Admin" fullScreen>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/** Where this browser remembers whether the admin console's side rail is showing its labels. */
|
|
7
|
+
export const RAIL_EXPANDED_KEY = "rapidmx:admin-rail-expanded";
|
|
8
|
+
|
|
9
|
+
/** Whether the rail was left expanded. A missing or blocked store means collapsed, as it always was. */
|
|
10
|
+
export function readRailExpanded(): boolean {
|
|
11
|
+
try {
|
|
12
|
+
return localStorage.getItem(RAIL_EXPANDED_KEY) === "true";
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Remembers the rail's state. Best effort: a blocked store only means it starts collapsed the next time. */
|
|
19
|
+
export function writeRailExpanded(expanded: boolean): void {
|
|
20
|
+
try {
|
|
21
|
+
localStorage.setItem(RAIL_EXPANDED_KEY, String(expanded));
|
|
22
|
+
} catch {
|
|
23
|
+
// Nothing to do.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -7,6 +7,7 @@ import { CALENDAR_COLOR_PALETTE, accentColorForMailbox, colorForFolder } from ".
|
|
|
7
7
|
import { Folder, Mailbox } from "../../../../lib/mail/mailApi.js";
|
|
8
8
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
9
9
|
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
10
|
+
import { SharedMailboxMark } from "../mail/layout/folderIcons.js";
|
|
10
11
|
|
|
11
12
|
export interface CalendarListSidebarMailbox {
|
|
12
13
|
mailbox: Mailbox;
|
|
@@ -108,7 +109,7 @@ export default function CalendarListSidebar({
|
|
|
108
109
|
return (
|
|
109
110
|
<nav aria-label="My calendars" className="p-3 border-t border-border flex flex-col gap-3">
|
|
110
111
|
{mailboxCalendars.map(({ mailbox, calendarFolders, error: mailboxError }) => {
|
|
111
|
-
const title = multiple ?
|
|
112
|
+
const title = multiple ? mailbox.displayName : "My calendars";
|
|
112
113
|
return (
|
|
113
114
|
<div key={mailbox.uid}>
|
|
114
115
|
<div className="flex items-center justify-between mb-1 gap-2">
|
|
@@ -120,7 +121,10 @@ export default function CalendarListSidebar({
|
|
|
120
121
|
style={{ backgroundColor: accentColorForMailbox(mailbox.uid) }}
|
|
121
122
|
/>
|
|
122
123
|
)}
|
|
123
|
-
<span className="truncate">
|
|
124
|
+
<span className="truncate">
|
|
125
|
+
{title}
|
|
126
|
+
{multiple && !mailbox.ownerUserUid && <SharedMailboxMark />}
|
|
127
|
+
</span>
|
|
124
128
|
</h2>
|
|
125
129
|
<button
|
|
126
130
|
type="button"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import React, { useContext, useId, useRef } from "react";
|
|
6
|
-
import { HiOutlineComputerDesktop, HiOutlineMoon, HiOutlineSun } from "react-icons/hi2";
|
|
6
|
+
import { HiOutlineComputerDesktop, HiOutlineMoon, HiOutlinePaintBrush, HiOutlineSun } from "react-icons/hi2";
|
|
7
7
|
import type { AppearanceMode } from "../../../../lib/appearance/preferencesApi.js";
|
|
8
8
|
import { AppearanceContext, INERT_APPEARANCE } from "../../appearance/appearanceContext.js";
|
|
9
9
|
|
|
@@ -49,7 +49,8 @@ export default function ThemeSwitch() {
|
|
|
49
49
|
|
|
50
50
|
return (
|
|
51
51
|
<div className="flex items-center justify-between gap-3 px-3.5 py-2 border-b border-border">
|
|
52
|
-
<span id={labelId} className="text-sm text-text">
|
|
52
|
+
<span id={labelId} className="flex items-center gap-2 text-sm text-text">
|
|
53
|
+
<HiOutlinePaintBrush size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
53
54
|
Theme
|
|
54
55
|
</span>
|
|
55
56
|
<div role="radiogroup" aria-labelledby={labelId} className="inline-flex rounded-sm border border-border bg-surface-alt p-0.5">
|
|
@@ -5,7 +5,17 @@
|
|
|
5
5
|
import React, { useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
6
6
|
import { createPortal } from "react-dom";
|
|
7
7
|
import useIsMobile from "../../../../lib/util/useIsMobile.js";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
HiOutlineArrowRightOnRectangle,
|
|
10
|
+
HiOutlineArrowUturnLeft,
|
|
11
|
+
HiOutlineBell,
|
|
12
|
+
HiOutlineBellAlert,
|
|
13
|
+
HiOutlineClock,
|
|
14
|
+
HiOutlineCog6Tooth,
|
|
15
|
+
HiOutlineCommandLine,
|
|
16
|
+
HiOutlineShieldCheck,
|
|
17
|
+
HiOutlineUserCircle,
|
|
18
|
+
} from "react-icons/hi2";
|
|
9
19
|
import { formatProfileName, getMyProfile, getMyUsername, Profile, profileInitials } from "../../../../lib/auth/profileApi.js";
|
|
10
20
|
import { listMailboxes, Mailbox } from "../../../../lib/mail/mailApi.js";
|
|
11
21
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
@@ -46,8 +56,7 @@ export interface UserMenuProps {
|
|
|
46
56
|
trustedRoles?: readonly string[];
|
|
47
57
|
/** Shows a "Settings" item, above "Admin"/"Sign Out" — an account-scoped surface (like "Admin"), not
|
|
48
58
|
* a content-scoped one, so it lives here rather than as a 5th `AppShell` rail icon. Links straight to
|
|
49
|
-
* `/settings/
|
|
50
|
-
* `/settings` landing page once a second section (Mail Filters, Signatures) makes one worth building. */
|
|
59
|
+
* `/settings/profile`, the first settings section; repoint this at a real `/settings` landing page once one is worth building. */
|
|
51
60
|
showSettingsLink?: boolean;
|
|
52
61
|
/** Shows the notification controls, above "Admin Console"/"Sign Out": a "Notifications" on/off switch for every pop-up and,
|
|
53
62
|
* while the browser hasn't been asked, "Turn on desktop notifications". Both are per browser (see `notifications/preferences.ts`
|
|
@@ -346,8 +355,9 @@ export default function UserMenu({
|
|
|
346
355
|
<a
|
|
347
356
|
role="menuitem"
|
|
348
357
|
href={accountUrl}
|
|
349
|
-
className="
|
|
358
|
+
className="flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
|
|
350
359
|
>
|
|
360
|
+
<HiOutlineUserCircle size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
351
361
|
Account
|
|
352
362
|
</a>
|
|
353
363
|
)}
|
|
@@ -355,8 +365,9 @@ export default function UserMenu({
|
|
|
355
365
|
<a
|
|
356
366
|
role="menuitem"
|
|
357
367
|
href={SETTINGS_HREF}
|
|
358
|
-
className="
|
|
368
|
+
className="flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
|
|
359
369
|
>
|
|
370
|
+
<HiOutlineCog6Tooth size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
360
371
|
Settings
|
|
361
372
|
</a>
|
|
362
373
|
)}
|
|
@@ -368,7 +379,10 @@ export default function UserMenu({
|
|
|
368
379
|
onClick={togglePopups}
|
|
369
380
|
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"
|
|
370
381
|
>
|
|
371
|
-
<span>
|
|
382
|
+
<span className="flex items-center gap-2">
|
|
383
|
+
<HiOutlineBell size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
384
|
+
Notifications
|
|
385
|
+
</span>
|
|
372
386
|
<span className="text-xs font-semibold text-text-muted">{popups ? "On" : "Off"}</span>
|
|
373
387
|
</button>
|
|
374
388
|
)}
|
|
@@ -377,8 +391,9 @@ export default function UserMenu({
|
|
|
377
391
|
role="menuitem"
|
|
378
392
|
type="button"
|
|
379
393
|
onClick={() => void enableDesktopNotifications()}
|
|
380
|
-
className="
|
|
394
|
+
className="flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
|
|
381
395
|
>
|
|
396
|
+
<HiOutlineBellAlert size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
382
397
|
Turn on desktop notifications
|
|
383
398
|
</button>
|
|
384
399
|
)}
|
|
@@ -392,7 +407,10 @@ export default function UserMenu({
|
|
|
392
407
|
}}
|
|
393
408
|
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"
|
|
394
409
|
>
|
|
395
|
-
<span
|
|
410
|
+
<span className="flex items-center gap-2">
|
|
411
|
+
<HiOutlineClock size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
412
|
+
Recent notifications
|
|
413
|
+
</span>
|
|
396
414
|
{unseenErrors > 0 && (
|
|
397
415
|
<span className="rounded-pill bg-danger px-1.5 text-xs font-bold text-white">
|
|
398
416
|
<span aria-hidden="true">{unseenErrors}</span>
|
|
@@ -410,8 +428,9 @@ export default function UserMenu({
|
|
|
410
428
|
setOpen(false);
|
|
411
429
|
onShowShortcuts();
|
|
412
430
|
}}
|
|
413
|
-
className="
|
|
431
|
+
className="flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
|
|
414
432
|
>
|
|
433
|
+
<HiOutlineCommandLine size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
415
434
|
Keyboard shortcuts
|
|
416
435
|
</button>
|
|
417
436
|
)}
|
|
@@ -429,8 +448,9 @@ export default function UserMenu({
|
|
|
429
448
|
role="menuitem"
|
|
430
449
|
type="button"
|
|
431
450
|
onClick={onSignOut}
|
|
432
|
-
className="
|
|
451
|
+
className="flex w-full items-center gap-2 text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
|
|
433
452
|
>
|
|
453
|
+
<HiOutlineArrowRightOnRectangle size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
434
454
|
Sign Out
|
|
435
455
|
</button>
|
|
436
456
|
</div>,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from "../../../mail/folderCounts.js";
|
|
31
31
|
import { ALL_MAILBOXES_SECTION, useSidebarSections } from "../../../mail/useCollapsedSections.js";
|
|
32
32
|
import FolderBadgeChip from "./FolderBadgeChip.js";
|
|
33
|
+
import { FolderTypeIcon, SharedMailboxMark } from "./folderIcons.js";
|
|
33
34
|
import SidebarSection from "./SidebarSection.js";
|
|
34
35
|
import { useUnreadTitle } from "../../../mail/useUnreadTitle.js";
|
|
35
36
|
import { pendingCountFor, usePendingSends } from "../../../mail/outbox/pendingSends.js";
|
|
@@ -391,7 +392,10 @@ export default function MailShell({
|
|
|
391
392
|
: "text-text hover:bg-surface-alt",
|
|
392
393
|
].join(" ")}
|
|
393
394
|
>
|
|
394
|
-
<span className={badge?.kind === "unread" ? "font-semibold" :
|
|
395
|
+
<span className={["flex items-center gap-2.5 min-w-0", badge?.kind === "unread" ? "font-semibold" : ""].join(" ")}>
|
|
396
|
+
<FolderTypeIcon type={type} />
|
|
397
|
+
<span className="truncate">{FOLDER_LABELS[type]}</span>
|
|
398
|
+
</span>
|
|
395
399
|
{badge && <FolderBadgeChip badge={badge} />}
|
|
396
400
|
</a>
|
|
397
401
|
);
|
|
@@ -405,7 +409,7 @@ export default function MailShell({
|
|
|
405
409
|
label={
|
|
406
410
|
<>
|
|
407
411
|
{mailbox.displayName}
|
|
408
|
-
{mailbox.ownerUserUid
|
|
412
|
+
{!mailbox.ownerUserUid && <SharedMailboxMark />}
|
|
409
413
|
</>
|
|
410
414
|
}
|
|
411
415
|
collapsible={collapsible}
|
|
@@ -431,7 +435,10 @@ export default function MailShell({
|
|
|
431
435
|
data-folder-placeholder={row.type}
|
|
432
436
|
className="flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted"
|
|
433
437
|
>
|
|
434
|
-
<span
|
|
438
|
+
<span className="flex items-center gap-2.5 min-w-0">
|
|
439
|
+
<FolderTypeIcon type={row.type} />
|
|
440
|
+
<span className="truncate">{FOLDER_LABELS[row.type]}</span>
|
|
441
|
+
</span>
|
|
435
442
|
{row.type === "outbox" && <OutboxBadge total={sending} pendingHere={sending} />}
|
|
436
443
|
</div>
|
|
437
444
|
);
|
|
@@ -451,8 +458,9 @@ export default function MailShell({
|
|
|
451
458
|
: "text-text hover:bg-surface-alt",
|
|
452
459
|
].join(" ")}
|
|
453
460
|
>
|
|
454
|
-
<span className={badge?.kind === "unread" ? "font-semibold" :
|
|
455
|
-
{
|
|
461
|
+
<span className={["flex items-center gap-2.5 min-w-0", badge?.kind === "unread" ? "font-semibold" : ""].join(" ")}>
|
|
462
|
+
<FolderTypeIcon type={folder.type} />
|
|
463
|
+
<span className="truncate">{FOLDER_LABELS[folder.type] ?? folder.name}</span>
|
|
456
464
|
</span>
|
|
457
465
|
{badge &&
|
|
458
466
|
(folder.type === "outbox" ? (
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import type { IconType } from "react-icons";
|
|
7
|
+
import {
|
|
8
|
+
HiOutlineArchiveBox,
|
|
9
|
+
HiOutlineArrowUpTray,
|
|
10
|
+
HiOutlineBookOpen,
|
|
11
|
+
HiOutlineCalendarDays,
|
|
12
|
+
HiOutlineClipboardDocumentList,
|
|
13
|
+
HiOutlineFolder,
|
|
14
|
+
HiOutlineInbox,
|
|
15
|
+
HiOutlinePaperAirplane,
|
|
16
|
+
HiOutlinePencilSquare,
|
|
17
|
+
HiOutlineShieldExclamation,
|
|
18
|
+
HiOutlineTrash,
|
|
19
|
+
HiOutlineUsers,
|
|
20
|
+
HiUserGroup,
|
|
21
|
+
} from "react-icons/hi2";
|
|
22
|
+
import type { FolderType } from "../../../../../lib/mail/mailApi.js";
|
|
23
|
+
|
|
24
|
+
const FOLDER_ICONS: Record<FolderType, IconType> = {
|
|
25
|
+
inbox: HiOutlineInbox,
|
|
26
|
+
drafts: HiOutlinePencilSquare,
|
|
27
|
+
sent_items: HiOutlinePaperAirplane,
|
|
28
|
+
outbox: HiOutlineArrowUpTray,
|
|
29
|
+
junk: HiOutlineShieldExclamation,
|
|
30
|
+
archive: HiOutlineArchiveBox,
|
|
31
|
+
deleted_items: HiOutlineTrash,
|
|
32
|
+
calendar: HiOutlineCalendarDays,
|
|
33
|
+
contacts: HiOutlineUsers,
|
|
34
|
+
tasks: HiOutlineClipboardDocumentList,
|
|
35
|
+
notes: HiOutlineBookOpen,
|
|
36
|
+
user: HiOutlineFolder,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The icon drawn to the left of a folder's name: one per well-known folder, a plain folder for the user's own. */
|
|
40
|
+
export function FolderTypeIcon({ type }: { type: FolderType | string }) {
|
|
41
|
+
const Icon = FOLDER_ICONS[type as FolderType] ?? HiOutlineFolder;
|
|
42
|
+
return <Icon size={18} aria-hidden="true" className="shrink-0 text-text-muted" />;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What marks a mailbox other people share with this user, beside its name: the group icon, and for a screen reader the word it stands for. */
|
|
46
|
+
export function SharedMailboxMark() {
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<HiUserGroup size={14} aria-hidden="true" className="inline-block ml-1.5 align-[-2px] shrink-0 text-text-muted" />
|
|
50
|
+
{" "}
|
|
51
|
+
<span className="sr-only">(shared)</span>
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -3,7 +3,22 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
|
|
6
|
-
import {
|
|
6
|
+
import type { IconType } from "react-icons";
|
|
7
|
+
import {
|
|
8
|
+
HiOutlineArrowUturnLeft,
|
|
9
|
+
HiOutlineBars3,
|
|
10
|
+
HiOutlineEnvelopeOpen,
|
|
11
|
+
HiOutlineFunnel,
|
|
12
|
+
HiOutlineLockClosed,
|
|
13
|
+
HiOutlineNoSymbol,
|
|
14
|
+
HiOutlinePaintBrush,
|
|
15
|
+
HiOutlinePencilSquare,
|
|
16
|
+
HiOutlinePuzzlePiece,
|
|
17
|
+
HiOutlineShieldCheck,
|
|
18
|
+
HiOutlineTag,
|
|
19
|
+
HiOutlineUserCircle,
|
|
20
|
+
HiOutlineUsers,
|
|
21
|
+
} from "react-icons/hi2";
|
|
7
22
|
import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
8
23
|
import Drawer from "../../../../../lib/components/overlays/Drawer.js";
|
|
9
24
|
import { Mailbox, listMailboxes } from "../../../../../lib/mail/mailApi.js";
|
|
@@ -20,21 +35,23 @@ export interface SettingsSectionDef {
|
|
|
20
35
|
id: string;
|
|
21
36
|
href: string;
|
|
22
37
|
label: string;
|
|
38
|
+
/** Drawn beside the label in the sidebar. */
|
|
39
|
+
icon: IconType;
|
|
23
40
|
}
|
|
24
41
|
|
|
25
42
|
/** Every settings section with its own sidebar entry. */
|
|
26
43
|
export const SETTINGS_SECTIONS: SettingsSectionDef[] = [
|
|
27
|
-
{ id: "profile", href: "/settings/profile", label: "Profile" },
|
|
28
|
-
{ id: "appearance", href: "/settings/appearance", label: "Appearance" },
|
|
29
|
-
{ id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies" },
|
|
30
|
-
{ id: "filters", href: "/settings/filters", label: "Mail Filters" },
|
|
31
|
-
{ id: "blocked-senders", href: "/settings/blocked-senders", label: "Blocked & Safe Senders" },
|
|
32
|
-
{ id: "signatures", href: "/settings/signatures", label: "Signatures" },
|
|
33
|
-
{ id: "labels", href: "/settings/labels", label: "Labels" },
|
|
34
|
-
{ id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts" },
|
|
35
|
-
{ id: "encryption", href: "/settings/encryption", label: "Encryption" },
|
|
36
|
-
{ id: "sharing", href: "/settings/sharing", label: "Sharing" },
|
|
37
|
-
{ id: "privacy", href: "/settings/privacy", label: "Privacy & Data" },
|
|
44
|
+
{ id: "profile", href: "/settings/profile", label: "Profile", icon: HiOutlineUserCircle },
|
|
45
|
+
{ id: "appearance", href: "/settings/appearance", label: "Appearance", icon: HiOutlinePaintBrush },
|
|
46
|
+
{ id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies", icon: HiOutlineArrowUturnLeft },
|
|
47
|
+
{ id: "filters", href: "/settings/filters", label: "Mail Filters", icon: HiOutlineFunnel },
|
|
48
|
+
{ id: "blocked-senders", href: "/settings/blocked-senders", label: "Blocked & Safe Senders", icon: HiOutlineNoSymbol },
|
|
49
|
+
{ id: "signatures", href: "/settings/signatures", label: "Signatures", icon: HiOutlinePencilSquare },
|
|
50
|
+
{ id: "labels", href: "/settings/labels", label: "Labels", icon: HiOutlineTag },
|
|
51
|
+
{ id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts", icon: HiOutlineEnvelopeOpen },
|
|
52
|
+
{ id: "encryption", href: "/settings/encryption", label: "Encryption", icon: HiOutlineLockClosed },
|
|
53
|
+
{ id: "sharing", href: "/settings/sharing", label: "Sharing", icon: HiOutlineUsers },
|
|
54
|
+
{ id: "privacy", href: "/settings/privacy", label: "Privacy & Data", icon: HiOutlineShieldCheck },
|
|
38
55
|
];
|
|
39
56
|
|
|
40
57
|
/** A `SETTINGS_SECTIONS` id, or a plugin's `settingsSections` item id (see `PluginNav`). */
|
|
@@ -47,6 +64,7 @@ export function settingsSections(pluginNav?: PluginNav): SettingsSectionDef[] {
|
|
|
47
64
|
id,
|
|
48
65
|
href,
|
|
49
66
|
label,
|
|
67
|
+
icon: HiOutlinePuzzlePiece,
|
|
50
68
|
}));
|
|
51
69
|
}
|
|
52
70
|
|
|
@@ -207,10 +225,11 @@ export default function SettingsShell({
|
|
|
207
225
|
href={`${section.href}?mailboxUid=${encodeURIComponent(mailboxUid!)}`}
|
|
208
226
|
aria-current={section.id === active ? "page" : undefined}
|
|
209
227
|
className={[
|
|
210
|
-
"
|
|
228
|
+
"flex items-center gap-2.5 px-2.5 py-1.5 rounded-sm text-sm",
|
|
211
229
|
section.id === active ? "bg-primary/10 text-primary-dark font-medium" : "text-text hover:bg-surface-alt",
|
|
212
230
|
].join(" ")}
|
|
213
231
|
>
|
|
232
|
+
<section.icon size={18} aria-hidden="true" className="shrink-0" />
|
|
214
233
|
{section.label}
|
|
215
234
|
</a>
|
|
216
235
|
))}
|
|
@@ -12,10 +12,10 @@ export const APP_HREFS = {
|
|
|
12
12
|
} as const;
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Settings is reached from the account menu, not the rail. It links straight to the
|
|
16
|
-
* exists - repoint this at a real `/settings` landing page once there is one.
|
|
15
|
+
* Settings is reached from the account menu, not the rail. It links straight to the Profile section, the first of them and the one that
|
|
16
|
+
* always exists - repoint this at a real `/settings` landing page once there is one.
|
|
17
17
|
*/
|
|
18
|
-
export const SETTINGS_HREF = "/settings/
|
|
18
|
+
export const SETTINGS_HREF = "/settings/profile";
|
|
19
19
|
|
|
20
20
|
/** Whether `pathname` is a page of Settings (any section), which is what "already there" means for it. */
|
|
21
21
|
export function isSettingsPath(pathname: string): boolean {
|
|
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
import "../../../styles/app.css";
|
|
7
7
|
import { useEffect, useState } from "react";
|
|
8
8
|
import { useRouter } from "@rapidrest/react/client";
|
|
9
|
-
import { HiOutlineClipboardDocumentList, HiOutlineClock, HiOutlineCpuChip, HiOutlineDocumentArrowDown, HiOutlineDocumentCheck, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlineKey, HiOutlineLockClosed, HiOutlinePaintBrush, HiOutlinePuzzlePiece, HiOutlineQueueList, HiOutlineRocketLaunch, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, HiOutlineWrenchScrewdriver, HiOutlineBars3, } from "react-icons/hi2";
|
|
9
|
+
import { HiOutlineClipboardDocumentList, HiOutlineClock, HiOutlineCpuChip, HiOutlineDocumentArrowDown, HiOutlineDocumentCheck, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlineKey, HiOutlineLockClosed, HiOutlinePaintBrush, HiOutlinePuzzlePiece, HiOutlineQueueList, HiOutlineRocketLaunch, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, HiOutlineWrenchScrewdriver, HiOutlineBars3, HiOutlineChevronDoubleLeft, HiOutlineChevronDoubleRight, } from "react-icons/hi2";
|
|
10
10
|
import { apiFetch, ApiRequestError } from "../../../../../lib/util/api.js";
|
|
11
11
|
import { getSetupStatus } from "../../../../../lib/admin/setupApi.js";
|
|
12
12
|
import { useSessionRefresh } from "../../../../../lib/auth/session.js";
|
|
@@ -16,6 +16,7 @@ import Button from "../../../../../lib/components/buttons/Button.js";
|
|
|
16
16
|
import Drawer from "../../../../../lib/components/overlays/Drawer.js";
|
|
17
17
|
import { FrameBrandingFooter, useBrandingHtml } from "../../layout/BrandingChrome.js";
|
|
18
18
|
import RailIcon from "../../layout/RailIcon.js";
|
|
19
|
+
import { readRailExpanded, writeRailExpanded } from "./railPreference.js";
|
|
19
20
|
import AppearanceProvider from "../../../appearance/AppearanceProvider.js";
|
|
20
21
|
import UserMenu from "../../layout/UserMenu.js";
|
|
21
22
|
import { clearElevationAttempt, elevationAttemptedRecently, elevationUrl, isElevationRequired, recordElevationAttempt, } from "../elevation.js";
|
|
@@ -114,6 +115,15 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
|
|
|
114
115
|
const [status, setStatus] = useState("checking");
|
|
115
116
|
const [error, setError] = useState(null);
|
|
116
117
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
118
|
+
// The side rail shows icons alone, or icons with their labels. Read after the first render: the page is drawn on the server first, which cannot know.
|
|
119
|
+
const [railExpanded, setRailExpanded] = useState(false);
|
|
120
|
+
useEffect(() => setRailExpanded(readRailExpanded()), []);
|
|
121
|
+
function toggleRail() {
|
|
122
|
+
setRailExpanded((expanded) => {
|
|
123
|
+
writeRailExpanded(!expanded);
|
|
124
|
+
return !expanded;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
117
127
|
// `branding` only feeds the footer below: the admin-configured header is for the webmail and public pages, not the
|
|
118
128
|
// console. `useBranding()` is still what injects the custom stylesheet and supplies the rail's icon.
|
|
119
129
|
const { branding, iconSrc } = useBranding();
|
|
@@ -198,12 +208,19 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
|
|
|
198
208
|
else {
|
|
199
209
|
const navItems = adminNavItems(pluginNav);
|
|
200
210
|
const activeItem = [...navItems, ...OFF_RAIL_ITEMS].find((item) => item.id === active);
|
|
201
|
-
content = (_jsx("div", { className: "rr-frame-bg min-h-screen flex flex-col", children: _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Admin sections", className:
|
|
202
|
-
|
|
211
|
+
content = (_jsx("div", { className: "rr-frame-bg min-h-screen flex flex-col", children: _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Admin sections", className: [
|
|
212
|
+
"hidden md:flex shrink-0 bg-surface border-r border-border flex-col pb-3 gap-1",
|
|
213
|
+
railExpanded ? "w-56 items-stretch" : "w-16 items-center",
|
|
214
|
+
].join(" "), children: [_jsx(RailIcon, { src: iconSrc }), navItems.map(({ id, href, label, icon: Icon }) => (_jsxs("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: railExpanded ? undefined : label, className: [
|
|
215
|
+
"h-10 flex items-center rounded-sm",
|
|
216
|
+
railExpanded ? "gap-3 px-3 mx-2 text-sm font-medium" : "w-10 justify-center",
|
|
203
217
|
id === active
|
|
204
218
|
? "bg-primary/10 text-primary-dark"
|
|
205
219
|
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
206
|
-
].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true"
|
|
220
|
+
].join(" "), children: [_jsx(Icon, { size: 20, "aria-hidden": "true", className: "shrink-0" }), railExpanded && _jsx("span", { className: "truncate", children: label })] }, id))), _jsxs("button", { type: "button", "aria-expanded": railExpanded, "aria-label": railExpanded ? "Collapse the menu" : "Expand the menu", title: railExpanded ? "Collapse the menu" : "Expand the menu", onClick: toggleRail, className: [
|
|
221
|
+
"mt-auto h-10 flex items-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text",
|
|
222
|
+
railExpanded ? "gap-3 px-3 mx-2 text-sm font-medium" : "w-10 justify-center",
|
|
223
|
+
].join(" "), children: [railExpanded ? (_jsx(HiOutlineChevronDoubleLeft, { size: 20, "aria-hidden": "true", className: "shrink-0" })) : (_jsx(HiOutlineChevronDoubleRight, { size: 20, "aria-hidden": "true", className: "shrink-0" })), railExpanded && _jsx("span", { children: "Collapse" })] })] }), _jsx(Drawer, { open: menuOpen, onClose: () => setMenuOpen(false), title: "Admin", fullScreen: true, children: _jsx("nav", { "aria-label": "Admin menu", className: "flex flex-col gap-1 -mx-2", children: navItems.map(({ id, href, label, icon: Icon }) => (_jsxs("a", { href: href, "aria-current": id === active ? "page" : undefined, onClick: () => setMenuOpen(false), className: [
|
|
207
224
|
"flex items-center gap-3 px-3 py-2.5 rounded-sm text-sm font-medium",
|
|
208
225
|
id === active ? "bg-primary/10 text-primary-dark" : "text-text hover:bg-surface-alt",
|
|
209
226
|
].join(" "), children: [_jsx(Icon, { size: 20, "aria-hidden": "true" }), label] }, id))) }) }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-4 md:px-6", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("button", { type: "button", className: "md:hidden shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open menu", onClick: () => setMenuOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide truncate", children: activeItem?.label })] }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut, showMailLink: true })] }), _jsx("div", { id: "app-content", className: "flex-1", children: _jsx("main", { className: "max-w-6xl mx-auto px-6 py-8", children: children }) })] })] }) }));
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Where this browser remembers whether the admin console's side rail is showing its labels. */
|
|
2
|
+
export declare const RAIL_EXPANDED_KEY = "rapidmx:admin-rail-expanded";
|
|
3
|
+
/** Whether the rail was left expanded. A missing or blocked store means collapsed, as it always was. */
|
|
4
|
+
export declare function readRailExpanded(): boolean;
|
|
5
|
+
/** Remembers the rail's state. Best effort: a blocked store only means it starts collapsed the next time. */
|
|
6
|
+
export declare function writeRailExpanded(expanded: boolean): void;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** Where this browser remembers whether the admin console's side rail is showing its labels. */
|
|
6
|
+
export const RAIL_EXPANDED_KEY = "rapidmx:admin-rail-expanded";
|
|
7
|
+
/** Whether the rail was left expanded. A missing or blocked store means collapsed, as it always was. */
|
|
8
|
+
export function readRailExpanded() {
|
|
9
|
+
try {
|
|
10
|
+
return localStorage.getItem(RAIL_EXPANDED_KEY) === "true";
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Remembers the rail's state. Best effort: a blocked store only means it starts collapsed the next time. */
|
|
17
|
+
export function writeRailExpanded(expanded) {
|
|
18
|
+
try {
|
|
19
|
+
localStorage.setItem(RAIL_EXPANDED_KEY, String(expanded));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Nothing to do.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -7,6 +7,7 @@ import { useState } from "react";
|
|
|
7
7
|
import { CALENDAR_COLOR_PALETTE, accentColorForMailbox, colorForFolder } from "../../../../lib/calendar/calendarColors.js";
|
|
8
8
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
9
9
|
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
10
|
+
import { SharedMailboxMark } from "../mail/layout/folderIcons.js";
|
|
10
11
|
/**
|
|
11
12
|
* Calendar's left-sidebar checklist of calendars — one row per `calendar`-type folder, a color swatch,
|
|
12
13
|
* and a checkbox controlling whether that calendar's events are currently shown (the checked set is owned
|
|
@@ -49,7 +50,7 @@ export default function CalendarListSidebar({ mailboxCalendars, checkedFolderUid
|
|
|
49
50
|
}
|
|
50
51
|
const addForm = (_jsxs("form", { onSubmit: handleAdd, className: "flex flex-col gap-1.5 mt-2", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New calendar name", value: name, onChange: (e) => setName(e.target.value), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("div", { className: "flex items-center gap-1", role: "radiogroup", "aria-label": "Calendar color", children: CALENDAR_COLOR_PALETTE.map((swatch) => (_jsx("button", { type: "button", "aria-label": `Color ${swatch}`, "aria-pressed": color === swatch, onClick: () => setColor(swatch), className: ["w-4 h-4 rounded-full shrink-0", color === swatch ? "ring-2 ring-offset-1 ring-text" : ""].join(" "), style: { backgroundColor: swatch } }, swatch))) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "submit", disabled: saving, className: "text-xs font-semibold text-primary-dark", children: "Add" }), _jsx("button", { type: "button", onClick: () => setAddingFor(null), className: "text-xs text-text-muted", children: "Cancel" })] })] }));
|
|
51
52
|
return (_jsx("nav", { "aria-label": "My calendars", className: "p-3 border-t border-border flex flex-col gap-3", children: mailboxCalendars.map(({ mailbox, calendarFolders, error: mailboxError }) => {
|
|
52
|
-
const title = multiple ?
|
|
53
|
-
return (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between mb-1 gap-2", children: [_jsxs("h2", { className: "flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-text-muted min-w-0", children: [multiple && (_jsx("span", { "aria-hidden": "true", className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: accentColorForMailbox(mailbox.uid) } })),
|
|
53
|
+
const title = multiple ? mailbox.displayName : "My calendars";
|
|
54
|
+
return (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between mb-1 gap-2", children: [_jsxs("h2", { className: "flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-text-muted min-w-0", children: [multiple && (_jsx("span", { "aria-hidden": "true", className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: accentColorForMailbox(mailbox.uid) } })), _jsxs("span", { className: "truncate", children: [title, multiple && !mailbox.ownerUserUid && _jsx(SharedMailboxMark, {})] })] }), _jsx("button", { type: "button", onClick: () => startAdding(mailbox.uid), "aria-label": multiple ? `Add calendar to ${mailbox.displayName}` : "Add calendar", className: "text-xs font-bold text-primary-dark hover:underline shrink-0", children: "+" })] }), mailboxError && (_jsx("div", { className: "mb-1", children: _jsx(Alert, { children: mailboxError }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: calendarFolders.map((folder) => (_jsxs("label", { className: "flex items-center gap-2 text-sm px-1 py-1 rounded-sm hover:bg-surface-alt cursor-pointer", children: [_jsx("input", { type: "checkbox", checked: checkedFolderUids.has(folder.uid), onChange: () => onToggle(folder.uid), style: { accentColor: colorFor(folder) } }), _jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: colorFor(folder) } }), _jsx("span", { className: "truncate", children: folder.name })] }, folder.uid))) }), addingFor === mailbox.uid && addForm] }, mailbox.uid));
|
|
54
55
|
}) }));
|
|
55
56
|
}
|
|
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useContext, useId, useRef } from "react";
|
|
7
|
-
import { HiOutlineComputerDesktop, HiOutlineMoon, HiOutlineSun } from "react-icons/hi2";
|
|
7
|
+
import { HiOutlineComputerDesktop, HiOutlineMoon, HiOutlinePaintBrush, HiOutlineSun } from "react-icons/hi2";
|
|
8
8
|
import { AppearanceContext, INERT_APPEARANCE } from "../../appearance/appearanceContext.js";
|
|
9
9
|
const CHOICES = [
|
|
10
10
|
{ mode: "system", label: "System", hint: "System - follow this device", Icon: HiOutlineComputerDesktop },
|
|
@@ -42,7 +42,7 @@ export default function ThemeSwitch() {
|
|
|
42
42
|
choose(target, true);
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
return (_jsxs("div", { className: "flex items-center justify-between gap-3 px-3.5 py-2 border-b border-border", children: [
|
|
45
|
+
return (_jsxs("div", { className: "flex items-center justify-between gap-3 px-3.5 py-2 border-b border-border", children: [_jsxs("span", { id: labelId, className: "flex items-center gap-2 text-sm text-text", children: [_jsx(HiOutlinePaintBrush, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Theme"] }), _jsx("div", { role: "radiogroup", "aria-labelledby": labelId, className: "inline-flex rounded-sm border border-border bg-surface-alt p-0.5", children: CHOICES.map(({ mode, label, hint, Icon }, index) => {
|
|
46
46
|
const selected = mode === current;
|
|
47
47
|
return (_jsx("button", { ref: (element) => {
|
|
48
48
|
buttons.current[index] = element;
|
|
@@ -18,8 +18,7 @@ export interface UserMenuProps {
|
|
|
18
18
|
trustedRoles?: readonly string[];
|
|
19
19
|
/** Shows a "Settings" item, above "Admin"/"Sign Out" — an account-scoped surface (like "Admin"), not
|
|
20
20
|
* a content-scoped one, so it lives here rather than as a 5th `AppShell` rail icon. Links straight to
|
|
21
|
-
* `/settings/
|
|
22
|
-
* `/settings` landing page once a second section (Mail Filters, Signatures) makes one worth building. */
|
|
21
|
+
* `/settings/profile`, the first settings section; repoint this at a real `/settings` landing page once one is worth building. */
|
|
23
22
|
showSettingsLink?: boolean;
|
|
24
23
|
/** Shows the notification controls, above "Admin Console"/"Sign Out": a "Notifications" on/off switch for every pop-up and,
|
|
25
24
|
* while the browser hasn't been asked, "Turn on desktop notifications". Both are per browser (see `notifications/preferences.ts`
|
|
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
import { useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
7
7
|
import { createPortal } from "react-dom";
|
|
8
8
|
import useIsMobile from "../../../../lib/util/useIsMobile.js";
|
|
9
|
-
import { HiOutlineArrowUturnLeft, HiOutlineShieldCheck } from "react-icons/hi2";
|
|
9
|
+
import { HiOutlineArrowRightOnRectangle, HiOutlineArrowUturnLeft, HiOutlineBell, HiOutlineBellAlert, HiOutlineClock, HiOutlineCog6Tooth, HiOutlineCommandLine, HiOutlineShieldCheck, HiOutlineUserCircle, } from "react-icons/hi2";
|
|
10
10
|
import { formatProfileName, getMyProfile, getMyUsername, profileInitials } from "../../../../lib/auth/profileApi.js";
|
|
11
11
|
import { listMailboxes } from "../../../../lib/mail/mailApi.js";
|
|
12
12
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
@@ -206,11 +206,11 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
206
206
|
const accountUrl = accountUrlOf(authServerUrl);
|
|
207
207
|
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: "relative 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 }), unseenErrors > 0 && (_jsx("span", { "data-testid": "unseen-errors-dot", "aria-hidden": "true", className: "absolute right-0.5 top-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface bg-danger" })), _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 &&
|
|
208
208
|
anchor &&
|
|
209
|
-
createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", 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 })] }), showMailLink && (_jsxs("a", { role: "menuitem", href: APP_HREFS.mail, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Back to mail"] })), _jsx(ThemeSwitch, {}), accountUrl && (
|
|
209
|
+
createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", 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 })] }), showMailLink && (_jsxs("a", { role: "menuitem", href: APP_HREFS.mail, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Back to mail"] })), _jsx(ThemeSwitch, {}), accountUrl && (_jsxs("a", { role: "menuitem", href: accountUrl, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineUserCircle, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Account"] })), showSettingsLink && (_jsxs("a", { role: "menuitem", href: SETTINGS_HREF, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineCog6Tooth, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "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: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineBell, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Notifications"] }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineBellAlert, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Turn on desktop notifications"] })), onShowNotifications && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => {
|
|
210
210
|
setOpen(false);
|
|
211
211
|
onShowNotifications();
|
|
212
|
-
}, 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: [
|
|
212
|
+
}, 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: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineClock, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Recent notifications"] }), unseenErrors > 0 && (_jsxs("span", { className: "rounded-pill bg-danger px-1.5 text-xs font-bold text-white", children: [_jsx("span", { "aria-hidden": "true", children: unseenErrors }), _jsxs("span", { className: "sr-only", children: [unseenErrors, " unseen ", unseenErrors === 1 ? "error" : "errors"] })] }))] })), onShowShortcuts && !isMobile && (_jsxs("button", { role: "menuitem", type: "button", "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.global.help, env), onClick: () => {
|
|
213
213
|
setOpen(false);
|
|
214
214
|
onShowShortcuts();
|
|
215
|
-
}, className: "
|
|
215
|
+
}, className: "flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineCommandLine, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "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"] })), _jsxs("button", { role: "menuitem", type: "button", onClick: onSignOut, className: "flex w-full items-center gap-2 text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowRightOnRectangle, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Sign Out"] })] }), document.body)] }));
|
|
216
216
|
}
|
|
@@ -22,6 +22,7 @@ import { primaryMailboxUid } from "../../../mail/primaryMailbox.js";
|
|
|
22
22
|
import { badgeFor, countOfFolder, inboxUnreadTotal, unreadBadgeTotal, } from "../../../mail/folderCounts.js";
|
|
23
23
|
import { ALL_MAILBOXES_SECTION, useSidebarSections } from "../../../mail/useCollapsedSections.js";
|
|
24
24
|
import FolderBadgeChip from "./FolderBadgeChip.js";
|
|
25
|
+
import { FolderTypeIcon, SharedMailboxMark } from "./folderIcons.js";
|
|
25
26
|
import SidebarSection from "./SidebarSection.js";
|
|
26
27
|
import { useUnreadTitle } from "../../../mail/useUnreadTitle.js";
|
|
27
28
|
import { pendingCountFor, usePendingSends } from "../../../mail/outbox/pendingSends.js";
|
|
@@ -220,13 +221,13 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
220
221
|
aggregateFolderType === type
|
|
221
222
|
? "bg-primary/10 text-primary-dark font-semibold"
|
|
222
223
|
: "text-text hover:bg-surface-alt",
|
|
223
|
-
].join(" "), children: [
|
|
224
|
-
}) })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsx(SidebarSection, { domId: `${idPrefix}-mail-section-${mailbox.uid}`, label: _jsxs(_Fragment, { children: [mailbox.displayName, mailbox.ownerUserUid
|
|
224
|
+
].join(" "), children: [_jsxs("span", { className: ["flex items-center gap-2.5 min-w-0", badge?.kind === "unread" ? "font-semibold" : ""].join(" "), children: [_jsx(FolderTypeIcon, { type: type }), _jsx("span", { className: "truncate", children: FOLDER_LABELS[type] })] }), badge && _jsx(FolderBadgeChip, { badge: badge })] }, type));
|
|
225
|
+
}) })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsx(SidebarSection, { domId: `${idPrefix}-mail-section-${mailbox.uid}`, label: _jsxs(_Fragment, { children: [mailbox.displayName, !mailbox.ownerUserUid && _jsx(SharedMailboxMark, {})] }), collapsible: collapsible, expanded: sections.isExpanded(mailbox.uid), onToggle: () => sections.toggle(mailbox.uid), unread: unreadBadgeTotal(folders, counts), notice: mailboxError && (_jsx("div", { className: "px-2.5 pb-1", children: _jsx(Alert, { children: mailboxError }) })), children: folderRows(folders, pendingCountFor(mailbox.uid, pendingSends)).map((row) => {
|
|
225
226
|
if (row.kind === "placeholder") {
|
|
226
227
|
// A folder the server has not made (or told us about) yet - the Outbox and Sent Items are made with the first message
|
|
227
228
|
// sent - shown the moment a message is on its way. Nothing to open; the real folder replaces it when it arrives.
|
|
228
229
|
const sending = pendingCountFor(mailbox.uid, pendingSends);
|
|
229
|
-
return (_jsxs("div", { "data-folder-placeholder": row.type, className: "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted", children: [_jsx("span", { children: FOLDER_LABELS[row.type] }), row.type === "outbox" && _jsx(OutboxBadge, { total: sending, pendingHere: sending })] }, `placeholder-${row.type}`));
|
|
230
|
+
return (_jsxs("div", { "data-folder-placeholder": row.type, className: "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted", children: [_jsxs("span", { className: "flex items-center gap-2.5 min-w-0", children: [_jsx(FolderTypeIcon, { type: row.type }), _jsx("span", { className: "truncate", children: FOLDER_LABELS[row.type] })] }), row.type === "outbox" && _jsx(OutboxBadge, { total: sending, pendingHere: sending })] }, `placeholder-${row.type}`));
|
|
230
231
|
}
|
|
231
232
|
const folder = row.folder;
|
|
232
233
|
const badge = badgeFor(folder.type, countOfFolder(folder, counts));
|
|
@@ -235,7 +236,7 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
235
236
|
folder.uid === folderUid
|
|
236
237
|
? "bg-primary/10 text-primary-dark font-semibold"
|
|
237
238
|
: "text-text hover:bg-surface-alt",
|
|
238
|
-
].join(" "), children: [
|
|
239
|
+
].join(" "), children: [_jsxs("span", { className: ["flex items-center gap-2.5 min-w-0", badge?.kind === "unread" ? "font-semibold" : ""].join(" "), children: [_jsx(FolderTypeIcon, { type: folder.type }), _jsx("span", { className: "truncate", children: FOLDER_LABELS[folder.type] ?? folder.name })] }), badge &&
|
|
239
240
|
(folder.type === "outbox" ? (_jsx(OutboxBadge, { total: badge.value, status: outboxStatuses[folder.uid], pendingHere: pendingCountFor(mailbox.uid, pendingSends) })) : (_jsx(FolderBadgeChip, { badge: badge })))] }, folder.uid));
|
|
240
241
|
}) }, mailbox.uid)))] }))] }));
|
|
241
242
|
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("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", fullScreen: true, children: _jsx("div", { className: "flex flex-col", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsxs("div", { className: "md:hidden flex items-center gap-2 p-3", children: [_jsx("button", { type: "button", className: "shrink-0 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("div", { ref: setMobileSearchSlot, className: "flex-1 min-w-0" })] }), _jsx(MobileComposeButton, { mailboxUid: defaultMailboxUid }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { FolderType } from "../../../../../lib/mail/mailApi.js";
|
|
3
|
+
/** The icon drawn to the left of a folder's name: one per well-known folder, a plain folder for the user's own. */
|
|
4
|
+
export declare function FolderTypeIcon({ type }: {
|
|
5
|
+
type: FolderType | string;
|
|
6
|
+
}): React.JSX.Element;
|
|
7
|
+
/** What marks a mailbox other people share with this user, beside its name: the group icon, and for a screen reader the word it stands for. */
|
|
8
|
+
export declare function SharedMailboxMark(): React.JSX.Element;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { HiOutlineArchiveBox, HiOutlineArrowUpTray, HiOutlineBookOpen, HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineFolder, HiOutlineInbox, HiOutlinePaperAirplane, HiOutlinePencilSquare, HiOutlineShieldExclamation, HiOutlineTrash, HiOutlineUsers, HiUserGroup, } from "react-icons/hi2";
|
|
3
|
+
const FOLDER_ICONS = {
|
|
4
|
+
inbox: HiOutlineInbox,
|
|
5
|
+
drafts: HiOutlinePencilSquare,
|
|
6
|
+
sent_items: HiOutlinePaperAirplane,
|
|
7
|
+
outbox: HiOutlineArrowUpTray,
|
|
8
|
+
junk: HiOutlineShieldExclamation,
|
|
9
|
+
archive: HiOutlineArchiveBox,
|
|
10
|
+
deleted_items: HiOutlineTrash,
|
|
11
|
+
calendar: HiOutlineCalendarDays,
|
|
12
|
+
contacts: HiOutlineUsers,
|
|
13
|
+
tasks: HiOutlineClipboardDocumentList,
|
|
14
|
+
notes: HiOutlineBookOpen,
|
|
15
|
+
user: HiOutlineFolder,
|
|
16
|
+
};
|
|
17
|
+
/** The icon drawn to the left of a folder's name: one per well-known folder, a plain folder for the user's own. */
|
|
18
|
+
export function FolderTypeIcon({ type }) {
|
|
19
|
+
const Icon = FOLDER_ICONS[type] ?? HiOutlineFolder;
|
|
20
|
+
return _jsx(Icon, { size: 18, "aria-hidden": "true", className: "shrink-0 text-text-muted" });
|
|
21
|
+
}
|
|
22
|
+
/** What marks a mailbox other people share with this user, beside its name: the group icon, and for a screen reader the word it stands for. */
|
|
23
|
+
export function SharedMailboxMark() {
|
|
24
|
+
return (_jsxs(_Fragment, { children: [_jsx(HiUserGroup, { size: 14, "aria-hidden": "true", className: "inline-block ml-1.5 align-[-2px] shrink-0 text-text-muted" }), " ", _jsx("span", { className: "sr-only", children: "(shared)" })] }));
|
|
25
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React, { PropsWithChildren } from "react";
|
|
2
|
+
import type { IconType } from "react-icons";
|
|
2
3
|
import { Mailbox } from "../../../../../lib/mail/mailApi.js";
|
|
3
4
|
import { AppShellProps } from "../../layout/AppShell.js";
|
|
4
5
|
import { PluginNav } from "../../../plugins/pluginNav.js";
|
|
@@ -6,6 +7,8 @@ export interface SettingsSectionDef {
|
|
|
6
7
|
id: string;
|
|
7
8
|
href: string;
|
|
8
9
|
label: string;
|
|
10
|
+
/** Drawn beside the label in the sidebar. */
|
|
11
|
+
icon: IconType;
|
|
9
12
|
}
|
|
10
13
|
/** Every settings section with its own sidebar entry. */
|
|
11
14
|
export declare const SETTINGS_SECTIONS: SettingsSectionDef[];
|
|
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
7
|
-
import { HiOutlineBars3 } from "react-icons/hi2";
|
|
7
|
+
import { HiOutlineArrowUturnLeft, HiOutlineBars3, HiOutlineEnvelopeOpen, HiOutlineFunnel, HiOutlineLockClosed, HiOutlineNoSymbol, HiOutlinePaintBrush, HiOutlinePencilSquare, HiOutlinePuzzlePiece, HiOutlineShieldCheck, HiOutlineTag, HiOutlineUserCircle, HiOutlineUsers, } from "react-icons/hi2";
|
|
8
8
|
import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
9
9
|
import Drawer from "../../../../../lib/components/overlays/Drawer.js";
|
|
10
10
|
import { listMailboxes } from "../../../../../lib/mail/mailApi.js";
|
|
@@ -18,17 +18,17 @@ import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.
|
|
|
18
18
|
import { mergePluginNavItems } from "../../../plugins/pluginNav.js";
|
|
19
19
|
/** Every settings section with its own sidebar entry. */
|
|
20
20
|
export const SETTINGS_SECTIONS = [
|
|
21
|
-
{ id: "profile", href: "/settings/profile", label: "Profile" },
|
|
22
|
-
{ id: "appearance", href: "/settings/appearance", label: "Appearance" },
|
|
23
|
-
{ id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies" },
|
|
24
|
-
{ id: "filters", href: "/settings/filters", label: "Mail Filters" },
|
|
25
|
-
{ id: "blocked-senders", href: "/settings/blocked-senders", label: "Blocked & Safe Senders" },
|
|
26
|
-
{ id: "signatures", href: "/settings/signatures", label: "Signatures" },
|
|
27
|
-
{ id: "labels", href: "/settings/labels", label: "Labels" },
|
|
28
|
-
{ id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts" },
|
|
29
|
-
{ id: "encryption", href: "/settings/encryption", label: "Encryption" },
|
|
30
|
-
{ id: "sharing", href: "/settings/sharing", label: "Sharing" },
|
|
31
|
-
{ id: "privacy", href: "/settings/privacy", label: "Privacy & Data" },
|
|
21
|
+
{ id: "profile", href: "/settings/profile", label: "Profile", icon: HiOutlineUserCircle },
|
|
22
|
+
{ id: "appearance", href: "/settings/appearance", label: "Appearance", icon: HiOutlinePaintBrush },
|
|
23
|
+
{ id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies", icon: HiOutlineArrowUturnLeft },
|
|
24
|
+
{ id: "filters", href: "/settings/filters", label: "Mail Filters", icon: HiOutlineFunnel },
|
|
25
|
+
{ id: "blocked-senders", href: "/settings/blocked-senders", label: "Blocked & Safe Senders", icon: HiOutlineNoSymbol },
|
|
26
|
+
{ id: "signatures", href: "/settings/signatures", label: "Signatures", icon: HiOutlinePencilSquare },
|
|
27
|
+
{ id: "labels", href: "/settings/labels", label: "Labels", icon: HiOutlineTag },
|
|
28
|
+
{ id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts", icon: HiOutlineEnvelopeOpen },
|
|
29
|
+
{ id: "encryption", href: "/settings/encryption", label: "Encryption", icon: HiOutlineLockClosed },
|
|
30
|
+
{ id: "sharing", href: "/settings/sharing", label: "Sharing", icon: HiOutlineUsers },
|
|
31
|
+
{ id: "privacy", href: "/settings/privacy", label: "Privacy & Data", icon: HiOutlineShieldCheck },
|
|
32
32
|
];
|
|
33
33
|
/** `SETTINGS_SECTIONS` followed by the plugins' `settingsSections` items, core ids winning - see
|
|
34
34
|
* `mergePluginNavItems`. */
|
|
@@ -37,6 +37,7 @@ export function settingsSections(pluginNav) {
|
|
|
37
37
|
id,
|
|
38
38
|
href,
|
|
39
39
|
label,
|
|
40
|
+
icon: HiOutlinePuzzlePiece,
|
|
40
41
|
}));
|
|
41
42
|
}
|
|
42
43
|
const SettingsShellContext = createContext({ mailboxes: [] });
|
|
@@ -111,7 +112,7 @@ export default function SettingsShell({ active, userUid, authServerUrl, imperson
|
|
|
111
112
|
// server didn't send its nav item, so that falls back to the current path.
|
|
112
113
|
const href = sections.find((s) => s.id === active)?.href ?? window.location.pathname;
|
|
113
114
|
navigate(`${href}?mailboxUid=${encodeURIComponent(e.target.value)}`);
|
|
114
|
-
}, 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) => (
|
|
115
|
+
}, 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) => (_jsxs("a", {
|
|
115
116
|
// `inner` (this whole branch) is only ever computed once `status === "ready"`
|
|
116
117
|
// and `mailboxUid` has already resolved truthy — a falsy `mailboxUid` returns
|
|
117
118
|
// `<MailboxProvisioning />` above instead (which is what the `!` below records,
|
|
@@ -120,9 +121,9 @@ export default function SettingsShell({ active, userUid, authServerUrl, imperson
|
|
|
120
121
|
// structurally can't trigger" removal precedent (e.g.
|
|
121
122
|
// `DomainDetailContent.handleVerify`).
|
|
122
123
|
href: `${section.href}?mailboxUid=${encodeURIComponent(mailboxUid)}`, "aria-current": section.id === active ? "page" : undefined, className: [
|
|
123
|
-
"
|
|
124
|
+
"flex items-center gap-2.5 px-2.5 py-1.5 rounded-sm text-sm",
|
|
124
125
|
section.id === active ? "bg-primary/10 text-primary-dark font-medium" : "text-text hover:bg-surface-alt",
|
|
125
|
-
].join(" "), children: section.label }, section.id))) })] }));
|
|
126
|
+
].join(" "), children: [_jsx(section.icon, { size: 18, "aria-hidden": "true", className: "shrink-0" }), section.label] }, section.id))) })] }));
|
|
126
127
|
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", fullScreen: true, 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 })] })] }));
|
|
127
128
|
}
|
|
128
129
|
return (_jsx(AppShell, { active: "settings", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner }));
|
|
@@ -6,9 +6,9 @@ export declare const APP_HREFS: {
|
|
|
6
6
|
readonly tasks: "/tasks";
|
|
7
7
|
};
|
|
8
8
|
/**
|
|
9
|
-
* Settings is reached from the account menu, not the rail. It links straight to the
|
|
10
|
-
* exists - repoint this at a real `/settings` landing page once there is one.
|
|
9
|
+
* Settings is reached from the account menu, not the rail. It links straight to the Profile section, the first of them and the one that
|
|
10
|
+
* always exists - repoint this at a real `/settings` landing page once there is one.
|
|
11
11
|
*/
|
|
12
|
-
export declare const SETTINGS_HREF = "/settings/
|
|
12
|
+
export declare const SETTINGS_HREF = "/settings/profile";
|
|
13
13
|
/** Whether `pathname` is a page of Settings (any section), which is what "already there" means for it. */
|
|
14
14
|
export declare function isSettingsPath(pathname: string): boolean;
|
|
@@ -10,10 +10,10 @@ export const APP_HREFS = {
|
|
|
10
10
|
tasks: "/tasks",
|
|
11
11
|
};
|
|
12
12
|
/**
|
|
13
|
-
* Settings is reached from the account menu, not the rail. It links straight to the
|
|
14
|
-
* exists - repoint this at a real `/settings` landing page once there is one.
|
|
13
|
+
* Settings is reached from the account menu, not the rail. It links straight to the Profile section, the first of them and the one that
|
|
14
|
+
* always exists - repoint this at a real `/settings` landing page once there is one.
|
|
15
15
|
*/
|
|
16
|
-
export const SETTINGS_HREF = "/settings/
|
|
16
|
+
export const SETTINGS_HREF = "/settings/profile";
|
|
17
17
|
/** Whether `pathname` is a page of Settings (any section), which is what "already there" means for it. */
|
|
18
18
|
export function isSettingsPath(pathname) {
|
|
19
19
|
return pathname === "/settings" || pathname.startsWith("/settings/");
|
|
@@ -4,7 +4,7 @@ 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, useId, useMemo, useRef, useState } from "react";
|
|
7
|
-
import {
|
|
7
|
+
import { describeTimeZone, sortTimeZones } from "../../util/timeZone.js";
|
|
8
8
|
/**
|
|
9
9
|
* A time zone chooser that lists zones as "Los Angeles, America (GMT-07:00)", west to east and then by city, and has a search box in the list: typing narrows it to the zones whose
|
|
10
10
|
* name or offset contains every word typed ("berlin", "gmt+9", "america indiana"). A button showing the chosen zone opens it; the arrow keys move
|
|
@@ -19,10 +19,19 @@ export default function TimeZonePicker({ id, "aria-label": label, "aria-describe
|
|
|
19
19
|
const [open, setOpen] = useState(false);
|
|
20
20
|
const [query, setQuery] = useState("");
|
|
21
21
|
const [active, setActive] = useState(-1);
|
|
22
|
-
// Listed by the offsets shown (which move with daylight saving): west to east, then by city.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
// Listed by the offsets shown (which move with daylight saving): west to east, then by city. Worked out only while the list is open - naming several
|
|
23
|
+
// hundred zones is the costly part, and a closed picker (most renders) shows one - and keyed on the date's value, since a caller may hand over a new `Date` each render.
|
|
24
|
+
const atTime = at?.getTime();
|
|
25
|
+
const entries = useMemo(() => {
|
|
26
|
+
if (!open) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const on = new Date(atTime ?? Date.now());
|
|
30
|
+
return sortTimeZones(zones, on).map((zone) => {
|
|
31
|
+
const text = describeTimeZone(zone, on);
|
|
32
|
+
return { zone, text, haystack: `${text} ${zone.replace(/_/g, " ")}`.toLowerCase() };
|
|
33
|
+
});
|
|
34
|
+
}, [open, zones, atTime]);
|
|
26
35
|
const shown = useMemo(() => {
|
|
27
36
|
const words = query.toLowerCase().replace(/_/g, " ").split(/\s+/).filter(Boolean);
|
|
28
37
|
return words.length === 0 ? entries : entries.filter((entry) => words.every((word) => entry.haystack.includes(word)));
|
|
@@ -22,7 +22,7 @@ export declare function describeTimeZone(zone: string, at?: Date): string;
|
|
|
22
22
|
* Orders zones for a list: by how far their clocks are from UTC at `at` (the westernmost first), then by city, then by id. A zone the runtime
|
|
23
23
|
* doesn't know comes last.
|
|
24
24
|
*/
|
|
25
|
-
export declare function
|
|
25
|
+
export declare function sortTimeZones(zones: readonly string[], at?: Date): string[];
|
|
26
26
|
/**
|
|
27
27
|
* Every time zone the browser can name, for a picker (sorted by offset from UTC as it is now, then by city): `Intl.supportedValuesOf("timeZone")` where there is one (it leaves out `UTC`,
|
|
28
28
|
* which is added), else just `extra` - the zones the caller already holds, such as a mailbox's current one.
|
|
@@ -22,13 +22,25 @@ export function deviceTimeZone() {
|
|
|
22
22
|
*/
|
|
23
23
|
export function zoneClock(zone, at = new Date()) {
|
|
24
24
|
try {
|
|
25
|
-
const part = (style) =>
|
|
25
|
+
const part = (style) => formatterFor(zone, style).formatToParts(at).find((p) => p.type === "timeZoneName").value;
|
|
26
26
|
return { abbreviation: part("short"), offset: part("longOffset").replace("GMT", "") || "+00:00" };
|
|
27
27
|
}
|
|
28
28
|
catch {
|
|
29
29
|
return null;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
/** Building an `Intl.DateTimeFormat` is by far the costly part of naming a zone's clock, and a list names hundreds: each is made once. */
|
|
33
|
+
const formatters = new Map();
|
|
34
|
+
function formatterFor(zone, style) {
|
|
35
|
+
const key = `${style}|${zone}`;
|
|
36
|
+
let formatter = formatters.get(key);
|
|
37
|
+
if (!formatter) {
|
|
38
|
+
// Throws for a zone the runtime doesn't know, which is then not remembered.
|
|
39
|
+
formatter = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: style });
|
|
40
|
+
formatters.set(key, formatter);
|
|
41
|
+
}
|
|
42
|
+
return formatter;
|
|
43
|
+
}
|
|
32
44
|
/** A zone id split into its city and the region it is in: "America/Argentina/Buenos_Aires" is "Buenos Aires" in "America/Argentina"; "UTC" is in none. */
|
|
33
45
|
function zoneNames(zone) {
|
|
34
46
|
const parts = zone.split("/");
|
|
@@ -51,13 +63,15 @@ export function describeTimeZone(zone, at = new Date()) {
|
|
|
51
63
|
* Orders zones for a list: by how far their clocks are from UTC at `at` (the westernmost first), then by city, then by id. A zone the runtime
|
|
52
64
|
* doesn't know comes last.
|
|
53
65
|
*/
|
|
54
|
-
export function
|
|
55
|
-
|
|
66
|
+
export function sortTimeZones(zones, at = new Date()) {
|
|
67
|
+
// Each zone's offset is read once, not once per comparison.
|
|
68
|
+
const keyed = zones.map((zone) => {
|
|
56
69
|
const offset = zoneClock(zone, at)?.offset;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
const minutes = offset ? (offset.startsWith("-") ? -1 : 1) * (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(4, 6))) : Number.POSITIVE_INFINITY;
|
|
71
|
+
return { zone, minutes, city: zoneNames(zone).city };
|
|
72
|
+
});
|
|
73
|
+
keyed.sort((a, b) => (a.minutes === b.minutes ? 0 : a.minutes < b.minutes ? -1 : 1) || a.city.localeCompare(b.city) || a.zone.localeCompare(b.zone));
|
|
74
|
+
return keyed.map((entry) => entry.zone);
|
|
61
75
|
}
|
|
62
76
|
/**
|
|
63
77
|
* Every time zone the browser can name, for a picker (sorted by offset from UTC as it is now, then by city): `Intl.supportedValuesOf("timeZone")` where there is one (it leaves out `UTC`,
|
|
@@ -71,6 +85,5 @@ export function timeZoneOptions(...extra) {
|
|
|
71
85
|
catch {
|
|
72
86
|
zones = [];
|
|
73
87
|
}
|
|
74
|
-
|
|
75
|
-
return [...new Set([DEFAULT_TIME_ZONE, ...zones, ...extra.filter(Boolean)])].sort((a, b) => compareTimeZones(a, b, now));
|
|
88
|
+
return sortTimeZones([...new Set([DEFAULT_TIME_ZONE, ...zones, ...extra.filter(Boolean)])]);
|
|
76
89
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import React, { KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
6
|
-
import {
|
|
6
|
+
import { describeTimeZone, sortTimeZones } from "../../util/timeZone.js";
|
|
7
7
|
|
|
8
8
|
export interface TimeZonePickerProps {
|
|
9
9
|
id?: string;
|
|
@@ -34,14 +34,19 @@ export default function TimeZonePicker({ id, "aria-label": label, "aria-describe
|
|
|
34
34
|
const [query, setQuery] = useState("");
|
|
35
35
|
const [active, setActive] = useState(-1);
|
|
36
36
|
|
|
37
|
-
// Listed by the offsets shown (which move with daylight saving): west to east, then by city.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
// Listed by the offsets shown (which move with daylight saving): west to east, then by city. Worked out only while the list is open - naming several
|
|
38
|
+
// hundred zones is the costly part, and a closed picker (most renders) shows one - and keyed on the date's value, since a caller may hand over a new `Date` each render.
|
|
39
|
+
const atTime = at?.getTime();
|
|
40
|
+
const entries = useMemo(() => {
|
|
41
|
+
if (!open) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
const on = new Date(atTime ?? Date.now());
|
|
45
|
+
return sortTimeZones(zones, on).map((zone) => {
|
|
46
|
+
const text = describeTimeZone(zone, on);
|
|
47
|
+
return { zone, text, haystack: `${text} ${zone.replace(/_/g, " ")}`.toLowerCase() };
|
|
48
|
+
});
|
|
49
|
+
}, [open, zones, atTime]);
|
|
45
50
|
const shown = useMemo(() => {
|
|
46
51
|
const words = query.toLowerCase().replace(/_/g, " ").split(/\s+/).filter(Boolean);
|
|
47
52
|
return words.length === 0 ? entries : entries.filter((entry) => words.every((word) => entry.haystack.includes(word)));
|
package/lib/util/timeZone.ts
CHANGED
|
@@ -25,13 +25,27 @@ export function deviceTimeZone(): string {
|
|
|
25
25
|
export function zoneClock(zone: string, at: Date = new Date()): { abbreviation: string; offset: string } | null {
|
|
26
26
|
try {
|
|
27
27
|
const part = (style: "short" | "longOffset") =>
|
|
28
|
-
|
|
28
|
+
formatterFor(zone, style).formatToParts(at).find((p) => p.type === "timeZoneName")!.value;
|
|
29
29
|
return { abbreviation: part("short"), offset: part("longOffset").replace("GMT", "") || "+00:00" };
|
|
30
30
|
} catch {
|
|
31
31
|
return null;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Building an `Intl.DateTimeFormat` is by far the costly part of naming a zone's clock, and a list names hundreds: each is made once. */
|
|
36
|
+
const formatters = new Map<string, Intl.DateTimeFormat>();
|
|
37
|
+
|
|
38
|
+
function formatterFor(zone: string, style: "short" | "longOffset"): Intl.DateTimeFormat {
|
|
39
|
+
const key = `${style}|${zone}`;
|
|
40
|
+
let formatter = formatters.get(key);
|
|
41
|
+
if (!formatter) {
|
|
42
|
+
// Throws for a zone the runtime doesn't know, which is then not remembered.
|
|
43
|
+
formatter = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: style });
|
|
44
|
+
formatters.set(key, formatter);
|
|
45
|
+
}
|
|
46
|
+
return formatter;
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
/** A zone id split into its city and the region it is in: "America/Argentina/Buenos_Aires" is "Buenos Aires" in "America/Argentina"; "UTC" is in none. */
|
|
36
50
|
function zoneNames(zone: string): { city: string; region: string } {
|
|
37
51
|
const parts = zone.split("/");
|
|
@@ -56,13 +70,15 @@ export function describeTimeZone(zone: string, at: Date = new Date()): string {
|
|
|
56
70
|
* Orders zones for a list: by how far their clocks are from UTC at `at` (the westernmost first), then by city, then by id. A zone the runtime
|
|
57
71
|
* doesn't know comes last.
|
|
58
72
|
*/
|
|
59
|
-
export function
|
|
60
|
-
|
|
73
|
+
export function sortTimeZones(zones: readonly string[], at: Date = new Date()): string[] {
|
|
74
|
+
// Each zone's offset is read once, not once per comparison.
|
|
75
|
+
const keyed = zones.map((zone) => {
|
|
61
76
|
const offset = zoneClock(zone, at)?.offset;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
const minutes = offset ? (offset.startsWith("-") ? -1 : 1) * (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(4, 6))) : Number.POSITIVE_INFINITY;
|
|
78
|
+
return { zone, minutes, city: zoneNames(zone).city };
|
|
79
|
+
});
|
|
80
|
+
keyed.sort((a, b) => (a.minutes === b.minutes ? 0 : a.minutes < b.minutes ? -1 : 1) || a.city.localeCompare(b.city) || a.zone.localeCompare(b.zone));
|
|
81
|
+
return keyed.map((entry) => entry.zone);
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
/**
|
|
@@ -76,6 +92,5 @@ export function timeZoneOptions(...extra: string[]): string[] {
|
|
|
76
92
|
} catch {
|
|
77
93
|
zones = [];
|
|
78
94
|
}
|
|
79
|
-
|
|
80
|
-
return [...new Set([DEFAULT_TIME_ZONE, ...zones, ...extra.filter(Boolean)])].sort((a, b) => compareTimeZones(a, b, now));
|
|
95
|
+
return sortTimeZones([...new Set([DEFAULT_TIME_ZONE, ...zones, ...extra.filter(Boolean)])]);
|
|
81
96
|
}
|
package/package.json
CHANGED