@rapidmx/web-client 0.16.0 → 0.17.2

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 (184) hide show
  1. package/README.md +36 -28
  2. package/apps/admin/distribution-lists/new/index.tsx +3 -1
  3. package/apps/admin/domains/[uid].tsx +3 -1
  4. package/apps/admin/domains/new/index.tsx +3 -1
  5. package/apps/admin/escrow-scopes/[uid].tsx +3 -1
  6. package/apps/admin/escrow-scopes/new/index.tsx +3 -1
  7. package/apps/admin/index.tsx +3 -1
  8. package/apps/admin/mailboxes/[uid].tsx +3 -1
  9. package/apps/admin/mailboxes/new/index.tsx +3 -1
  10. package/apps/admin/transport-rules/new/index.tsx +3 -1
  11. package/apps/escrow/matters/new/index.tsx +3 -1
  12. package/apps/shared/components/admin/layout/AdminShell.tsx +4 -1
  13. package/apps/shared/components/admin/setup/SetupWizard.tsx +3 -1
  14. package/apps/shared/components/calendar/layout/CalendarShell.tsx +2 -2
  15. package/apps/shared/components/contacts/layout/ContactsShell.tsx +2 -2
  16. package/apps/shared/components/layout/AppShell.tsx +24 -43
  17. package/apps/shared/components/mail/ConversationThreadPane.tsx +1 -0
  18. package/apps/shared/components/mail/EmptyFolderBar.tsx +41 -0
  19. package/apps/shared/components/mail/MailSelectionBar.tsx +19 -15
  20. package/apps/shared/components/mail/MenuButton.tsx +31 -11
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +202 -4
  22. package/apps/shared/components/mail/PermanentDeleteDialog.tsx +47 -0
  23. package/apps/shared/components/mail/layout/MailShell.tsx +36 -34
  24. package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -0
  25. package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -0
  26. package/apps/shared/components/mail/reading/messageExport.ts +59 -0
  27. package/apps/shared/components/mail/reading/printMessage.ts +99 -0
  28. package/apps/shared/components/mail/reading/useMessageActions.ts +443 -0
  29. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -2
  30. package/apps/shared/components/tasks/layout/TasksShell.tsx +2 -2
  31. package/apps/shared/keyboard/GlobalShortcuts.tsx +1 -1
  32. package/apps/shared/mail/folderOfType.ts +49 -0
  33. package/apps/shared/mail/permanentDelete.ts +160 -0
  34. package/apps/shared/mail/reportNotices.ts +66 -0
  35. package/apps/shared/mail/senderBlocking.ts +141 -0
  36. package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -0
  37. package/apps/shared/mail/usePermanentDelete.tsx +160 -0
  38. package/apps/shared/navigation/frameContext.tsx +1 -1
  39. package/apps/shared/navigation/index.ts +70 -0
  40. package/apps/shared/navigation/pageTitle.ts +19 -0
  41. package/apps/www/_shell.tsx +82 -0
  42. package/apps/www/calendar/index.tsx +5 -2
  43. package/apps/www/contacts/[uid].tsx +6 -3
  44. package/apps/www/contacts/index.tsx +8 -3
  45. package/apps/www/index.tsx +146 -31
  46. package/apps/www/messages/[uid].tsx +8 -4
  47. package/apps/www/settings/appearance/index.tsx +5 -2
  48. package/apps/www/settings/auto-reply/index.tsx +5 -2
  49. package/apps/www/settings/blocked-senders/index.tsx +303 -0
  50. package/apps/www/settings/encryption/index.tsx +5 -2
  51. package/apps/www/settings/filters/[uid].tsx +5 -2
  52. package/apps/www/settings/filters/index.tsx +5 -2
  53. package/apps/www/settings/filters/new/index.tsx +30 -5
  54. package/apps/www/settings/labels/index.tsx +5 -2
  55. package/apps/www/settings/privacy/index.tsx +5 -2
  56. package/apps/www/settings/profile/index.tsx +5 -2
  57. package/apps/www/settings/read-receipts/index.tsx +5 -2
  58. package/apps/www/settings/sharing/index.tsx +5 -2
  59. package/apps/www/settings/signatures/[uid].tsx +5 -2
  60. package/apps/www/settings/signatures/index.tsx +5 -2
  61. package/apps/www/settings/signatures/new/index.tsx +6 -3
  62. package/apps/www/tasks/index.tsx +7 -2
  63. package/dist/apps/admin/distribution-lists/new/index.js +3 -1
  64. package/dist/apps/admin/domains/[uid].js +3 -1
  65. package/dist/apps/admin/domains/new/index.js +3 -1
  66. package/dist/apps/admin/escrow-scopes/[uid].js +3 -1
  67. package/dist/apps/admin/escrow-scopes/new/index.js +3 -1
  68. package/dist/apps/admin/index.js +3 -1
  69. package/dist/apps/admin/mailboxes/[uid].js +3 -1
  70. package/dist/apps/admin/mailboxes/new/index.js +3 -1
  71. package/dist/apps/admin/transport-rules/new/index.js +3 -1
  72. package/dist/apps/escrow/matters/new/index.js +3 -1
  73. package/dist/apps/shared/components/admin/layout/AdminShell.js +4 -1
  74. package/dist/apps/shared/components/admin/setup/SetupWizard.js +3 -1
  75. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +2 -2
  76. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +2 -2
  77. package/dist/apps/shared/components/layout/AppShell.d.ts +8 -11
  78. package/dist/apps/shared/components/layout/AppShell.js +24 -33
  79. package/dist/apps/shared/components/mail/ConversationThreadPane.js +1 -1
  80. package/dist/apps/shared/components/mail/EmptyFolderBar.d.ts +18 -0
  81. package/dist/apps/shared/components/mail/EmptyFolderBar.js +10 -0
  82. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +9 -4
  83. package/dist/apps/shared/components/mail/MailSelectionBar.js +10 -12
  84. package/dist/apps/shared/components/mail/MenuButton.d.ts +9 -2
  85. package/dist/apps/shared/components/mail/MenuButton.js +15 -11
  86. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +5 -0
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +147 -7
  88. package/dist/apps/shared/components/mail/PermanentDeleteDialog.d.ts +19 -0
  89. package/dist/apps/shared/components/mail/PermanentDeleteDialog.js +11 -0
  90. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +13 -9
  91. package/dist/apps/shared/components/mail/layout/MailShell.js +26 -33
  92. package/dist/apps/shared/components/mail/reading/MessageMoreMenu.d.ts +56 -0
  93. package/dist/apps/shared/components/mail/reading/MessageMoreMenu.js +153 -0
  94. package/dist/apps/shared/components/mail/reading/MessageSourceDialog.d.ts +27 -0
  95. package/dist/apps/shared/components/mail/reading/MessageSourceDialog.js +27 -0
  96. package/dist/apps/shared/components/mail/reading/messageExport.d.ts +19 -0
  97. package/dist/apps/shared/components/mail/reading/messageExport.js +51 -0
  98. package/dist/apps/shared/components/mail/reading/printMessage.d.ts +30 -0
  99. package/dist/apps/shared/components/mail/reading/printMessage.js +70 -0
  100. package/dist/apps/shared/components/mail/reading/useMessageActions.d.ts +57 -0
  101. package/dist/apps/shared/components/mail/reading/useMessageActions.js +366 -0
  102. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  103. package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -2
  104. package/dist/apps/shared/keyboard/GlobalShortcuts.js +1 -1
  105. package/dist/apps/shared/mail/folderOfType.d.ts +13 -0
  106. package/dist/apps/shared/mail/folderOfType.js +40 -0
  107. package/dist/apps/shared/mail/permanentDelete.d.ts +62 -0
  108. package/dist/apps/shared/mail/permanentDelete.js +127 -0
  109. package/dist/apps/shared/mail/reportNotices.d.ts +21 -0
  110. package/dist/apps/shared/mail/reportNotices.js +47 -0
  111. package/dist/apps/shared/mail/senderBlocking.d.ts +26 -0
  112. package/dist/apps/shared/mail/senderBlocking.js +92 -0
  113. package/dist/apps/shared/mail/useMailboxUpdateAccess.d.ts +11 -0
  114. package/dist/apps/shared/mail/useMailboxUpdateAccess.js +52 -0
  115. package/dist/apps/shared/mail/usePermanentDelete.d.ts +35 -0
  116. package/dist/apps/shared/mail/usePermanentDelete.js +96 -0
  117. package/dist/apps/shared/navigation/frameContext.d.ts +1 -1
  118. package/dist/apps/shared/navigation/index.d.ts +27 -0
  119. package/dist/apps/shared/navigation/index.js +58 -0
  120. package/dist/apps/shared/navigation/pageTitle.d.ts +11 -0
  121. package/dist/apps/shared/navigation/pageTitle.js +12 -0
  122. package/dist/apps/www/_shell.d.ts +26 -0
  123. package/dist/apps/www/_shell.js +31 -0
  124. package/dist/apps/www/calendar/index.d.ts +7 -2
  125. package/dist/apps/www/calendar/index.js +4 -2
  126. package/dist/apps/www/contacts/[uid].d.ts +14 -3
  127. package/dist/apps/www/contacts/[uid].js +5 -3
  128. package/dist/apps/www/contacts/index.d.ts +7 -2
  129. package/dist/apps/www/contacts/index.js +6 -4
  130. package/dist/apps/www/index.d.ts +7 -2
  131. package/dist/apps/www/index.js +117 -19
  132. package/dist/apps/www/messages/[uid].d.ts +14 -3
  133. package/dist/apps/www/messages/[uid].js +7 -5
  134. package/dist/apps/www/settings/appearance/index.d.ts +8 -2
  135. package/dist/apps/www/settings/appearance/index.js +4 -2
  136. package/dist/apps/www/settings/auto-reply/index.d.ts +7 -2
  137. package/dist/apps/www/settings/auto-reply/index.js +4 -2
  138. package/dist/apps/www/settings/blocked-senders/index.d.ts +9 -0
  139. package/dist/apps/www/settings/blocked-senders/index.js +134 -0
  140. package/dist/apps/www/settings/encryption/index.d.ts +7 -2
  141. package/dist/apps/www/settings/encryption/index.js +4 -2
  142. package/dist/apps/www/settings/filters/[uid].d.ts +7 -2
  143. package/dist/apps/www/settings/filters/[uid].js +4 -2
  144. package/dist/apps/www/settings/filters/index.d.ts +7 -2
  145. package/dist/apps/www/settings/filters/index.js +4 -2
  146. package/dist/apps/www/settings/filters/new/index.d.ts +7 -2
  147. package/dist/apps/www/settings/filters/new/index.js +28 -5
  148. package/dist/apps/www/settings/labels/index.d.ts +7 -2
  149. package/dist/apps/www/settings/labels/index.js +4 -2
  150. package/dist/apps/www/settings/privacy/index.d.ts +7 -2
  151. package/dist/apps/www/settings/privacy/index.js +4 -2
  152. package/dist/apps/www/settings/profile/index.d.ts +7 -2
  153. package/dist/apps/www/settings/profile/index.js +4 -2
  154. package/dist/apps/www/settings/read-receipts/index.d.ts +7 -2
  155. package/dist/apps/www/settings/read-receipts/index.js +4 -2
  156. package/dist/apps/www/settings/sharing/index.d.ts +7 -2
  157. package/dist/apps/www/settings/sharing/index.js +4 -2
  158. package/dist/apps/www/settings/signatures/[uid].d.ts +7 -2
  159. package/dist/apps/www/settings/signatures/[uid].js +4 -2
  160. package/dist/apps/www/settings/signatures/index.d.ts +7 -2
  161. package/dist/apps/www/settings/signatures/index.js +4 -2
  162. package/dist/apps/www/settings/signatures/new/index.d.ts +7 -2
  163. package/dist/apps/www/settings/signatures/new/index.js +5 -3
  164. package/dist/apps/www/tasks/index.d.ts +7 -2
  165. package/dist/apps/www/tasks/index.js +5 -3
  166. package/package.json +4 -2
  167. package/apps/shared/navigation/AppRouter.tsx +0 -300
  168. package/apps/shared/navigation/idle.ts +0 -45
  169. package/apps/shared/navigation/routerContext.tsx +0 -83
  170. package/apps/shared/navigation/routes.ts +0 -72
  171. package/apps/www/_routedPage.tsx +0 -24
  172. package/apps/www/_routes.ts +0 -37
  173. package/dist/apps/shared/navigation/AppRouter.d.ts +0 -52
  174. package/dist/apps/shared/navigation/AppRouter.js +0 -242
  175. package/dist/apps/shared/navigation/idle.d.ts +0 -14
  176. package/dist/apps/shared/navigation/idle.js +0 -43
  177. package/dist/apps/shared/navigation/routerContext.d.ts +0 -37
  178. package/dist/apps/shared/navigation/routerContext.js +0 -56
  179. package/dist/apps/shared/navigation/routes.d.ts +0 -32
  180. package/dist/apps/shared/navigation/routes.js +0 -37
  181. package/dist/apps/www/_routedPage.d.ts +0 -12
  182. package/dist/apps/www/_routedPage.js +0 -19
  183. package/dist/apps/www/_routes.d.ts +0 -11
  184. package/dist/apps/www/_routes.js +0 -31
@@ -0,0 +1,12 @@
1
+ /** The name the deployment goes by in the browser tab: its branding title, else its company name, else RapidMX. */
2
+ export function brandName(branding) {
3
+ return branding?.title || branding?.companyName || "RapidMX";
4
+ }
5
+ /**
6
+ * The `title` export of a webmail page: `export const title = pageTitle("Calendar")`. `@rapidrest/react` renders it into the
7
+ * document's `<title>` on the server - so the tab is right from the first byte, and a crawler sees it - and sends it with every
8
+ * client navigation, so the tab follows the page. `label` is the app's name (`Acme: Calendar`), the same for all of an app's pages.
9
+ */
10
+ export function pageTitle(label) {
11
+ return ({ branding }) => `${brandName(branding)}: ${label}`;
12
+ }
@@ -0,0 +1,26 @@
1
+ import React, { PropsWithChildren } from "react";
2
+ import { type AppShellActive, type AppShellProps } from "../shared/components/layout/AppShell.js";
3
+ /**
4
+ * The webmail's persistent app frame: `@rapidrest/react`'s app shell (`_shell.tsx`), rendered around every page of `apps/www` -
5
+ * on the server, in the same tree the browser hydrates - and kept mounted while the router swaps the pages inside it, so that
6
+ * moving between Mail, Calendar, Contacts, Tasks and Settings replaces only the page. It is what holds the state that must outlive
7
+ * a page: the compose windows in progress, the unlock prompt, the idle-key timer, the sign-out listener, the mail connection (one
8
+ * push socket for every app), the user menu and the notification pop-ups (all `AppChrome`'s).
9
+ *
10
+ * The props are the current page's - `WwwRoute.fetchProps()`'s, the same for every page: the user, the auth-server URL, the
11
+ * branding, the plugin navigation, the impersonation state and the appearance - and are fetched again on every navigation, so a
12
+ * change of any of them (a plugin installed, the branding edited) shows on the next page. A page still renders its own shell
13
+ * (`MailShell`, `CalendarShell`, ...) with `AppShell` inside it as it always did; inside this frame that renders only its children.
14
+ *
15
+ * What a page load did by itself and the router now does after each navigation - focus moves to the content, the window scrolls to
16
+ * the top, a screen reader is told the page's title - is configured here (`useNavigationEffects()`); the tab's title is each
17
+ * page's `title` export (`pageTitle()`). `busy` is the router's pending state: the content is `aria-busy` while the next page
18
+ * loads and the old one is still on screen.
19
+ */
20
+ export type WwwShellProps = PropsWithChildren<Omit<AppShellProps, "active">>;
21
+ /**
22
+ * The app-rail entry (or `"settings"`, which has none) that a route highlights: the first segment of its template names the
23
+ * app. Anything else - Mail's `/` and `/messages/:uid` - is Mail.
24
+ */
25
+ export declare function activeAppOf(route: string): AppShellActive;
26
+ export default function WwwShell({ children, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, branding, appearance, }: WwwShellProps): React.JSX.Element;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useCallback, useMemo, useState } from "react";
7
+ import { useNavigationEffects, useRouter } from "@rapidrest/react/client";
8
+ import { AppChrome } from "../shared/components/layout/AppShell.js";
9
+ import { AppFrameContext } from "../shared/navigation/frameContext.js";
10
+ import { titleWithUnread } from "../shared/mail/useUnreadTitle.js";
11
+ /**
12
+ * The app-rail entry (or `"settings"`, which has none) that a route highlights: the first segment of its template names the
13
+ * app. Anything else - Mail's `/` and `/messages/:uid` - is Mail.
14
+ */
15
+ export function activeAppOf(route) {
16
+ const app = route.split("/")[1];
17
+ return app === "calendar" || app === "contacts" || app === "tasks" || app === "settings" ? app : "mail";
18
+ }
19
+ export default function WwwShell({ children, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, branding, appearance, }) {
20
+ const { route, pending } = useRouter();
21
+ const [takeovers, setTakeovers] = useState(0);
22
+ const enterTakeover = useCallback(() => {
23
+ setTakeovers((n) => n + 1);
24
+ return () => setTakeovers((n) => n - 1);
25
+ }, []);
26
+ const frame = useMemo(() => ({ enterTakeover }), [enterTakeover]);
27
+ // The content region is what a page load would have started at: focus it (it is only focusable by script), start at the top, and say the
28
+ // page's title - without the unread count that the tab's title carries in front of it.
29
+ useNavigationEffects({ focus: "#app-content", scroll: "top", announce: ({ title }) => titleWithUnread(title, 0) });
30
+ return (_jsx(AppFrameContext.Provider, { value: frame, children: _jsx(AppChrome, { active: activeAppOf(route), busy: pending, hideChrome: takeovers > 0, userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, branding: branding, appearance: appearance, children: children }) }));
31
+ }
@@ -1,3 +1,8 @@
1
+ import React from "react";
1
2
  import { CalendarShellProps } from "../../shared/components/calendar/layout/CalendarShell.js";
2
- declare const _default: import("../../shared/navigation/AppRouter.js").RoutedPageComponent<CalendarShellProps>;
3
- export default _default;
3
+ declare function CalendarPage(props: CalendarShellProps): React.JSX.Element;
4
+ export default CalendarPage;
5
+ /** The tab's title: `Brand: Calendar` (see `pageTitle()`). */
6
+ export declare const title: (props: {
7
+ branding?: Pick<import("@rapidmx/react-shared/branding/brandingApi.js").Branding, "title" | "companyName"> | null;
8
+ }) => string;
@@ -3,7 +3,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { routedPage } from "../_routedPage.js";
6
+ import { pageTitle } from "../../shared/navigation/pageTitle.js";
7
7
  import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
8
8
  import { DndContext, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
9
9
  import { HiOutlineBars3, HiOutlinePlus } from "react-icons/hi2";
@@ -346,4 +346,6 @@ function CalendarContent({ userUid, bookingHref }) {
346
346
  const sidebarContent = (_jsxs(_Fragment, { children: [_jsx(MiniDatePicker, { selected: viewDate, onSelect: (date) => setViewDate(startOfDay(date)) }), _jsx(CalendarListSidebar, { mailboxCalendars: mailboxCalendars, checkedFolderUids: checkedFolderUids, onToggle: toggleCalendar, onAddCalendar: handleAddCalendar, colorFor: colorFor })] }));
347
347
  return (_jsxs("div", { className: "flex-1 min-w-0 flex min-h-0", children: [_jsx("div", { className: "hidden lg:flex w-56 shrink-0 border-r border-border flex-col overflow-y-auto", children: sidebarContent }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Calendars", fullScreen: true, children: _jsx("div", { className: "flex flex-col", children: sidebarContent }) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col min-h-0", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-2 p-3 border-b border-border shrink-0", children: [_jsx("button", { type: "button", className: "lg:hidden w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text shrink-0", "aria-label": "Open calendars", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), !isMobile && (_jsx(Button, { type: "button", onClick: (e) => openNewEvent(undefined, undefined, undefined, anchorOf(e.currentTarget, "below")), className: "!w-auto shrink-0", ...newEventHint, children: "+ New event" })), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", onClick: () => shiftView(-1), "aria-label": "Previous", ...previousHint, className: "w-7 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "\u2039" }), _jsx("button", { type: "button", onClick: goToday, ...todayHint, className: "px-2 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "Today" }), _jsx("button", { type: "button", onClick: () => shiftView(1), "aria-label": "Next", ...nextHint, className: "w-7 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "\u203A" })] }), _jsx("h1", { className: "hidden md:block text-lg font-bold tracking-tight", children: title }), _jsx("div", { className: "ml-auto flex flex-wrap gap-1", children: VIEW_TYPES.map((v) => (_jsx(ViewButton, { view: v, current: view, onSelect: setView }, v))) })] }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), _jsx("div", { ref: swipe.ref, className: "flex-1 min-w-0 flex flex-col min-h-0 overflow-hidden", ...swipe.handlers, style: swipe.style, children: _jsx("div", { "data-swipe-phase": swipe.phase, className: "flex-1 min-w-0 flex flex-col min-h-0", style: swipe.contentStyle, children: _jsx("div", { className: "flex-1 min-w-0 flex flex-col min-h-0", style: wheelSlide.style, children: loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(DndContext, { sensors: sensors, onDragStart: () => setDragging(true), onDragEnd: handleDragEnd, onDragCancel: () => setDragging(false), children: view === "month" ? (_jsx(MonthView, { viewDate: viewDate, occurrences: occurrences, folderColors: folderColors, onSelectDay: handleSelectDay, onSelectEvent: openEvent, onSelectSlot: (start, end, anchor) => openNewEvent(start, end, undefined, anchor, true) })) : view === "split" ? (_jsx(SplitDayView, { day: viewDate, columns: splitColumns, occurrences: occurrences, onSelectEvent: openEvent, onSelectSlot: (start, end, targetFolderUid, anchor) => openNewEvent(start, end, targetFolderUid, anchor) })) : (_jsx(TimeGridView, { days: days, occurrences: occurrences, folderColors: folderColors, onSelectEvent: openEvent, onSelectSlot: (start, end, anchor) => openNewEvent(start, end, undefined, anchor) })) })) }) }) }), modal && modalMailbox && (_jsx(EventModal, { open: true, onClose: closeModal, mailboxUid: modalMailbox.uid, folderUid: modalFolderUid, calendars: calendarFolders.filter((f) => f.mailboxUid === modalMailbox.uid).map((f) => ({ uid: f.uid, name: f.name })), mailboxOptions: mailboxOptions, folderColors: folderColors, organizerAddress: modalMailbox.primarySmtpAddress, organizerAliases: modalMailbox.aliasAddresses, occurrence: modal.occurrence, initialStart: modal.initialStart, initialEnd: modal.initialEnd, initialAllDay: modal.initialAllDay, anchor: modal.anchor, bookingHref: bookingHref, onSaved: handleSaved, onDeleted: handleDeleted })), isMobile && mailboxUid && folderUid && !modal && _jsx(FloatingActionButton, { label: "New event", icon: HiOutlinePlus, onClick: () => openNewEvent() })] })] }));
348
348
  }
349
- export default routedPage("/calendar", CalendarPage);
349
+ export default CalendarPage;
350
+ /** The tab's title: `Brand: Calendar` (see `pageTitle()`). */
351
+ export const title = pageTitle("Calendar");
@@ -1,7 +1,18 @@
1
+ import React from "react";
1
2
  import { ContactsShellProps } from "../../shared/components/contacts/layout/ContactsShell.js";
2
- declare const _default: import("../../shared/navigation/AppRouter.js").RoutedPageComponent<ContactsShellProps & {
3
+ /**
4
+ * Only reached on mobile (below the `md` breakpoint) — desktop's `apps/www/contacts/index.tsx` keeps its
5
+ * existing inline detail pane and never navigates here; see that file's `handleSelectRow`. There is no
6
+ * equivalent "new contact" route — an unsaved contact has no uid to route on, so creation stays an
7
+ * in-place mode-switch on every device (see `apps/www/contacts/index.tsx`'s `handleNew`).
8
+ */
9
+ declare function ContactDetailPage(props: ContactsShellProps & {
3
10
  params: {
4
11
  uid: string;
5
12
  };
6
- }>;
7
- export default _default;
13
+ }): React.JSX.Element;
14
+ export default ContactDetailPage;
15
+ /** The tab's title: `Brand: Contacts` (see `pageTitle()`). */
16
+ export declare const title: (props: {
17
+ branding?: Pick<import("@rapidmx/react-shared/branding/brandingApi.js").Branding, "title" | "companyName"> | null;
18
+ }) => string;
@@ -3,8 +3,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { routedPage } from "../_routedPage.js";
7
- import { useNavigate } from "../../shared/navigation/AppRouter.js";
6
+ import { pageTitle } from "../../shared/navigation/pageTitle.js";
7
+ import { useNavigate } from "../../shared/navigation/index.js";
8
8
  import { useEffect, useState } from "react";
9
9
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
10
10
  import { deleteContact, getContact } from "@rapidmx/react-shared/contacts/contactsApi.js";
@@ -72,4 +72,6 @@ function ContactDetailContent({ uid }) {
72
72
  }
73
73
  return (_jsx("div", { className: "p-6", children: _jsx(ContactDetailPane, { contact: contact, onEdit: () => setMode("edit"), onDelete: () => handleDelete(contact), backHref: backHref, onKeysChanged: handleKeysChanged }) }));
74
74
  }
75
- export default routedPage("/contacts/:uid", ContactDetailPage);
75
+ export default ContactDetailPage;
76
+ /** The tab's title: `Brand: Contacts` (see `pageTitle()`). */
77
+ export const title = pageTitle("Contacts");
@@ -1,3 +1,8 @@
1
+ import React from "react";
1
2
  import { ContactsShellProps } from "../../shared/components/contacts/layout/ContactsShell.js";
2
- declare const _default: import("../../shared/navigation/AppRouter.js").RoutedPageComponent<ContactsShellProps>;
3
- export default _default;
3
+ declare function ContactsPage(props: ContactsShellProps): React.JSX.Element;
4
+ export default ContactsPage;
5
+ /** The tab's title: `Brand: Contacts` (see `pageTitle()`). */
6
+ export declare const title: (props: {
7
+ branding?: Pick<import("@rapidmx/react-shared/branding/brandingApi.js").Branding, "title" | "companyName"> | null;
8
+ }) => string;
@@ -3,8 +3,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { routedPage } from "../_routedPage.js";
7
- import { useNavigate } from "../../shared/navigation/AppRouter.js";
6
+ import { pageTitle } from "../../shared/navigation/pageTitle.js";
7
+ import { useNavigate } from "../../shared/navigation/index.js";
8
8
  import { useEffect, useMemo, useRef, useState } from "react";
9
9
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
10
10
  import { createContact, deleteContact, listContacts, listDeletedContacts, setContactFavorite, updateContact, } from "@rapidmx/react-shared/contacts/contactsApi.js";
@@ -344,6 +344,8 @@ function ContactsContent({ userUid }) {
344
344
  return (_jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex flex-col lg:flex-row md:flex-none md:h-[calc(100dvh_-_var(--rr-header-h,4rem))] md:overflow-hidden", children: [_jsx(ContactsSidebar, { mailboxUid: mailboxUid, contacts: contacts, active: view, onSelect: handleSelectView, showDeleted: canViewDeleted }), _jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex", children: [_jsxs("div", { className: [
345
345
  "w-full md:w-[clamp(16rem,32vw,26rem)] min-w-0 md:shrink-0 md:border-r border-border md:flex flex-col min-h-0",
346
346
  mode === "new" ? "hidden md:flex" : "flex",
347
- ].join(" "), children: [_jsx(ContactsToolbar, { selectedCount: checkedContacts.length, allSelectedFavorited: checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite), onNewContact: handleNew, onEdit: handleToolbarEdit, onDelete: () => setConfirmingBulkDelete(true), onEmail: handleEmail, onToggleFavorite: handleToggleFavorite, onAddCategory: handleAddCategory, onExportVCard: handleExportVCard, onImportFile: handleImportFile, shortcuts: true, hideNew: isMobile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { ref: searchInputRef, type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search contacts", "aria-label": "Search contacts", className: INPUT_CLASS }) }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), (isDeletedView ? deletedTruncated : truncated) && (_jsx("div", { className: "p-3", children: _jsxs(Alert, { children: ["This folder has more contacts than can be shown at once - only the first ", LIST_PAGE_SIZE * MAX_LIST_PAGES, " are listed."] }) })), deletedError && isDeletedView && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: deletedError }) })), (isDeletedView ? deletedLoading : loading) ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : sorted.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "No contacts found." })) : (_jsx("div", { className: "flex-1 min-h-0 overflow-auto", children: _jsxs("table", { className: "w-full table-fixed text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-10 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "w-[58%] px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Name", sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""] }) }), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("info"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Contact info", sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""] }) })] }) }), _jsx("tbody", { children: sorted.map((contact) => (_jsxs("tr", { className: ["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(" "), children: [!isDeletedView && (_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${contact.displayName}`, checked: checkedUids.has(contact.uid), onChange: () => toggleChecked(contact.uid) }) })), _jsx("td", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSelectRow(contact), className: "flex w-full min-w-0 items-center gap-2 text-left", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 28 }), _jsxs("span", { className: "min-w-0 truncate font-medium", children: [contact.displayName, contact.favorite && _jsx("span", { "aria-label": "Favorite", children: " \u2605" })] })] }) }), _jsx("td", { className: "max-w-0 truncate px-3 py-2 text-text-muted", children: primaryInfo(contact) })] }, contact.uid))) })] }) }))] }), _jsxs("div", { className: ["flex-1 min-w-0 min-h-0 flex-col md:flex", mode === "new" ? "flex" : "hidden"].join(" "), children: [savedElsewhere && mode !== "new" && (_jsxs("p", { role: "status", className: "m-6 mb-0 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text", children: [savedElsewhere.displayName, " was added to", " ", mailboxes.find((mb) => mb.uid === savedElsewhere.mailboxUid)?.displayName ?? "another mailbox", ".", " ", _jsx("a", { href: `/contacts?mailboxUid=${encodeURIComponent(savedElsewhere.mailboxUid)}`, className: "font-medium text-primary-dark hover:underline", children: "View that mailbox\u2019s contacts" })] })), mode === "new" && mailboxUid && folderUid ? (_jsx(ContactForm, { mailboxUid: mailboxUid, folderUid: folderUid, mailboxes: writableMailboxes, onSaved: handleSaved, onCancel: handleCancel })) : mode === "edit" && selected ? (_jsx(ContactForm, { contact: selected, onSaved: handleSaved, onCancel: handleCancel })) : selected ? (_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsx(ContactDetailPane, { contact: selected, onEdit: () => setMode("edit"), onDelete: () => handleDelete(selected), onKeysChanged: () => void reload(), canResolveKeys: ownsMailbox || delegateCanUpdate }) })) : (_jsx("div", { className: "flex flex-1 items-center justify-center p-6 text-center", children: _jsx("p", { className: "text-sm text-text-muted", children: "Select a contact, or create a new one." }) }))] })] }), _jsxs(Modal, { open: confirmingBulkDelete, onClose: () => setConfirmingBulkDelete(false), title: "Delete contacts", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Delete ", checkedContacts.length, " selected ", checkedContacts.length === 1 ? "contact" : "contacts", "? They move to Deleted contacts."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingBulkDelete(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: handleBulkDelete, children: "Delete" })] })] }), isMobile && mode !== "new" && _jsx(FloatingActionButton, { label: "New contact", icon: HiOutlineUserPlus, onClick: handleNew })] }));
347
+ ].join(" "), children: [_jsx(ContactsToolbar, { selectedCount: checkedContacts.length, allSelectedFavorited: checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite), onNewContact: handleNew, onEdit: handleToolbarEdit, onDelete: () => setConfirmingBulkDelete(true), onEmail: handleEmail, onToggleFavorite: handleToggleFavorite, onAddCategory: handleAddCategory, onExportVCard: handleExportVCard, onImportFile: handleImportFile, shortcuts: true, hideNew: isMobile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { ref: searchInputRef, type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search contacts", "aria-label": "Search contacts", className: INPUT_CLASS }) }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), (isDeletedView ? deletedTruncated : truncated) && (_jsx("div", { className: "p-3", children: _jsxs(Alert, { children: ["This folder has more contacts than can be shown at once - only the first ", LIST_PAGE_SIZE * MAX_LIST_PAGES, " are listed."] }) })), deletedError && isDeletedView && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: deletedError }) })), (isDeletedView ? deletedLoading : loading) ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : sorted.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "No contacts found." })) : (_jsx("div", { className: "flex-1 min-h-0 overflow-auto", children: _jsxs("table", { className: "w-full table-fixed text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-10 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "w-[58%] px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Name", sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""] }) }), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("info"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Contact info", sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""] }) })] }) }), _jsx("tbody", { children: sorted.map((contact) => (_jsxs("tr", { className: ["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(" "), children: [!isDeletedView && (_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${contact.displayName}`, checked: checkedUids.has(contact.uid), onChange: () => toggleChecked(contact.uid) }) })), _jsx("td", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSelectRow(contact), className: "flex w-full min-w-0 items-center gap-2 text-left", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 28 }), _jsxs("span", { className: "min-w-0 truncate font-medium", children: [contact.displayName, contact.favorite && _jsx("span", { "aria-label": "Favorite", children: " \u2605" })] })] }) }), _jsx("td", { className: "max-w-0 truncate px-3 py-2 text-text-muted", children: primaryInfo(contact) })] }, contact.uid))) })] }) }))] }), _jsxs("div", { className: ["flex-1 min-w-0 min-h-0 flex-col md:flex", mode === "new" ? "flex" : "hidden"].join(" "), children: [savedElsewhere && mode !== "new" && (_jsxs("p", { role: "status", className: "m-6 mb-0 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text", children: [savedElsewhere.displayName, " was added to", " ", mailboxes.find((mb) => mb.uid === savedElsewhere.mailboxUid)?.displayName ?? "another mailbox", ".", " ", _jsx("a", { href: `/contacts?mailboxUid=${encodeURIComponent(savedElsewhere.mailboxUid)}`, "data-router-shallow": "", "data-router-prefetch": "false", className: "font-medium text-primary-dark hover:underline", children: "View that mailbox\u2019s contacts" })] })), mode === "new" && mailboxUid && folderUid ? (_jsx(ContactForm, { mailboxUid: mailboxUid, folderUid: folderUid, mailboxes: writableMailboxes, onSaved: handleSaved, onCancel: handleCancel })) : mode === "edit" && selected ? (_jsx(ContactForm, { contact: selected, onSaved: handleSaved, onCancel: handleCancel })) : selected ? (_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsx(ContactDetailPane, { contact: selected, onEdit: () => setMode("edit"), onDelete: () => handleDelete(selected), onKeysChanged: () => void reload(), canResolveKeys: ownsMailbox || delegateCanUpdate }) })) : (_jsx("div", { className: "flex flex-1 items-center justify-center p-6 text-center", children: _jsx("p", { className: "text-sm text-text-muted", children: "Select a contact, or create a new one." }) }))] })] }), _jsxs(Modal, { open: confirmingBulkDelete, onClose: () => setConfirmingBulkDelete(false), title: "Delete contacts", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Delete ", checkedContacts.length, " selected ", checkedContacts.length === 1 ? "contact" : "contacts", "? They move to Deleted contacts."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingBulkDelete(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: handleBulkDelete, children: "Delete" })] })] }), isMobile && mode !== "new" && _jsx(FloatingActionButton, { label: "New contact", icon: HiOutlineUserPlus, onClick: handleNew })] }));
348
348
  }
349
- export default routedPage("/contacts", ContactsPage);
349
+ export default ContactsPage;
350
+ /** The tab's title: `Brand: Contacts` (see `pageTitle()`). */
351
+ export const title = pageTitle("Contacts");
@@ -1,3 +1,8 @@
1
+ import React from "react";
1
2
  import { MailShellProps } from "../shared/components/mail/layout/MailShell.js";
2
- declare const _default: import("../shared/navigation/AppRouter.js").RoutedPageComponent<MailShellProps>;
3
- export default _default;
3
+ declare function InboxPage(props: MailShellProps): React.JSX.Element;
4
+ export default InboxPage;
5
+ /** The tab's title: `Brand: Mail` (see `pageTitle()`). */
6
+ export declare const title: (props: {
7
+ branding?: Pick<import("@rapidmx/react-shared/branding/brandingApi.js").Branding, "title" | "companyName"> | null;
8
+ }) => string;
@@ -3,9 +3,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { routedPage } from "./_routedPage.js";
7
- import { useNavigate } from "../shared/navigation/AppRouter.js";
8
- import { whenIdle } from "../shared/navigation/idle.js";
6
+ import { pageTitle } from "../shared/navigation/pageTitle.js";
7
+ import { useNavigate } from "../shared/navigation/index.js";
8
+ import { whenIdle } from "@rapidrest/react/client";
9
9
  import { prefetchComposeWindow } from "../shared/components/mail/compose/ComposeContext.js";
10
10
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
11
11
  import { createPortal } from "react-dom";
@@ -38,6 +38,10 @@ import MoveToFolderDialog from "../shared/components/mail/MoveToFolderDialog.js"
38
38
  import { EncryptedPreview } from "../shared/components/mail/reading/EncryptedPreview.js";
39
39
  import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
40
40
  import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
41
+ import EmptyFolderBar from "../shared/components/mail/EmptyFolderBar.js";
42
+ import { EMPTIABLE_FOLDER_TYPES } from "../shared/mail/permanentDelete.js";
43
+ import { usePermanentDelete } from "../shared/mail/usePermanentDelete.js";
44
+ import { useMailboxUpdateAccess } from "../shared/mail/useMailboxUpdateAccess.js";
41
45
  import { CONVERSATION_SORT_NOTE, CONVERSATION_SORT_UNAVAILABLE, MAIL_LIST_CLASSIFICATION_FILTERS, getMailListPreferences, setMailListPreferences, sortConversations, } from "../shared/components/mail/listPreferences.js";
42
46
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
43
47
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
@@ -365,7 +369,7 @@ function InboxPage(props) {
365
369
  return (_jsx(MailShell, { ...props, children: _jsx(InboxContent, { userUid: props.userUid }) }));
366
370
  }
367
371
  function InboxContent({ userUid }) {
368
- const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot } = useMailShell();
372
+ const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange, folderCountOf, mobileSearchSlot } = useMailShell();
369
373
  const isMobile = useIsMobile();
370
374
  const navigate = useNavigate();
371
375
  const { requestUnlock } = useUnlockPrompt();
@@ -408,6 +412,8 @@ function InboxContent({ userUid }) {
408
412
  // or it would act on a selection that is still arriving.
409
413
  const [resolvingSelection, setResolvingSelection] = useState(0);
410
414
  const [bulkBusy, setBulkBusy] = useState(false);
415
+ // Delete on what is already in Deleted Items, and Empty folder: confirmed, then done for good (see `usePermanentDelete()`).
416
+ const permanent = usePermanentDelete();
411
417
  // Bumped to force the list effect below to re-run - a bulk update is deliberately neither atomic nor
412
418
  // all-or-nothing (see `bulkUpdateMessages()`), so a rejection means refetching rather than guessing
413
419
  // which half of the selection actually landed.
@@ -467,6 +473,8 @@ function InboxContent({ userUid }) {
467
473
  // Always set once this page renders: a caller with no mailbox at all gets `MailboxProvisioning` from the shell instead.
468
474
  const activeMailboxUid = (mailboxUid ?? primaryMailboxUid(mailboxes, userUid));
469
475
  const mailboxKeys = mailboxes.find((mb) => mb.uid === activeMailboxUid)?.keys ?? [];
476
+ // False for a mailbox shared with the reader view-only: Empty folder is not offered there (the server would refuse it).
477
+ const mailboxWritable = useMailboxUpdateAccess(mailboxes.find((mb) => mb.uid === activeMailboxUid));
470
478
  // The Sort/Filter menus' and "Show as conversations"' current settings, remembered per mailbox across
471
479
  // reloads (`listPreferences.ts`). Read during render, not in an effect, so the very first listing
472
480
  // already uses the remembered arrangement rather than fetching the default one and immediately
@@ -1744,6 +1752,49 @@ function InboxContent({ userUid }) {
1744
1752
  function moveSelectionToType(type, name, chosen) {
1745
1753
  return runBulkAction((moving) => moveToFolderOfType(moving, type, name), true, chosen);
1746
1754
  }
1755
+ /**
1756
+ * Permanently deletes `chosen` - messages that are already in Deleted Items - once the reader has confirmed it (`usePermanentDelete()`
1757
+ * asks, deletes, tells the badges and says what happened). The rows that went leave the list, and selection and advance behave as for
1758
+ * Archive; a conversation list is reloaded, as after every bulk action on it, and so is a flat one when the server refused some
1759
+ * (those stay listed, and the reload shows what is really there). Resolves whether anything was deleted.
1760
+ */
1761
+ async function purgeChosen(chosen) {
1762
+ const outcome = await permanent.requestPermanentDelete(chosen);
1763
+ if (!outcome) {
1764
+ return false;
1765
+ }
1766
+ if (asConversations) {
1767
+ setSelectedConversationIds(new Set());
1768
+ setConversationMessagesById({});
1769
+ setRefreshKey((n) => n + 1);
1770
+ }
1771
+ else {
1772
+ removeListedMessages(new Set(outcome.deleted.map((m) => m.uid)));
1773
+ if (outcome.failed.length > 0) {
1774
+ setRefreshKey((n) => n + 1);
1775
+ }
1776
+ }
1777
+ setSelectedUids(new Set());
1778
+ return outcome.deleted.length > 0;
1779
+ }
1780
+ /** Empty folder: permanently deletes everything in the folder being listed (Deleted Items or Junk Email) after the reader has confirmed. */
1781
+ async function emptyListedFolder(folder, count) {
1782
+ const outcome = await permanent.requestEmptyFolder({ uid: folder.uid, name: folder.name }, count);
1783
+ if (!outcome) {
1784
+ return;
1785
+ }
1786
+ if (outcome.emptied) {
1787
+ setMessages([]);
1788
+ setConversations([]);
1789
+ }
1790
+ setSelectedUid(null);
1791
+ setOpenThread(null);
1792
+ setSelectedUids(new Set());
1793
+ setSelectedConversationIds(new Set());
1794
+ setConversationMessagesById({});
1795
+ // What is really left - after a partial delete, or one that failed outright - is whatever the server lists.
1796
+ setRefreshKey((n) => n + 1);
1797
+ }
1747
1798
  // ---- Swiping a row on a phone (see `SwipeRow`): right to left archives, left to right asks for a folder. Both go through the same
1748
1799
  // ---- bulk path as the selection bar's Archive and Move to, so the badges, the optimistic bookkeeping and the failure pop-up are one
1749
1800
  // ---- implementation. Not offered where the actions aren't (an aggregate view spans mailboxes, search results are a mixed bag, the
@@ -1864,6 +1915,10 @@ function InboxContent({ userUid }) {
1864
1915
  function folderTypeOf(folderUidOfMessage) {
1865
1916
  return mailboxFolders.flatMap((mf) => mf.folders).find((f) => f.uid === folderUidOfMessage)?.type;
1866
1917
  }
1918
+ /** Whether a message is in its mailbox's Deleted Items - where Delete is the permanent one. */
1919
+ function inDeletedItems(message) {
1920
+ return folderTypeOf(message.folderUid) === "deleted_items";
1921
+ }
1867
1922
  /** Moves the keyboard's focus to a row - and scrolls it into view - once the selection has moved to it. */
1868
1923
  function focusRow(uid) {
1869
1924
  // Only called from a key press, when the list is on screen.
@@ -1921,13 +1976,13 @@ function InboxContent({ userUid }) {
1921
1976
  return all.filter((m) => !folderUid || crossMailbox || m.folderUid === folderUid);
1922
1977
  }
1923
1978
  /**
1924
- * Runs a bulk action on what the keyboard acts on - the ticked rows in select mode, else the open message or conversation - keeping only
1925
- * the messages `applies` accepts (Delete leaves out what is already in Deleted Items, Mark read what is already read: nothing to do
1926
- * then). Held while another bulk action is on the wire, like the bar's buttons. Closes the open thread after a move away from it.
1979
+ * What the keyboard acts on - the ticked rows in select mode, else the open message or conversation - keeping only the messages `applies`
1980
+ * accepts (Mark read leaves out what is already read: nothing to do then). Nothing (an empty list) while another bulk action or a
1981
+ * permanent delete is on the wire, like the bar's buttons, or when the open conversation could not be loaded.
1927
1982
  */
1928
- async function runOnKeyboardTargets(action, removesRows, applies = () => true) {
1929
- if (bulkBusy || resolvingSelection > 0) {
1930
- return;
1983
+ async function keyboardTargets(applies = () => true) {
1984
+ if (bulkBusy || resolvingSelection > 0 || permanent.busy) {
1985
+ return [];
1931
1986
  }
1932
1987
  let chosen;
1933
1988
  try {
@@ -1939,23 +1994,48 @@ function InboxContent({ userUid }) {
1939
1994
  }
1940
1995
  catch (err) {
1941
1996
  notifyApiError(err, "Couldn't load the messages in that conversation");
1942
- return;
1997
+ return [];
1943
1998
  }
1944
- chosen = chosen.filter(applies);
1999
+ return chosen.filter(applies);
2000
+ }
2001
+ /** Closes the open thread (and the selection in it) - what a delete of the whole conversation leaves nothing to show of. */
2002
+ function closeOpenThread() {
2003
+ setOpenThread(null);
2004
+ setSelectedUid(null);
2005
+ }
2006
+ /** Runs a bulk action on `chosen` (from `keyboardTargets()`), closing the open thread after a move away from it. */
2007
+ async function actOnKeyboardTargets(chosen, action, removesRows) {
1945
2008
  if (chosen.length === 0) {
1946
2009
  return;
1947
2010
  }
1948
2011
  const succeeded = await runBulkAction(action, removesRows, chosen);
1949
2012
  if (succeeded && removesRows && threadPane && !selectMode) {
1950
- setOpenThread(null);
1951
- setSelectedUid(null);
2013
+ closeOpenThread();
2014
+ }
2015
+ }
2016
+ /** Runs a bulk action on what the keyboard acts on (`keyboardTargets()`). */
2017
+ async function runOnKeyboardTargets(action, removesRows, applies = () => true) {
2018
+ await actOnKeyboardTargets(await keyboardTargets(applies), action, removesRows);
2019
+ }
2020
+ /**
2021
+ * Delete, from the keyboard: what is in Deleted Items is permanently deleted (after a confirmation), anything else moves to its mailbox's
2022
+ * Deleted Items - as the selection bar's Delete does. A mix (search results from several folders) moves only what is not in Deleted Items yet.
2023
+ */
2024
+ async function deleteFromKeyboard() {
2025
+ const targets = await keyboardTargets();
2026
+ if (targets.length > 0 && targets.every(inDeletedItems)) {
2027
+ if ((await purgeChosen(targets)) && threadPane && !selectMode) {
2028
+ closeOpenThread();
2029
+ }
2030
+ return;
1952
2031
  }
2032
+ await actOnKeyboardTargets(targets.filter((message) => !inDeletedItems(message)), (chosen) => moveToFolderOfType(chosen, "deleted_items", "Deleted Items"), true);
1953
2033
  }
1954
2034
  useShortcut(SHORTCUTS.mail.next, () => selectNeighbour(1), { enabled: canMoveSelection });
1955
2035
  useShortcut(SHORTCUTS.mail.previous, () => selectNeighbour(-1), { enabled: canMoveSelection });
1956
2036
  useShortcut(SHORTCUTS.mail.nextUnread, () => selectNextUnread(1), { enabled: canMoveSelection });
1957
2037
  useShortcut(SHORTCUTS.mail.previousUnread, () => selectNextUnread(-1), { enabled: canMoveSelection });
1958
- useShortcut(SHORTCUTS.mail.delete, () => void runOnKeyboardTargets((chosen) => moveToFolderOfType(chosen, "deleted_items", "Deleted Items"), true, (message) => folderTypeOf(message.folderUid) !== "deleted_items"), { enabled: keyboardActions && keyboardTargetExists });
2038
+ useShortcut(SHORTCUTS.mail.delete, () => void deleteFromKeyboard(), { enabled: keyboardActions && keyboardTargetExists });
1959
2039
  useShortcut(SHORTCUTS.mail.markRead, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, true, { patch: patchListedMessage, track: trackMessageChange }), false, isUnread), { enabled: keyboardActions && keyboardTargetExists });
1960
2040
  useShortcut(SHORTCUTS.mail.markUnread, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, false, { patch: patchListedMessage, track: trackMessageChange }), false, (message) => !isUnread(message)), { enabled: keyboardActions && keyboardTargetExists });
1961
2041
  useShortcut(SHORTCUTS.mail.flag, () =>
@@ -1995,6 +2075,19 @@ function InboxContent({ userUid }) {
1995
2075
  const labelsDisabledReason = crossMailbox && selectedMessages.some((m) => m.mailboxUid !== activeMailboxUid)
1996
2076
  ? "Labels can only be applied here to messages in the open mailbox"
1997
2077
  : undefined;
2078
+ // Delete is the permanent one for what is in Deleted Items: for the selection when there is one (search results can come from any folder),
2079
+ // else for the folder being viewed, so the button says so before anything is ticked.
2080
+ const deletesPermanently = selectedMessages.length > 0 ? selectedMessages.every(inDeletedItems) : currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
2081
+ // Empty folder is offered at the top of the list of a mailbox's own Deleted Items or Junk Email - not for search results, nor for the "All
2082
+ // mailboxes" views, which have no one folder to empty.
2083
+ const emptiableFolder = !isSearching && !aggregateFolderType ? currentFolders.find((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type)) : undefined;
2084
+ // How many it holds as the sidebar's counts have it (kept right as messages leave), unless that says none while the list shows some.
2085
+ const emptiableCount = emptiableFolder && folderCountOf(emptiableFolder).total > 0 ? folderCountOf(emptiableFolder).total : undefined;
2086
+ const emptyDisabledReason = !mailboxWritable
2087
+ ? "This mailbox is shared with you view-only"
2088
+ : listedRowCount === 0 && emptiableCount === undefined
2089
+ ? "This folder is already empty"
2090
+ : undefined;
1998
2091
  if (!folderUid && !aggregateFolderType) {
1999
2092
  // `MailShell` never renders this component at all until a mailbox is resolved (see its own
2000
2093
  // full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
@@ -2014,7 +2107,10 @@ function InboxContent({ userUid }) {
2014
2107
  // Optimistic: the rows and the folder badges change now, and change back if the server refuses.
2015
2108
  (chosen) => setReadStateMany(chosen, read, { patch: patchListedMessage, track: trackMessageChange }), false), onSetFlagged: (flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged), false), shortcuts: keyboardActions, onArchive: () => void runBulkAction(bulkArchive, true), onMoveTo: async (targetFolderUid) => {
2016
2109
  await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid), true);
2017
- }, onReportJunk: () => void moveSelectionToType("junk", "Junk Email"), onDelete: () => void moveSelectionToType("deleted_items", "Deleted Items"), busy: bulkBusy || resolvingSelection > 0 })) : (_jsx(MailListToolbar, { sortBy: preferences.sortBy, sortOrder: preferences.sortOrder, filter: preferences.filter, labelUids: preferences.labelUids, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onLabelUidsChange: (nextLabelUids) => updatePreferences({ labelUids: nextLabelUids }), showAsConversations: preferences.showAsConversations, onSortChange: (sortBy, sortOrder) => updatePreferences({ sortBy, sortOrder }), onFilterChange: (filter) => updatePreferences({ filter }), onShowAsConversationsChange: (showAsConversations) => updatePreferences({ showAsConversations }), selectMode: selectMode, onSelectModeChange: setSelectMode, offerClassificationFilters: offerClassificationFilters, filterDisabled: isSearching, filterDisabledReason: "Filters don't apply to search results", sortKeysDisabled: isSearching || !!aggregateFolderType,
2110
+ }, onReportJunk: () => void moveSelectionToType("junk", "Junk Email"), onDelete: () => void (deletesPermanently
2111
+ ? purgeChosen(selectedMessages)
2112
+ : // A mix (search results from several folders) moves only what is not in Deleted Items yet.
2113
+ moveSelectionToType("deleted_items", "Deleted Items", selectedMessages.filter((m) => !inDeletedItems(m)))), deletesPermanently: deletesPermanently, busy: bulkBusy || resolvingSelection > 0 || permanent.busy })) : (_jsx(MailListToolbar, { sortBy: preferences.sortBy, sortOrder: preferences.sortOrder, filter: preferences.filter, labelUids: preferences.labelUids, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onLabelUidsChange: (nextLabelUids) => updatePreferences({ labelUids: nextLabelUids }), showAsConversations: preferences.showAsConversations, onSortChange: (sortBy, sortOrder) => updatePreferences({ sortBy, sortOrder }), onFilterChange: (filter) => updatePreferences({ filter }), onShowAsConversationsChange: (showAsConversations) => updatePreferences({ showAsConversations }), selectMode: selectMode, onSelectModeChange: setSelectMode, offerClassificationFilters: offerClassificationFilters, filterDisabled: isSearching, filterDisabledReason: "Filters don't apply to search results", sortKeysDisabled: isSearching || !!aggregateFolderType,
2018
2114
  // A conversation row is a thread summary, so only some of the keys have anything to
2019
2115
  // order by - the rest stay pickable and are applied to the rows already fetched.
2020
2116
  unavailableSortKeys: asConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined, sortKeysNote: isSearching
@@ -2027,7 +2123,7 @@ function InboxContent({ userUid }) {
2027
2123
  ? "Open a mailbox's own folder to select messages"
2028
2124
  : loading
2029
2125
  ? "Wait for this folder to finish loading"
2030
- : "There is nothing here to select" })), !isMobile && _jsx("div", { className: "p-2 border-b border-border", children: searchField }), isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot), offerClassificationFilters && (_jsx("div", { className: "flex border-b border-border text-xs", children: MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (_jsx("button", { type: "button", "aria-pressed": preferences.filter === value, onClick: () => updatePreferences({ filter: value }), className: [
2126
+ : "There is nothing here to select" })), !isMobile && _jsx("div", { className: "p-2 border-b border-border", children: searchField }), isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot), emptiableFolder && !selectMode && (_jsx(EmptyFolderBar, { folderName: emptiableFolder.name, count: emptiableCount, disabled: !!emptyDisabledReason || permanent.busy || bulkBusy, disabledReason: emptyDisabledReason, onEmpty: () => void emptyListedFolder(emptiableFolder, emptiableCount) })), permanent.dialog, offerClassificationFilters && (_jsx("div", { className: "flex border-b border-border text-xs", children: MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (_jsx("button", { type: "button", "aria-pressed": preferences.filter === value, onClick: () => updatePreferences({ filter: value }), className: [
2031
2127
  "flex-1 py-1.5 font-semibold",
2032
2128
  preferences.filter === value
2033
2129
  ? "text-primary-dark border-b-2 border-primary-dark"
@@ -2062,6 +2158,8 @@ function InboxContent({ userUid }) {
2062
2158
  // whichever folder is currently being viewed (into Archive), so it's removed
2063
2159
  // from the list rather than patched in place.
2064
2160
  removeListedMessage(updated.uid);
2065
- }, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) }), _jsx(MoveToFolderDialog, { open: moveDialog !== null, onClose: () => setMoveDialog(null), mailboxUid: activeMailboxUid, folders: currentFolders, currentFolderUid: folderUid, count: moveDialog === null ? 1 : "messages" in moveDialog ? moveDialog.messages.length : moveDialog.conversation.messageCount, onMove: swipeMove, onFolderCreated: onFolderCreated })] }));
2161
+ }, onChanged: patchListedMessage, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) }), _jsx(MoveToFolderDialog, { open: moveDialog !== null, onClose: () => setMoveDialog(null), mailboxUid: activeMailboxUid, folders: currentFolders, currentFolderUid: folderUid, count: moveDialog === null ? 1 : "messages" in moveDialog ? moveDialog.messages.length : moveDialog.conversation.messageCount, onMove: swipeMove, onFolderCreated: onFolderCreated })] }));
2066
2162
  }
2067
- export default routedPage("/", InboxPage);
2163
+ export default InboxPage;
2164
+ /** The tab's title: `Brand: Mail` (see `pageTitle()`). */
2165
+ export const title = pageTitle("Mail");
@@ -1,7 +1,18 @@
1
+ import React from "react";
1
2
  import { MailShellProps } from "../../shared/components/mail/layout/MailShell.js";
2
- declare const _default: import("../../shared/navigation/AppRouter.js").RoutedPageComponent<MailShellProps & {
3
+ /**
4
+ * Only reached on mobile (below the `md` breakpoint) — desktop's `apps/www/index.tsx` keeps its existing
5
+ * inline reading pane and never navigates here; see that file's `handleSelect`. A row of the conversation list
6
+ * comes with `?conversation=<id>` (`handleOpenConversation`), and then this page is the whole thread, opened at this
7
+ * message, as the desktop's reading pane is; without it (a flat list, a link to a message) it is that one message.
8
+ */
9
+ declare function MessageDetailPage(props: MailShellProps & {
3
10
  params: {
4
11
  uid: string;
5
12
  };
6
- }>;
7
- export default _default;
13
+ }): React.JSX.Element;
14
+ export default MessageDetailPage;
15
+ /** The tab's title: `Brand: Mail` (see `pageTitle()`). */
16
+ export declare const title: (props: {
17
+ branding?: Pick<import("@rapidmx/react-shared/branding/brandingApi.js").Branding, "title" | "companyName"> | null;
18
+ }) => string;
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { routedPage } from "../_routedPage.js";
6
+ import { pageTitle } from "../../shared/navigation/pageTitle.js";
7
7
  import { useEffect, useState } from "react";
8
8
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
9
  import { getMessage } from "@rapidmx/react-shared/mail/mailApi.js";
@@ -12,7 +12,7 @@ import { useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHook
12
12
  import { useMarkMessageRead } from "../../shared/mail/useMarkMessageRead.js";
13
13
  import MailShell, { useMailShell } from "../../shared/components/mail/layout/MailShell.js";
14
14
  import { LazyConversationThreadPane, LazyMessageDetailPane } from "../../shared/components/mail/LazyReadingPane.js";
15
- import { useLocationSearch } from "../../shared/navigation/AppRouter.js";
15
+ import { useSearchParams } from "@rapidrest/react/client";
16
16
  import { ReadingPaneSkeleton } from "../../shared/components/mail/reading/MessageCard.js";
17
17
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
18
18
  /**
@@ -30,7 +30,7 @@ function MessageDetailContent({ uid }) {
30
30
  const [loading, setLoading] = useState(true);
31
31
  const [error, setError] = useState(null);
32
32
  const [labels, setLabels] = useState([]);
33
- const conversationId = new URLSearchParams(useLocationSearch()).get("conversation");
33
+ const conversationId = useSearchParams()[0].get("conversation");
34
34
  useEffect(() => {
35
35
  setLoading(true);
36
36
  setError(null);
@@ -80,6 +80,8 @@ function MessageDetailContent({ uid }) {
80
80
  if (conversationId) {
81
81
  return (_jsxs("div", { className: "flex flex-col h-full min-h-0", children: [_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" }), _jsx(LazyConversationThreadPane, { conversation: { conversationId, subject: message.subject, messageCount: 1 }, selectedUid: message.uid, mailboxUid: message.mailboxUid, folders: folders, labels: labels, shortcuts: true, onMessagePatched: () => undefined, onMessageRemoved: () => undefined, onLabelCreated: (label) => setLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })] }));
82
82
  }
83
- return (_jsx(LazyMessageDetailPane, { shortcuts: true, message: message, attachments: attachments, backHref: backHref, isSentItems: isSentItems, onRecalled: setMessage, isOutbox: isOutbox, onReceiptHandled: setMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: setMessage, onFolderCreated: onFolderCreated, onScheduledSendCanceled: setMessage, onArchived: setMessage, labels: labels, onLabelsChanged: setMessage, onLabelCreated: (label) => setLabels((prev) => [...prev, label]) }));
83
+ return (_jsx(LazyMessageDetailPane, { shortcuts: true, message: message, attachments: attachments, backHref: backHref, isSentItems: isSentItems, onRecalled: setMessage, isOutbox: isOutbox, onReceiptHandled: setMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: setMessage, onFolderCreated: onFolderCreated, onScheduledSendCanceled: setMessage, onArchived: setMessage, onChanged: setMessage, labels: labels, onLabelsChanged: setMessage, onLabelCreated: (label) => setLabels((prev) => [...prev, label]) }));
84
84
  }
85
- export default routedPage("/messages/:uid", MessageDetailPage);
85
+ export default MessageDetailPage;
86
+ /** The tab's title: `Brand: Mail` (see `pageTitle()`). */
87
+ export const title = pageTitle("Mail");
@@ -1,4 +1,10 @@
1
+ import React from "react";
1
2
  import { SettingsShellProps } from "../../../shared/components/settings/layout/SettingsShell.js";
2
3
  export type SettingsAppearancePageProps = Omit<SettingsShellProps, "active">;
3
- declare const _default: import("../../../shared/navigation/AppRouter.js").RoutedPageComponent<SettingsAppearancePageProps>;
4
- export default _default;
4
+ /** Settings > Appearance: the colour scheme, theme colours and background. Not mailbox-scoped - it is the signed-in user's own. */
5
+ declare function SettingsAppearancePage(props: SettingsAppearancePageProps): React.JSX.Element;
6
+ export default SettingsAppearancePage;
7
+ /** The tab's title: `Brand: Settings` (see `pageTitle()`). */
8
+ export declare const title: (props: {
9
+ branding?: Pick<import("@rapidmx/react-shared/branding/brandingApi.js").Branding, "title" | "companyName"> | null;
10
+ }) => string;
@@ -3,11 +3,13 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { routedPage } from "../../_routedPage.js";
6
+ import { pageTitle } from "../../../shared/navigation/pageTitle.js";
7
7
  import SettingsShell from "../../../shared/components/settings/layout/SettingsShell.js";
8
8
  import AppearanceForm from "../../../shared/appearance/AppearanceForm.js";
9
9
  /** Settings > Appearance: the colour scheme, theme colours and background. Not mailbox-scoped - it is the signed-in user's own. */
10
10
  function SettingsAppearancePage(props) {
11
11
  return (_jsx(SettingsShell, { ...props, active: "appearance", children: _jsx(AppearanceForm, {}) }));
12
12
  }
13
- export default routedPage("/settings/appearance", SettingsAppearancePage);
13
+ export default SettingsAppearancePage;
14
+ /** The tab's title: `Brand: Settings` (see `pageTitle()`). */
15
+ export const title = pageTitle("Settings");
@@ -1,4 +1,9 @@
1
+ import React from "react";
1
2
  import { SettingsShellProps } from "../../../shared/components/settings/layout/SettingsShell.js";
2
3
  export type SettingsAutoReplyPageProps = Omit<SettingsShellProps, "active">;
3
- declare const _default: import("../../../shared/navigation/AppRouter.js").RoutedPageComponent<SettingsAutoReplyPageProps>;
4
- export default _default;
4
+ declare function SettingsAutoReplyPage(props: SettingsAutoReplyPageProps): React.JSX.Element;
5
+ export default SettingsAutoReplyPage;
6
+ /** The tab's title: `Brand: Settings` (see `pageTitle()`). */
7
+ export declare const title: (props: {
8
+ branding?: Pick<import("@rapidmx/react-shared/branding/brandingApi.js").Branding, "title" | "companyName"> | null;
9
+ }) => string;