@rapidmx/web-client 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +192 -88
  2. package/apps/shared/auth/accountUrl.ts +9 -0
  3. package/apps/shared/auth/adminAccess.ts +99 -0
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +2 -1
  5. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  6. package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
  7. package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
  8. package/apps/shared/components/layout/AppShell.tsx +411 -288
  9. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  10. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
  11. package/apps/shared/components/layout/UserMenu.tsx +303 -185
  12. package/apps/shared/components/mail/ConversationList.tsx +269 -262
  13. package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
  14. package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
  15. package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
  16. package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1387
  17. package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
  18. package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
  19. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  20. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1736
  21. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  22. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  23. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  24. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  25. package/apps/shared/components/mail/layout/MailShell.tsx +476 -472
  26. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  27. package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
  28. package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
  29. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  30. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  31. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  32. package/apps/shared/keyboard/dispatch.ts +124 -0
  33. package/apps/shared/keyboard/format.ts +89 -0
  34. package/apps/shared/keyboard/keymap.ts +114 -0
  35. package/apps/shared/keyboard/match.ts +44 -0
  36. package/apps/shared/keyboard/parse.ts +136 -0
  37. package/apps/shared/keyboard/platform.ts +34 -0
  38. package/apps/shared/keyboard/registry.ts +65 -0
  39. package/apps/shared/keyboard/targets.ts +79 -0
  40. package/apps/shared/keyboard/useShortcut.ts +50 -0
  41. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  42. package/apps/shared/mail/folderCounts.ts +302 -0
  43. package/apps/shared/mail/listSnapshots.ts +87 -0
  44. package/apps/shared/mail/messageReadState.ts +85 -0
  45. package/apps/shared/mail/newMailNotifications.ts +183 -0
  46. package/apps/shared/mail/useMailConnection.ts +150 -0
  47. package/apps/shared/mail/useMailLiveUpdates.ts +232 -224
  48. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  49. package/apps/shared/mail/useNewMailNotifications.ts +163 -0
  50. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  51. package/apps/shared/navigation/AppRouter.tsx +300 -0
  52. package/apps/shared/navigation/appHrefs.ts +23 -0
  53. package/apps/shared/navigation/frameContext.tsx +35 -0
  54. package/apps/shared/navigation/idle.ts +45 -0
  55. package/apps/shared/navigation/routerContext.tsx +83 -0
  56. package/apps/shared/navigation/routes.ts +72 -0
  57. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  58. package/apps/shared/styles/app.css +28 -10
  59. package/apps/www/_routedPage.tsx +24 -0
  60. package/apps/www/_routes.ts +35 -0
  61. package/apps/www/calendar/index.tsx +55 -19
  62. package/apps/www/contacts/[uid].tsx +112 -107
  63. package/apps/www/contacts/index.tsx +584 -567
  64. package/apps/www/index.tsx +2280 -1917
  65. package/apps/www/messages/[uid].tsx +106 -101
  66. package/apps/www/settings/auto-reply/index.tsx +4 -1
  67. package/apps/www/settings/encryption/index.tsx +1252 -1249
  68. package/apps/www/settings/filters/[uid].tsx +4 -1
  69. package/apps/www/settings/filters/index.tsx +102 -99
  70. package/apps/www/settings/filters/new/index.tsx +138 -133
  71. package/apps/www/settings/labels/index.tsx +204 -201
  72. package/apps/www/settings/privacy/index.tsx +4 -1
  73. package/apps/www/settings/read-receipts/index.tsx +4 -1
  74. package/apps/www/settings/sharing/index.tsx +277 -274
  75. package/apps/www/settings/signatures/[uid].tsx +170 -167
  76. package/apps/www/settings/signatures/index.tsx +88 -85
  77. package/apps/www/settings/signatures/new/index.tsx +134 -129
  78. package/apps/www/tasks/index.tsx +18 -2
  79. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  80. package/dist/apps/shared/auth/accountUrl.js +8 -0
  81. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  82. package/dist/apps/shared/auth/adminAccess.js +89 -0
  83. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
  84. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  85. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  86. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  87. package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
  88. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  89. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
  90. package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
  91. package/dist/apps/shared/components/layout/AppShell.js +73 -14
  92. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  93. package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
  94. package/dist/apps/shared/components/layout/UserMenu.d.ts +19 -3
  95. package/dist/apps/shared/components/layout/UserMenu.js +52 -5
  96. package/dist/apps/shared/components/mail/ConversationList.js +4 -11
  97. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  98. package/dist/apps/shared/components/mail/ConversationThreadPane.js +52 -27
  99. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  100. package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
  101. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
  102. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
  103. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
  104. package/dist/apps/shared/components/mail/MessageDetailPane.js +83 -34
  105. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
  106. package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
  107. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
  108. package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
  109. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  110. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
  111. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +98 -22
  112. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  113. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  114. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  115. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  116. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  117. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  118. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  119. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  120. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +12 -4
  121. package/dist/apps/shared/components/mail/layout/MailShell.js +76 -96
  122. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  123. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  124. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  125. package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
  126. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  127. package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
  128. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  129. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  130. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  131. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  132. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  133. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  134. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  135. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  136. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  137. package/dist/apps/shared/keyboard/format.js +74 -0
  138. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  139. package/dist/apps/shared/keyboard/keymap.js +84 -0
  140. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  141. package/dist/apps/shared/keyboard/match.js +31 -0
  142. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  143. package/dist/apps/shared/keyboard/parse.js +107 -0
  144. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  145. package/dist/apps/shared/keyboard/platform.js +22 -0
  146. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  147. package/dist/apps/shared/keyboard/registry.js +33 -0
  148. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  149. package/dist/apps/shared/keyboard/targets.js +67 -0
  150. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  151. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  152. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  153. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  154. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  155. package/dist/apps/shared/mail/folderCounts.js +212 -0
  156. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  157. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  158. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  159. package/dist/apps/shared/mail/messageReadState.js +63 -0
  160. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  161. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  162. package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
  163. package/dist/apps/shared/mail/useMailConnection.js +96 -0
  164. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +13 -6
  165. package/dist/apps/shared/mail/useMailLiveUpdates.js +28 -26
  166. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  167. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  168. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
  169. package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
  170. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  171. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  172. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  173. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  174. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  175. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  176. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  177. package/dist/apps/shared/navigation/frameContext.js +24 -0
  178. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  179. package/dist/apps/shared/navigation/idle.js +43 -0
  180. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  181. package/dist/apps/shared/navigation/routerContext.js +56 -0
  182. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  183. package/dist/apps/shared/navigation/routes.js +37 -0
  184. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  185. package/dist/apps/shared/styles/app.css +28 -10
  186. package/dist/apps/www/_routedPage.d.ts +12 -0
  187. package/dist/apps/www/_routedPage.js +19 -0
  188. package/dist/apps/www/_routes.d.ts +11 -0
  189. package/dist/apps/www/_routes.js +29 -0
  190. package/dist/apps/www/calendar/index.d.ts +2 -2
  191. package/dist/apps/www/calendar/index.js +39 -8
  192. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  193. package/dist/apps/www/contacts/[uid].js +6 -2
  194. package/dist/apps/www/contacts/index.d.ts +2 -2
  195. package/dist/apps/www/contacts/index.js +17 -4
  196. package/dist/apps/www/index.d.ts +2 -2
  197. package/dist/apps/www/index.js +301 -35
  198. package/dist/apps/www/messages/[uid].d.ts +3 -7
  199. package/dist/apps/www/messages/[uid].js +7 -4
  200. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  201. package/dist/apps/www/settings/auto-reply/index.js +3 -1
  202. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  203. package/dist/apps/www/settings/encryption/index.js +3 -1
  204. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  205. package/dist/apps/www/settings/filters/[uid].js +3 -1
  206. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  207. package/dist/apps/www/settings/filters/index.js +3 -1
  208. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  209. package/dist/apps/www/settings/filters/new/index.js +6 -2
  210. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  211. package/dist/apps/www/settings/labels/index.js +3 -1
  212. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  213. package/dist/apps/www/settings/privacy/index.js +3 -1
  214. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  215. package/dist/apps/www/settings/read-receipts/index.js +3 -1
  216. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  217. package/dist/apps/www/settings/sharing/index.js +3 -1
  218. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  219. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  220. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  221. package/dist/apps/www/settings/signatures/index.js +3 -1
  222. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  223. package/dist/apps/www/settings/signatures/new/index.js +6 -2
  224. package/dist/apps/www/tasks/index.d.ts +2 -2
  225. package/dist/apps/www/tasks/index.js +15 -3
  226. package/package.json +2 -2
@@ -0,0 +1,56 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * What the client-side router (`AppRouter.tsx`) offers to the tree under it - the location and a way to navigate - and the hooks that read
7
+ * it. Kept apart from the router itself so that the app frame (`AppShell`), which the router renders, can use `useNavigate()` without a
8
+ * circular import. `AppRouter.tsx` re-exports everything here, so callers keep importing from there.
9
+ */
10
+ import { createContext, useContext, useEffect, useState } from "react";
11
+ export const RouterContext = createContext(null);
12
+ /** Before the browser's own location has been read (the server render and the first client render, which must match). */
13
+ export const UNKNOWN_LOCATION = { pathname: "", search: "", hash: "" };
14
+ export function readWindowLocation() {
15
+ const { pathname, search, hash } = window.location;
16
+ return { pathname, search, hash };
17
+ }
18
+ /** What `useNavigate()` does outside a router: an ordinary navigation, exactly as `window.location.href = href` always was. */
19
+ function navigateWithBrowser(href, options = {}) {
20
+ if (options.replace) {
21
+ window.location.replace(href);
22
+ }
23
+ else {
24
+ window.location.href = href;
25
+ }
26
+ }
27
+ /**
28
+ * The function to go somewhere with. `navigate("/calendar")` and `navigate("/?mailboxUid=a&folderUid=b")` change the page and
29
+ * the URL without a page load when the router knows the route (Mail, Calendar, Contacts, Tasks, Settings and their subpages),
30
+ * and load the page normally otherwise; outside the router it is always a normal navigation. Prefer a plain `<a href>` for
31
+ * anything the user clicks - the router intercepts it, and it stays a real link - and this for what code decides (after a
32
+ * save, a delete, a select). The returned function is stable across renders.
33
+ */
34
+ export function useNavigate() {
35
+ const router = useContext(RouterContext);
36
+ return router ? router.navigate : navigateWithBrowser;
37
+ }
38
+ /**
39
+ * Where the app is now, as `{ pathname, search, hash }`, updating as the router navigates (folder changes are `search`
40
+ * changes, not page changes). Empty until the browser's location has been read in an effect after the first render, so that
41
+ * the server render and the first client render agree - read query parameters from it in an effect, not during render.
42
+ */
43
+ export function useLocation() {
44
+ const router = useContext(RouterContext);
45
+ const [own, setOwn] = useState(UNKNOWN_LOCATION);
46
+ useEffect(() => {
47
+ if (!router) {
48
+ setOwn(readWindowLocation());
49
+ }
50
+ }, [router]);
51
+ return router ? router.location : own;
52
+ }
53
+ /** `useLocation().search` - what the shells read `?mailboxUid=` and the like from. */
54
+ export function useLocationSearch() {
55
+ return useLocation().search;
56
+ }
@@ -0,0 +1,32 @@
1
+ import type { ComponentType } from "react";
2
+ /**
3
+ * One page the client-side router (`AppRouter`) can show without a page load. The table of them (`apps/www/_routes.ts`)
4
+ * mirrors the files under `apps/www`: what isn't in it (the admin and escrow consoles, plugin pages, anything else) is
5
+ * reached by a normal navigation.
6
+ */
7
+ export interface RouteDefinition {
8
+ /** The route template: literal segments and `:name` parameters, the same shape as the file path (`/messages/:uid`
9
+ * for `messages/[uid].tsx`). The first entry that matches a URL wins, so list literal routes before parameterized
10
+ * ones (`/settings/filters/new` before `/settings/filters/:uid`). */
11
+ path: string;
12
+ /** The app-rail entry (or `"settings"`) highlighted while this page shows - what the page's shell passes as `active`. */
13
+ active: string;
14
+ /**
15
+ * Loads the page's module, which must have a `routedPage()` component as its default export. Written as
16
+ * `() => import("./calendar/index.js")` so the bundler makes the page its own chunk; it is called the first time the
17
+ * page is shown or prefetched (pointer over or focus on a link to it, or when the browser is idle).
18
+ */
19
+ load: () => Promise<{
20
+ default: ComponentType<any>;
21
+ }>;
22
+ /** Downloaded when the browser is idle after the first page has loaded - the app rail's own pages, which are the
23
+ * likeliest next click. */
24
+ idlePrefetch?: boolean;
25
+ }
26
+ export interface RouteMatch {
27
+ route: RouteDefinition;
28
+ /** The values of the template's `:name` segments, decoded. */
29
+ params: Record<string, string>;
30
+ }
31
+ /** The first route in `routes` whose template matches `pathname`, with its parameters. */
32
+ export declare function matchRoute(routes: readonly RouteDefinition[], pathname: string): RouteMatch | undefined;
@@ -0,0 +1,37 @@
1
+ /** Splits a path into segments, ignoring a trailing slash: `/` and `""` have none. */
2
+ function segmentsOf(path) {
3
+ return path.split("/").filter((segment) => segment !== "");
4
+ }
5
+ /** Matches `pathname` against one route template - `undefined` when it doesn't, or a parameter isn't valid percent-encoding. */
6
+ function matchTemplate(template, pathname) {
7
+ const expected = segmentsOf(template);
8
+ const actual = segmentsOf(pathname);
9
+ if (expected.length !== actual.length) {
10
+ return undefined;
11
+ }
12
+ const params = {};
13
+ for (let i = 0; i < expected.length; i++) {
14
+ if (expected[i].startsWith(":")) {
15
+ try {
16
+ params[expected[i].slice(1)] = decodeURIComponent(actual[i]);
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ else if (expected[i] !== actual[i]) {
23
+ return undefined;
24
+ }
25
+ }
26
+ return params;
27
+ }
28
+ /** The first route in `routes` whose template matches `pathname`, with its parameters. */
29
+ export function matchRoute(routes, pathname) {
30
+ for (const route of routes) {
31
+ const params = matchTemplate(route.path, pathname);
32
+ if (params) {
33
+ return { route, params };
34
+ }
35
+ }
36
+ return undefined;
37
+ }
@@ -22,7 +22,17 @@
22
22
  */
23
23
  import { useEffect, useRef } from "react";
24
24
  import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
25
- import { buildLocalIndex, cancelLocalIndexBuild } from "./localIndexBuilder.js";
25
+ /**
26
+ * The index builder (with the message security code it decrypts through - PKI.js and the ASN.1/X.509 libraries, over half a
27
+ * megabyte) is loaded only once a mailbox is actually unlocked and a build is due, so an inbox that never unlocks - the
28
+ * common case - never downloads it.
29
+ */
30
+ let builder;
31
+ const loadBuilder = () => (builder ?? (builder = import("./localIndexBuilder.js").catch((err) => {
32
+ // A failed download is tried again the next time one is due.
33
+ builder = undefined;
34
+ throw err;
35
+ })));
26
36
  import { destroyLocalIndex, pruneInaccessibleLocalIndexes } from "./localIndexRpcClient.js";
27
37
  export const POLL_INTERVAL_MS = 5000;
28
38
  /** Renders nothing - pure side-effect component, mounted by `MailShell.tsx`. */
@@ -42,7 +52,9 @@ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMai
42
52
  trackedRef.current.set(targetMailboxUid, true);
43
53
  // Never an unhandled rejection - a broken local index is best-effort infrastructure, not a build
44
54
  // the rest of the app depends on.
45
- buildLocalIndex(targetMailboxUid, unlocked, targetFolders).catch(() => undefined);
55
+ loadBuilder()
56
+ .then(({ buildLocalIndex }) => buildLocalIndex(targetMailboxUid, unlocked, targetFolders))
57
+ .catch(() => undefined);
46
58
  }
47
59
  useEffect(() => {
48
60
  if (mailboxUid) {
@@ -57,7 +69,9 @@ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMai
57
69
  trackedRef.current.set(trackedUid, false);
58
70
  // Stop the pass still running with the now-destroyed keys; the destroy itself also makes
59
71
  // the Worker reject anything that pass still sends.
60
- void cancelLocalIndexBuild(trackedUid);
72
+ loadBuilder()
73
+ .then(({ cancelLocalIndexBuild }) => cancelLocalIndexBuild(trackedUid))
74
+ .catch(() => undefined);
61
75
  void destroyLocalIndex(trackedUid);
62
76
  }
63
77
  }
@@ -276,13 +276,31 @@ h5,
276
276
  h6 {
277
277
  font-family: var(--rr-font-display);
278
278
  }
279
-
280
- /* The compose editor's quoted original (`composeQuoting.ts`'s blockquote). TipTap keeps the element but not the
281
- * inline style it was seeded with, and Tailwind's preflight resets the browser's own blockquote indent - without
282
- * this the quote under a reply reads as if it were part of the new message. */
283
- .tiptap blockquote {
284
- margin: 0;
285
- padding-left: 1em;
286
- border-left: 2px solid var(--rr-color-border);
287
- color: var(--rr-color-text-muted);
288
- }
279
+
280
+ /* The compose editor's quoted original (`composeQuoting.ts`'s blockquote). TipTap keeps the element but not the
281
+ * inline style it was seeded with, and Tailwind's preflight resets the browser's own blockquote indent - without
282
+ * this the quote under a reply reads as if it were part of the new message. */
283
+ .tiptap blockquote {
284
+ margin: 0;
285
+ padding-left: 1em;
286
+ border-left: 2px solid var(--rr-color-border);
287
+ color: var(--rr-color-text-muted);
288
+ }
289
+
290
+ /* A new-mail pop-up (`NewMailToasts.tsx`) eases in from just above its place. Only for readers who have not asked their system
291
+ * for less motion: with `prefers-reduced-motion: reduce` it simply appears. */
292
+ @media (prefers-reduced-motion: no-preference) {
293
+ .rr-toast-in {
294
+ animation: rr-toast-in 0.18s ease-out;
295
+ }
296
+ }
297
+ @keyframes rr-toast-in {
298
+ from {
299
+ opacity: 0;
300
+ transform: translateY(-0.5rem);
301
+ }
302
+ to {
303
+ opacity: 1;
304
+ transform: none;
305
+ }
306
+ }
@@ -0,0 +1,12 @@
1
+ import { ComponentType } from "react";
2
+ import { RoutedPageComponent } from "../shared/navigation/AppRouter.js";
3
+ /**
4
+ * Makes a `www` page the entry of the client-side router: the page's default export is `routedPage("/its/route", Page)`.
5
+ * `@rapidrest/react` server-renders and hydrates exactly that component, so what it renders - the router, with the persistent
6
+ * app frame (`AppChrome`) and `Page` inside it - is the whole client tree, and later pages replace `Page` without a page load.
7
+ * `path` is the page's route template, the same as its entry in `_routes.ts`.
8
+ *
9
+ * The result carries the plain `Page` as `.page`, which is what the router shows when it loads this module for a navigation
10
+ * (rendering the wrapped default export there would nest a second router).
11
+ */
12
+ export declare function routedPage<P extends object>(path: string, Page: ComponentType<P>): RoutedPageComponent<P>;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import AppRouter from "../shared/navigation/AppRouter.js";
3
+ import { wwwRoutes } from "./_routes.js";
4
+ /**
5
+ * Makes a `www` page the entry of the client-side router: the page's default export is `routedPage("/its/route", Page)`.
6
+ * `@rapidrest/react` server-renders and hydrates exactly that component, so what it renders - the router, with the persistent
7
+ * app frame (`AppChrome`) and `Page` inside it - is the whole client tree, and later pages replace `Page` without a page load.
8
+ * `path` is the page's route template, the same as its entry in `_routes.ts`.
9
+ *
10
+ * The result carries the plain `Page` as `.page`, which is what the router shows when it loads this module for a navigation
11
+ * (rendering the wrapped default export there would nest a second router).
12
+ */
13
+ export function routedPage(path, Page) {
14
+ function RoutedPage(props) {
15
+ return _jsx(AppRouter, { routes: wwwRoutes, initialPath: path, initialPage: Page, pageProps: props });
16
+ }
17
+ RoutedPage.page = Page;
18
+ return RoutedPage;
19
+ }
@@ -0,0 +1,11 @@
1
+ import type { RouteDefinition } from "../shared/navigation/routes.js";
2
+ /**
3
+ * Every `apps/www` page the client-side router can show without a page load (see `apps/shared/navigation/AppRouter.tsx`), in
4
+ * the order they are matched: literal routes before the parameterized ones that would also match them. Each page loads with a
5
+ * dynamic `import()`, which is what makes it a chunk of its own - only the page a document was rendered for is in its first
6
+ * JavaScript. Keep it in step with the files under `apps/www`; a page missing from here still works, it is just reached with a
7
+ * page load. (The file starts with `_` so `@rapidrest/react` doesn't treat it as a page.)
8
+ *
9
+ * The four app-rail pages are `idlePrefetch`ed: downloaded when the browser is idle after the first page has loaded.
10
+ */
11
+ export declare const wwwRoutes: readonly RouteDefinition[];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Every `apps/www` page the client-side router can show without a page load (see `apps/shared/navigation/AppRouter.tsx`), in
3
+ * the order they are matched: literal routes before the parameterized ones that would also match them. Each page loads with a
4
+ * dynamic `import()`, which is what makes it a chunk of its own - only the page a document was rendered for is in its first
5
+ * JavaScript. Keep it in step with the files under `apps/www`; a page missing from here still works, it is just reached with a
6
+ * page load. (The file starts with `_` so `@rapidrest/react` doesn't treat it as a page.)
7
+ *
8
+ * The four app-rail pages are `idlePrefetch`ed: downloaded when the browser is idle after the first page has loaded.
9
+ */
10
+ export const wwwRoutes = [
11
+ { path: "/", active: "mail", load: () => import("./index.js"), idlePrefetch: true },
12
+ { path: "/messages/:uid", active: "mail", load: () => import("./messages/[uid].js") },
13
+ { path: "/calendar", active: "calendar", load: () => import("./calendar/index.js"), idlePrefetch: true },
14
+ { path: "/contacts", active: "contacts", load: () => import("./contacts/index.js"), idlePrefetch: true },
15
+ { path: "/contacts/:uid", active: "contacts", load: () => import("./contacts/[uid].js") },
16
+ { path: "/tasks", active: "tasks", load: () => import("./tasks/index.js"), idlePrefetch: true },
17
+ { path: "/settings/auto-reply", active: "settings", load: () => import("./settings/auto-reply/index.js") },
18
+ { path: "/settings/encryption", active: "settings", load: () => import("./settings/encryption/index.js") },
19
+ { path: "/settings/filters", active: "settings", load: () => import("./settings/filters/index.js") },
20
+ { path: "/settings/filters/new", active: "settings", load: () => import("./settings/filters/new/index.js") },
21
+ { path: "/settings/filters/:uid", active: "settings", load: () => import("./settings/filters/[uid].js") },
22
+ { path: "/settings/labels", active: "settings", load: () => import("./settings/labels/index.js") },
23
+ { path: "/settings/privacy", active: "settings", load: () => import("./settings/privacy/index.js") },
24
+ { path: "/settings/read-receipts", active: "settings", load: () => import("./settings/read-receipts/index.js") },
25
+ { path: "/settings/sharing", active: "settings", load: () => import("./settings/sharing/index.js") },
26
+ { path: "/settings/signatures", active: "settings", load: () => import("./settings/signatures/index.js") },
27
+ { path: "/settings/signatures/new", active: "settings", load: () => import("./settings/signatures/new/index.js") },
28
+ { path: "/settings/signatures/:uid", active: "settings", load: () => import("./settings/signatures/[uid].js") },
29
+ ];
@@ -1,3 +1,3 @@
1
- import React from "react";
2
1
  import { CalendarShellProps } from "../../shared/components/calendar/layout/CalendarShell.js";
3
- export default function CalendarPage(props: CalendarShellProps): React.JSX.Element;
2
+ declare const _default: import("../../shared/navigation/AppRouter.js").RoutedPageComponent<CalendarShellProps>;
3
+ export default _default;
@@ -3,6 +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
7
  import { useEffect, useMemo, useRef, useState } from "react";
7
8
  import { DndContext, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
8
9
  import { HiOutlineBars3 } from "react-icons/hi2";
@@ -24,11 +25,33 @@ import SplitDayView from "../../shared/components/calendar/SplitDayView.js";
24
25
  import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
25
26
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
26
27
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
27
- export default function CalendarPage(props) {
28
+ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
29
+ import { useShortcut } from "../../shared/keyboard/useShortcut.js";
30
+ import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
31
+ function CalendarPage(props) {
28
32
  return (_jsx(CalendarShell, { ...props, children: _jsx(CalendarContent, { userUid: props.userUid }) }));
29
33
  }
30
34
  const VIEW_TYPES = ["month", "week", "workWeek", "day", "split"];
31
35
  const VIEW_LABELS = { month: "Month", week: "Week", workWeek: "Work Week", day: "Day", split: "Split" };
36
+ /** The shortcut that switches to a view (Outlook's Ctrl+Alt+1-4); the split view has none. */
37
+ const VIEW_SHORTCUTS = {
38
+ day: SHORTCUTS.calendar.day,
39
+ workWeek: SHORTCUTS.calendar.workWeek,
40
+ week: SHORTCUTS.calendar.week,
41
+ month: SHORTCUTS.calendar.month,
42
+ };
43
+ /** One of the view switcher's buttons, with its shortcut in the tooltip. */
44
+ function ViewButton({ view, current, onSelect }) {
45
+ const shortcut = VIEW_SHORTCUTS[view];
46
+ const hint = useShortcutProps(VIEW_LABELS[view], shortcut ?? SHORTCUTS.calendar.day, !!shortcut);
47
+ return (_jsx("button", { type: "button", onClick: () => onSelect(view), ...hint, className: [
48
+ "px-3 h-7 text-sm rounded-sm whitespace-nowrap",
49
+ current === view ? "bg-primary text-white" : "hover:bg-surface-alt",
50
+ (view === "workWeek" || view === "split") && "hidden md:inline-block",
51
+ ]
52
+ .filter(Boolean)
53
+ .join(" "), children: VIEW_LABELS[view] }));
54
+ }
32
55
  function CalendarContent({ userUid }) {
33
56
  const { mailboxUid, folderUid, calendarFolders, mailboxCalendars, mailboxes, reloadFolders, colorFor } = useCalendarShell();
34
57
  // `PointerSensor` alone activates a drag on the very first touch-move, indistinguishable from a
@@ -195,6 +218,19 @@ function CalendarContent({ userUid }) {
195
218
  function closeModal() {
196
219
  setModal(null);
197
220
  }
221
+ // Keyboard shortcuts - the toolbar's own actions. (A dialog open - the event editor - silences them.)
222
+ useShortcut(SHORTCUTS.calendar.create, () => openNewEvent(), { enabled: !!mailboxUid && !!folderUid });
223
+ useShortcut(SHORTCUTS.calendar.today, goToday);
224
+ useShortcut(SHORTCUTS.calendar.previous, () => shiftView(-1));
225
+ useShortcut(SHORTCUTS.calendar.next, () => shiftView(1));
226
+ useShortcut(SHORTCUTS.calendar.day, () => setView("day"));
227
+ useShortcut(SHORTCUTS.calendar.workWeek, () => setView("workWeek"));
228
+ useShortcut(SHORTCUTS.calendar.week, () => setView("week"));
229
+ useShortcut(SHORTCUTS.calendar.month, () => setView("month"));
230
+ const newEventHint = useShortcutProps("New event", SHORTCUTS.calendar.create, !!mailboxUid && !!folderUid);
231
+ const previousHint = useShortcutProps("Previous", SHORTCUTS.calendar.previous);
232
+ const todayHint = useShortcutProps("Today", SHORTCUTS.calendar.today);
233
+ const nextHint = useShortcutProps("Next", SHORTCUTS.calendar.next);
198
234
  function handleSaved() {
199
235
  setModal(null);
200
236
  reload();
@@ -238,11 +274,6 @@ function CalendarContent({ userUid }) {
238
274
  .filter((mc) => mc.calendarFolders.length > 0 && writableMailboxes.some((mb) => mb.uid === mc.mailbox.uid))
239
275
  .map((mc) => ({ mailbox: mc.mailbox, calendars: mc.calendarFolders.map((f) => ({ uid: f.uid, name: f.name })) })), [mailboxCalendars, writableMailboxes]);
240
276
  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 })] }));
241
- return (_jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsx("div", { className: "hidden md: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", children: _jsx("div", { className: "flex flex-col", children: sidebarContent }) }), _jsxs("div", { className: "flex-1 flex flex-col min-h-0", children: [_jsxs("div", { className: "flex items-center gap-3 p-3 border-b border-border shrink-0", children: [_jsx("button", { type: "button", className: "md: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" }) }), _jsx(Button, { type: "button", onClick: () => openNewEvent(), className: "!w-auto shrink-0", children: "+ New event" }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", onClick: () => shiftView(-1), "aria-label": "Previous", className: "w-7 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "\u2039" }), _jsx("button", { type: "button", onClick: goToday, 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", 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 gap-1 overflow-x-auto", children: VIEW_TYPES.map((v) => (_jsx("button", { type: "button", onClick: () => setView(v), className: [
242
- "px-3 h-7 text-sm rounded-sm whitespace-nowrap",
243
- view === v ? "bg-primary text-white" : "hover:bg-surface-alt",
244
- (v === "workWeek" || v === "split") && "hidden md:inline-block",
245
- ]
246
- .filter(Boolean)
247
- .join(" "), children: VIEW_LABELS[v] }, v))) })] }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), actionError && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: actionError }) })), loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(DndContext, { sensors: sensors, onDragEnd: handleDragEnd, children: view === "month" ? (_jsx(MonthView, { viewDate: viewDate, occurrences: occurrences, folderColors: folderColors, onSelectDay: handleSelectDay, onSelectEvent: openEvent })) : view === "split" ? (_jsx(SplitDayView, { day: viewDate, columns: splitColumns, occurrences: occurrences, onSelectEvent: openEvent, onSelectSlot: (start, end, targetFolderUid) => openNewEvent(start, end, targetFolderUid) })) : (_jsx(TimeGridView, { days: days, occurrences: occurrences, folderColors: folderColors, onSelectEvent: openEvent, onSelectSlot: openNewEvent })) })), 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, organizerAddress: modalMailbox.primarySmtpAddress, organizerAliases: modalMailbox.aliasAddresses, occurrence: modal.occurrence, initialStart: modal.initialStart, initialEnd: modal.initialEnd, onSaved: handleSaved, onDeleted: handleDeleted }))] })] }));
277
+ return (_jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsx("div", { className: "hidden md: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", children: _jsx("div", { className: "flex flex-col", children: sidebarContent }) }), _jsxs("div", { className: "flex-1 flex flex-col min-h-0", children: [_jsxs("div", { className: "flex items-center gap-3 p-3 border-b border-border shrink-0", children: [_jsx("button", { type: "button", className: "md: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" }) }), _jsx(Button, { type: "button", onClick: () => openNewEvent(), 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 gap-1 overflow-x-auto", 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 }) })), actionError && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: actionError }) })), loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(DndContext, { sensors: sensors, onDragEnd: handleDragEnd, children: view === "month" ? (_jsx(MonthView, { viewDate: viewDate, occurrences: occurrences, folderColors: folderColors, onSelectDay: handleSelectDay, onSelectEvent: openEvent })) : view === "split" ? (_jsx(SplitDayView, { day: viewDate, columns: splitColumns, occurrences: occurrences, onSelectEvent: openEvent, onSelectSlot: (start, end, targetFolderUid) => openNewEvent(start, end, targetFolderUid) })) : (_jsx(TimeGridView, { days: days, occurrences: occurrences, folderColors: folderColors, onSelectEvent: openEvent, onSelectSlot: openNewEvent })) })), 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, organizerAddress: modalMailbox.primarySmtpAddress, organizerAliases: modalMailbox.aliasAddresses, occurrence: modal.occurrence, initialStart: modal.initialStart, initialEnd: modal.initialEnd, onSaved: handleSaved, onDeleted: handleDeleted }))] })] }));
248
278
  }
279
+ export default routedPage("/calendar", CalendarPage);
@@ -1,13 +1,7 @@
1
- import React from "react";
2
1
  import { ContactsShellProps } from "../../shared/components/contacts/layout/ContactsShell.js";
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
- export default function ContactDetailPage(props: ContactsShellProps & {
2
+ declare const _default: import("../../shared/navigation/AppRouter.js").RoutedPageComponent<ContactsShellProps & {
10
3
  params: {
11
4
  uid: string;
12
5
  };
13
- }): React.JSX.Element;
6
+ }>;
7
+ export default _default;
@@ -3,6 +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
8
  import { useEffect, useState } from "react";
7
9
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
10
  import { deleteContact, getContact } from "@rapidmx/react-shared/contacts/contactsApi.js";
@@ -17,10 +19,11 @@ import { clearPinnedSignerCache } from "../../shared/components/mail/pinnedSigne
17
19
  * equivalent "new contact" route — an unsaved contact has no uid to route on, so creation stays an
18
20
  * in-place mode-switch on every device (see `apps/www/contacts/index.tsx`'s `handleNew`).
19
21
  */
20
- export default function ContactDetailPage(props) {
22
+ function ContactDetailPage(props) {
21
23
  return (_jsx(ContactsShell, { ...props, children: _jsx(ContactDetailContent, { uid: props.params.uid }) }));
22
24
  }
23
25
  function ContactDetailContent({ uid }) {
26
+ const navigate = useNavigate();
24
27
  const [contact, setContact] = useState(null);
25
28
  const [loading, setLoading] = useState(true);
26
29
  const [error, setError] = useState(null);
@@ -46,7 +49,7 @@ function ContactDetailContent({ uid }) {
46
49
  await deleteContact(contact.uid, contact.version);
47
50
  // A deleted contact's pinned signing keys must stop vouching for signatures.
48
51
  clearPinnedSignerCache();
49
- window.location.href = "/contacts";
52
+ navigate("/contacts");
50
53
  }
51
54
  catch (err) {
52
55
  setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this contact.");
@@ -71,3 +74,4 @@ function ContactDetailContent({ uid }) {
71
74
  }
72
75
  return (_jsxs("div", { className: "p-6", children: [deleteError && _jsx(Alert, { children: deleteError }), _jsx(ContactDetailPane, { contact: contact, onEdit: () => setMode("edit"), onDelete: () => handleDelete(contact), backHref: backHref, onKeysChanged: handleKeysChanged })] }));
73
76
  }
77
+ export default routedPage("/contacts/:uid", ContactDetailPage);
@@ -1,3 +1,3 @@
1
- import React from "react";
2
1
  import { ContactsShellProps } from "../../shared/components/contacts/layout/ContactsShell.js";
3
- export default function ContactsPage(props: ContactsShellProps): React.JSX.Element;
2
+ declare const _default: import("../../shared/navigation/AppRouter.js").RoutedPageComponent<ContactsShellProps>;
3
+ export default _default;
@@ -3,7 +3,9 @@ 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 { useEffect, useMemo, useState } from "react";
6
+ import { routedPage } from "../_routedPage.js";
7
+ import { useNavigate } from "../../shared/navigation/AppRouter.js";
8
+ import { useEffect, useMemo, useRef, useState } from "react";
7
9
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
10
  import { createContact, deleteContact, listContacts, listDeletedContacts, setContactFavorite, updateContact, } from "@rapidmx/react-shared/contacts/contactsApi.js";
9
11
  import { contactsToVCardFile, contactToVCard, parseVCards } from "@rapidmx/react-shared/contacts/vcard.js";
@@ -22,8 +24,10 @@ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
22
24
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
23
25
  import { LIST_PAGE_SIZE, MAX_LIST_PAGES, listAllPages } from "../../shared/mail/listAllPages.js";
24
26
  import { clearPinnedSignerCache } from "../../shared/components/mail/pinnedSigners.js";
27
+ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
28
+ import { useShortcut } from "../../shared/keyboard/useShortcut.js";
25
29
  const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
26
- export default function ContactsPage(props) {
30
+ function ContactsPage(props) {
27
31
  return (_jsx(ContactsShell, { ...props, children: _jsx(ContactsContent, { userUid: props.userUid }) }));
28
32
  }
29
33
  /** Shown in the Deleted view when the server answered `?deleted=true` with live contacts (see `canViewDeleted`). */
@@ -51,6 +55,7 @@ function ContactsContent({ userUid }) {
51
55
  const writableMailboxes = useWritableMailboxes(mailboxes, userUid, mailboxUid);
52
56
  const { openCompose } = useCompose();
53
57
  const isMobile = useIsMobile();
58
+ const navigate = useNavigate();
54
59
  const [contacts, setContacts] = useState([]);
55
60
  const [deletedContacts, setDeletedContacts] = useState([]);
56
61
  const [deletedLoading, setDeletedLoading] = useState(false);
@@ -208,7 +213,7 @@ function ContactsContent({ userUid }) {
208
213
  }
209
214
  function handleSelectRow(contact) {
210
215
  if (isMobile) {
211
- window.location.href = `/contacts/${encodeURIComponent(contact.uid)}`;
216
+ navigate(`/contacts/${encodeURIComponent(contact.uid)}`);
212
217
  return;
213
218
  }
214
219
  setSelectedUid(contact.uid);
@@ -218,6 +223,13 @@ function ContactsContent({ userUid }) {
218
223
  setSelectedUid(null);
219
224
  setMode("new");
220
225
  }
226
+ // Keyboard shortcuts - the toolbar's New contact and the search box. (A dialog open silences them.)
227
+ const searchInputRef = useRef(null);
228
+ useShortcut(SHORTCUTS.contacts.create, handleNew);
229
+ useShortcut(SHORTCUTS.contacts.search, () => {
230
+ searchInputRef.current?.focus();
231
+ searchInputRef.current?.select();
232
+ });
221
233
  function handleSaved(contact) {
222
234
  setMode("view");
223
235
  if (contact.mailboxUid !== mailboxUid) {
@@ -337,5 +349,6 @@ function ContactsContent({ userUid }) {
337
349
  return (_jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsx(ContactsSidebar, { mailboxUid: mailboxUid, contacts: contacts, active: view, onSelect: handleSelectView, showDeleted: canViewDeleted }), _jsxs("div", { className: [
338
350
  "w-full md:w-96 shrink-0 md:border-r border-border md:flex flex-col min-h-0",
339
351
  mode === "new" ? "hidden md:flex" : "flex",
340
- ].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 }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { 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 overflow-auto", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-8 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "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: "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 items-center gap-2 text-left", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 28 }), _jsxs("span", { className: "truncate font-medium", children: [contact.displayName, contact.favorite && _jsx("span", { "aria-label": "Favorite", children: " \u2605" })] })] }) }), _jsx("td", { className: "px-3 py-2 text-text-muted truncate", children: primaryInfo(contact) })] }, contact.uid))) })] }) }))] }), _jsxs("div", { className: ["flex-1 min-w-0 overflow-y-auto p-6 md:block", mode === "new" ? "block" : "hidden"].join(" "), children: [savedElsewhere && mode !== "new" && (_jsxs("p", { role: "status", className: "mb-4 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(ContactDetailPane, { contact: selected, onEdit: () => setMode("edit"), onDelete: () => handleDelete(selected), onKeysChanged: () => void reload(), canResolveKeys: ownsMailbox || delegateCanUpdate })) : (_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" })] })] })] }));
352
+ ].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 }), _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 overflow-auto", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-8 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "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: "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 items-center gap-2 text-left", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 28 }), _jsxs("span", { className: "truncate font-medium", children: [contact.displayName, contact.favorite && _jsx("span", { "aria-label": "Favorite", children: " \u2605" })] })] }) }), _jsx("td", { className: "px-3 py-2 text-text-muted truncate", children: primaryInfo(contact) })] }, contact.uid))) })] }) }))] }), _jsxs("div", { className: ["flex-1 min-w-0 overflow-y-auto p-6 md:block", mode === "new" ? "block" : "hidden"].join(" "), children: [savedElsewhere && mode !== "new" && (_jsxs("p", { role: "status", className: "mb-4 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(ContactDetailPane, { contact: selected, onEdit: () => setMode("edit"), onDelete: () => handleDelete(selected), onKeysChanged: () => void reload(), canResolveKeys: ownsMailbox || delegateCanUpdate })) : (_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" })] })] })] }));
341
353
  }
354
+ export default routedPage("/contacts", ContactsPage);
@@ -1,3 +1,3 @@
1
- import React from "react";
2
1
  import { MailShellProps } from "../shared/components/mail/layout/MailShell.js";
3
- export default function InboxPage(props: MailShellProps): React.JSX.Element;
2
+ declare const _default: import("../shared/navigation/AppRouter.js").RoutedPageComponent<MailShellProps>;
3
+ export default _default;