@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.
Files changed (29) hide show
  1. package/apps/shared/components/admin/layout/AdminShell.tsx +39 -4
  2. package/apps/shared/components/admin/layout/railPreference.ts +25 -0
  3. package/apps/shared/components/calendar/CalendarListSidebar.tsx +6 -2
  4. package/apps/shared/components/layout/ThemeSwitch.tsx +3 -2
  5. package/apps/shared/components/layout/UserMenu.tsx +30 -10
  6. package/apps/shared/components/mail/layout/MailShell.tsx +13 -5
  7. package/apps/shared/components/mail/layout/folderIcons.tsx +54 -0
  8. package/apps/shared/components/settings/layout/SettingsShell.tsx +32 -13
  9. package/apps/shared/navigation/appHrefs.ts +3 -3
  10. package/dist/apps/shared/components/admin/layout/AdminShell.js +21 -4
  11. package/dist/apps/shared/components/admin/layout/railPreference.d.ts +6 -0
  12. package/dist/apps/shared/components/admin/layout/railPreference.js +24 -0
  13. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +3 -2
  14. package/dist/apps/shared/components/layout/ThemeSwitch.js +2 -2
  15. package/dist/apps/shared/components/layout/UserMenu.d.ts +1 -2
  16. package/dist/apps/shared/components/layout/UserMenu.js +4 -4
  17. package/dist/apps/shared/components/mail/layout/MailShell.js +5 -4
  18. package/dist/apps/shared/components/mail/layout/folderIcons.d.ts +8 -0
  19. package/dist/apps/shared/components/mail/layout/folderIcons.js +25 -0
  20. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  21. package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -15
  22. package/dist/apps/shared/navigation/appHrefs.d.ts +3 -3
  23. package/dist/apps/shared/navigation/appHrefs.js +3 -3
  24. package/dist/lib/components/pickers/TimeZonePicker.js +14 -5
  25. package/dist/lib/util/timeZone.d.ts +1 -1
  26. package/dist/lib/util/timeZone.js +22 -9
  27. package/lib/components/pickers/TimeZonePicker.tsx +14 -9
  28. package/lib/util/timeZone.ts +24 -9
  29. 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="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center pb-3 gap-1"
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
- "w-10 h-10 flex items-center justify-center rounded-sm",
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 ? `${mailbox.displayName}${mailbox.ownerUserUid ? "" : " (shared)"}` : "My calendars";
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">{title}</span>
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 { HiOutlineArrowUturnLeft, HiOutlineShieldCheck } from "react-icons/hi2";
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/auto-reply`, the only settings section that exists today; repoint this at a real
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="block px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
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="block px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
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>Notifications</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="block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
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>Recent notifications</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="block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
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="block w-full text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
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" : undefined}>{FOLDER_LABELS[type]}</span>
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 ? "" : " (shared)"}
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>{FOLDER_LABELS[row.type]}</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" : undefined}>
455
- {FOLDER_LABELS[folder.type] ?? folder.name}
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 { HiOutlineBars3 } from "react-icons/hi2";
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
- "block px-2.5 py-1.5 rounded-sm text-sm",
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 first settings section, the only one that always
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/auto-reply";
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: "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center pb-3 gap-1", children: [_jsx(RailIcon, { src: iconSrc }), navItems.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
202
- "w-10 h-10 flex items-center justify-center rounded-sm",
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" }) }, id)))] }), _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: [
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 ? `${mailbox.displayName}${mailbox.ownerUserUid ? "" : " (shared)"}` : "My calendars";
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) } })), _jsx("span", { className: "truncate", children: title })] }), _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));
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: [_jsx("span", { id: labelId, className: "text-sm text-text", children: "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) => {
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/auto-reply`, the only settings section that exists today; repoint this at a real
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 && (_jsx("a", { role: "menuitem", href: accountUrl, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Account" })), showSettingsLink && (_jsx("a", { role: "menuitem", href: SETTINGS_HREF, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Settings" })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": popups, type: "button", onClick: togglePopups, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx("span", { children: "Notifications" }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsx("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: "Turn on desktop notifications" })), onShowNotifications && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => {
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: [_jsx("span", { children: "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 && (_jsx("button", { role: "menuitem", type: "button", "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.global.help, env), onClick: () => {
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: "block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: "Keyboard shortcuts" })), (showAdminLink || detectedAdmin) && (_jsxs("a", { role: "menuitem", href: "/admin", className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineShieldCheck, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Admin Console"] })), _jsx("button", { role: "menuitem", type: "button", onClick: onSignOut, className: "block w-full text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Sign Out" })] }), document.body)] }));
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: [_jsx("span", { className: badge?.kind === "unread" ? "font-semibold" : undefined, children: FOLDER_LABELS[type] }), badge && _jsx(FolderBadgeChip, { badge: badge })] }, type));
224
- }) })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsx(SidebarSection, { domId: `${idPrefix}-mail-section-${mailbox.uid}`, label: _jsxs(_Fragment, { children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }), 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) => {
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: [_jsx("span", { className: badge?.kind === "unread" ? "font-semibold" : undefined, children: FOLDER_LABELS[folder.type] ?? folder.name }), badge &&
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) => (_jsx("a", {
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
- "block px-2.5 py-1.5 rounded-sm text-sm",
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 first settings section, the only one that always
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/auto-reply";
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 first settings section, the only one that always
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/auto-reply";
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 { compareTimeZones, describeTimeZone } from "../../util/timeZone.js";
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
- const entries = useMemo(() => [...zones]
24
- .sort((a, b) => compareTimeZones(a, b, at))
25
- .map((zone) => ({ zone, text: describeTimeZone(zone, at), haystack: `${describeTimeZone(zone, at)} ${zone.replace(/_/g, " ")}`.toLowerCase() })), [zones, at]);
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 compareTimeZones(a: string, b: string, at?: Date): number;
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) => new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: style }).formatToParts(at).find((p) => p.type === "timeZoneName").value;
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 compareTimeZones(a, b, at = new Date()) {
55
- const minutes = (zone) => {
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
- return offset ? (offset.startsWith("-") ? -1 : 1) * (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(4, 6))) : Number.POSITIVE_INFINITY;
58
- };
59
- const byOffset = minutes(a) - minutes(b);
60
- return (Number.isNaN(byOffset) ? 0 : byOffset) || zoneNames(a).city.localeCompare(zoneNames(b).city) || a.localeCompare(b);
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
- const now = new Date();
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 { compareTimeZones, describeTimeZone } from "../../util/timeZone.js";
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
- const entries = useMemo(
39
- () =>
40
- [...zones]
41
- .sort((a, b) => compareTimeZones(a, b, at))
42
- .map((zone) => ({ zone, text: describeTimeZone(zone, at), haystack: `${describeTimeZone(zone, at)} ${zone.replace(/_/g, " ")}`.toLowerCase() })),
43
- [zones, at],
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)));
@@ -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
- new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: style }).formatToParts(at).find((p) => p.type === "timeZoneName")!.value;
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 compareTimeZones(a: string, b: string, at: Date = new Date()): number {
60
- const minutes = (zone: string) => {
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
- return offset ? (offset.startsWith("-") ? -1 : 1) * (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(4, 6))) : Number.POSITIVE_INFINITY;
63
- };
64
- const byOffset = minutes(a) - minutes(b);
65
- return (Number.isNaN(byOffset) ? 0 : byOffset) || zoneNames(a).city.localeCompare(zoneNames(b).city) || a.localeCompare(b);
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
- const now = new Date();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/web-client",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
4
4
  "description": "RapidMX's webmail and admin console React UI for web-based clients.",
5
5
  "type": "module",
6
6
  "repository": "https://github.com/rapidmx/web-client",