@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,83 @@
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
+
12
+ export interface RouterLocation {
13
+ pathname: string;
14
+ /** With the leading `?`, or `""`. */
15
+ search: string;
16
+ /** With the leading `#`, or `""`. */
17
+ hash: string;
18
+ }
19
+
20
+ export interface NavigateOptions {
21
+ /** Replaces the current history entry instead of adding one (a redirect, or a state change that isn't a "place"). */
22
+ replace?: boolean;
23
+ }
24
+
25
+ /** Goes to `href`: without a page load when it is a route of the app, else as an ordinary navigation. */
26
+ export type NavigateFn = (href: string, options?: NavigateOptions) => void;
27
+
28
+ export interface RouterContextValue {
29
+ location: RouterLocation;
30
+ navigate: NavigateFn;
31
+ }
32
+
33
+ export const RouterContext = createContext<RouterContextValue | null>(null);
34
+
35
+ /** Before the browser's own location has been read (the server render and the first client render, which must match). */
36
+ export const UNKNOWN_LOCATION: RouterLocation = { pathname: "", search: "", hash: "" };
37
+
38
+ export function readWindowLocation(): RouterLocation {
39
+ const { pathname, search, hash } = window.location;
40
+ return { pathname, search, hash };
41
+ }
42
+
43
+ /** What `useNavigate()` does outside a router: an ordinary navigation, exactly as `window.location.href = href` always was. */
44
+ function navigateWithBrowser(href: string, options: NavigateOptions = {}): void {
45
+ if (options.replace) {
46
+ window.location.replace(href);
47
+ } else {
48
+ window.location.href = href;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The function to go somewhere with. `navigate("/calendar")` and `navigate("/?mailboxUid=a&folderUid=b")` change the page and
54
+ * the URL without a page load when the router knows the route (Mail, Calendar, Contacts, Tasks, Settings and their subpages),
55
+ * and load the page normally otherwise; outside the router it is always a normal navigation. Prefer a plain `<a href>` for
56
+ * anything the user clicks - the router intercepts it, and it stays a real link - and this for what code decides (after a
57
+ * save, a delete, a select). The returned function is stable across renders.
58
+ */
59
+ export function useNavigate(): NavigateFn {
60
+ const router = useContext(RouterContext);
61
+ return router ? router.navigate : navigateWithBrowser;
62
+ }
63
+
64
+ /**
65
+ * Where the app is now, as `{ pathname, search, hash }`, updating as the router navigates (folder changes are `search`
66
+ * changes, not page changes). Empty until the browser's location has been read in an effect after the first render, so that
67
+ * the server render and the first client render agree - read query parameters from it in an effect, not during render.
68
+ */
69
+ export function useLocation(): RouterLocation {
70
+ const router = useContext(RouterContext);
71
+ const [own, setOwn] = useState<RouterLocation>(UNKNOWN_LOCATION);
72
+ useEffect(() => {
73
+ if (!router) {
74
+ setOwn(readWindowLocation());
75
+ }
76
+ }, [router]);
77
+ return router ? router.location : own;
78
+ }
79
+
80
+ /** `useLocation().search` - what the shells read `?mailboxUid=` and the like from. */
81
+ export function useLocationSearch(): string {
82
+ return useLocation().search;
83
+ }
@@ -0,0 +1,72 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import type { ComponentType } from "react";
6
+
7
+ /**
8
+ * One page the client-side router (`AppRouter`) can show without a page load. The table of them (`apps/www/_routes.ts`)
9
+ * mirrors the files under `apps/www`: what isn't in it (the admin and escrow consoles, plugin pages, anything else) is
10
+ * reached by a normal navigation.
11
+ */
12
+ export interface RouteDefinition {
13
+ /** The route template: literal segments and `:name` parameters, the same shape as the file path (`/messages/:uid`
14
+ * for `messages/[uid].tsx`). The first entry that matches a URL wins, so list literal routes before parameterized
15
+ * ones (`/settings/filters/new` before `/settings/filters/:uid`). */
16
+ path: string;
17
+ /** The app-rail entry (or `"settings"`) highlighted while this page shows - what the page's shell passes as `active`. */
18
+ active: string;
19
+ /**
20
+ * Loads the page's module, which must have a `routedPage()` component as its default export. Written as
21
+ * `() => import("./calendar/index.js")` so the bundler makes the page its own chunk; it is called the first time the
22
+ * page is shown or prefetched (pointer over or focus on a link to it, or when the browser is idle).
23
+ */
24
+ load: () => Promise<{ default: ComponentType<any> }>;
25
+ /** Downloaded when the browser is idle after the first page has loaded - the app rail's own pages, which are the
26
+ * likeliest next click. */
27
+ idlePrefetch?: boolean;
28
+ }
29
+
30
+ export interface RouteMatch {
31
+ route: RouteDefinition;
32
+ /** The values of the template's `:name` segments, decoded. */
33
+ params: Record<string, string>;
34
+ }
35
+
36
+ /** Splits a path into segments, ignoring a trailing slash: `/` and `""` have none. */
37
+ function segmentsOf(path: string): string[] {
38
+ return path.split("/").filter((segment) => segment !== "");
39
+ }
40
+
41
+ /** Matches `pathname` against one route template - `undefined` when it doesn't, or a parameter isn't valid percent-encoding. */
42
+ function matchTemplate(template: string, pathname: string): Record<string, string> | undefined {
43
+ const expected = segmentsOf(template);
44
+ const actual = segmentsOf(pathname);
45
+ if (expected.length !== actual.length) {
46
+ return undefined;
47
+ }
48
+ const params: Record<string, string> = {};
49
+ for (let i = 0; i < expected.length; i++) {
50
+ if (expected[i].startsWith(":")) {
51
+ try {
52
+ params[expected[i].slice(1)] = decodeURIComponent(actual[i]);
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ } else if (expected[i] !== actual[i]) {
57
+ return undefined;
58
+ }
59
+ }
60
+ return params;
61
+ }
62
+
63
+ /** The first route in `routes` whose template matches `pathname`, with its parameters. */
64
+ export function matchRoute(routes: readonly RouteDefinition[], pathname: string): RouteMatch | undefined {
65
+ for (const route of routes) {
66
+ const params = matchTemplate(route.path, pathname);
67
+ if (params) {
68
+ return { route, params };
69
+ }
70
+ }
71
+ return undefined;
72
+ }
@@ -1,98 +1,114 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- /**
6
- * Mounts the Tier 2 local index's lifecycle: starts the background build for the active mailbox once it's
7
- * unlocked, and destroys an index on the same events that destroy that mailbox's unlocked keys (spec §11
8
- * "MUST be destroyed on the same events that destroy private keys: explicit logout, session revocation,
9
- * and the configurable idle timeout").
10
- *
11
- * **Why polling, not hooking the existing destroy call sites directly**: `destroyUnlockedKeys()`
12
- * (`react-shared/src/crypto/keySession.ts`) has no observer/callback hook. Watching `getUnlockedKeys()`
13
- * for a present-to-absent transition catches *every* destroy path uniformly - idle timeout, the manual
14
- * button, or anything else that calls it - without needing to know which one fired.
15
- *
16
- * Every mailbox this component has seen unlocked is tracked for the life of the mount - not just whichever
17
- * is active right now - so switching mailboxes (or a folders-list refresh re-running the build effect)
18
- * can never lose a pending unlocked-to-locked transition for a mailbox whose index was already built.
19
- *
20
- * **Explicit logout** navigates away immediately, which may not leave time for a poll tick - `AppShell.tsx`
21
- * handles that case itself via `destroyAllLocalIndexes()`.
22
- */
23
- import { useEffect, useRef } from "react";
24
- import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
25
- import type { PublicKey } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
26
- import type { Folder } from "@rapidmx/react-shared/mail/mailApi.js";
27
- import { buildLocalIndex, cancelLocalIndexBuild } from "./localIndexBuilder.js";
28
- import { destroyLocalIndex, pruneInaccessibleLocalIndexes } from "./localIndexRpcClient.js";
29
-
30
- export const POLL_INTERVAL_MS = 5_000;
31
-
32
- export interface LocalIndexLifecycleProps {
33
- mailboxUid?: string;
34
- mailboxKeys?: PublicKey[];
35
- folders: Folder[];
36
- /** Every mailbox the signed-in user can currently access. Once known, indexes on this device for any
37
- * other mailbox (e.g. left behind by a different user who never signed out) are removed. */
38
- accessibleMailboxUids?: string[];
39
- }
40
-
41
- /** Renders nothing - pure side-effect component, mounted by `MailShell.tsx`. */
42
- export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMailboxUids }: LocalIndexLifecycleProps) {
43
- // mailboxUid -> whether it was unlocked at the last check. An entry exists for every mailbox whose
44
- // build this component started; only ever changed by a real observed transition, never reset by an
45
- // effect re-run.
46
- const trackedRef = useRef(new Map<string, boolean>());
47
- // The latest props, for the long-lived polling interval below.
48
- const latestRef = useRef({ mailboxUid, folders });
49
- latestRef.current = { mailboxUid, folders };
50
-
51
- function startBuildIfUnlocked(targetMailboxUid: string, targetFolders: Folder[]) {
52
- const unlocked = getUnlockedKeys(targetMailboxUid);
53
- if (!unlocked || targetFolders.length === 0 || trackedRef.current.get(targetMailboxUid)) {
54
- return;
55
- }
56
- trackedRef.current.set(targetMailboxUid, true);
57
- // Never an unhandled rejection - a broken local index is best-effort infrastructure, not a build
58
- // the rest of the app depends on.
59
- buildLocalIndex(targetMailboxUid, unlocked, targetFolders).catch(() => undefined);
60
- }
61
-
62
- useEffect(() => {
63
- if (mailboxUid) {
64
- startBuildIfUnlocked(mailboxUid, folders);
65
- }
66
- }, [mailboxUid, folders]);
67
-
68
- useEffect(() => {
69
- const interval = setInterval(() => {
70
- for (const [trackedUid, wasUnlocked] of trackedRef.current) {
71
- if (wasUnlocked && !getUnlockedKeys(trackedUid)) {
72
- // A present-to-absent transition: something just destroyed this mailbox's keys.
73
- trackedRef.current.set(trackedUid, false);
74
- // Stop the pass still running with the now-destroyed keys; the destroy itself also makes
75
- // the Worker reject anything that pass still sends.
76
- void cancelLocalIndexBuild(trackedUid);
77
- void destroyLocalIndex(trackedUid);
78
- }
79
- }
80
- // The active mailbox was (re-)unlocked since the last check, e.g. via an on-demand unlock prompt.
81
- const { mailboxUid: activeUid, folders: activeFolders } = latestRef.current;
82
- if (activeUid) {
83
- startBuildIfUnlocked(activeUid, activeFolders);
84
- }
85
- }, POLL_INTERVAL_MS);
86
- return () => clearInterval(interval);
87
- }, []);
88
-
89
- // A stable string key so a re-render with an equal-but-new array doesn't re-scan OPFS.
90
- const accessibleKey = accessibleMailboxUids && accessibleMailboxUids.length > 0 ? JSON.stringify([...accessibleMailboxUids].sort()) : "";
91
- useEffect(() => {
92
- if (accessibleKey) {
93
- void pruneInaccessibleLocalIndexes(JSON.parse(accessibleKey) as string[]);
94
- }
95
- }, [accessibleKey]);
96
-
97
- return null;
98
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Mounts the Tier 2 local index's lifecycle: starts the background build for the active mailbox once it's
7
+ * unlocked, and destroys an index on the same events that destroy that mailbox's unlocked keys (spec §11
8
+ * "MUST be destroyed on the same events that destroy private keys: explicit logout, session revocation,
9
+ * and the configurable idle timeout").
10
+ *
11
+ * **Why polling, not hooking the existing destroy call sites directly**: `destroyUnlockedKeys()`
12
+ * (`react-shared/src/crypto/keySession.ts`) has no observer/callback hook. Watching `getUnlockedKeys()`
13
+ * for a present-to-absent transition catches *every* destroy path uniformly - idle timeout, the manual
14
+ * button, or anything else that calls it - without needing to know which one fired.
15
+ *
16
+ * Every mailbox this component has seen unlocked is tracked for the life of the mount - not just whichever
17
+ * is active right now - so switching mailboxes (or a folders-list refresh re-running the build effect)
18
+ * can never lose a pending unlocked-to-locked transition for a mailbox whose index was already built.
19
+ *
20
+ * **Explicit logout** navigates away immediately, which may not leave time for a poll tick - `AppShell.tsx`
21
+ * handles that case itself via `destroyAllLocalIndexes()`.
22
+ */
23
+ import { useEffect, useRef } from "react";
24
+ import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
25
+ import type { PublicKey } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
26
+ import type { Folder } from "@rapidmx/react-shared/mail/mailApi.js";
27
+
28
+ /**
29
+ * The index builder (with the message security code it decrypts through - PKI.js and the ASN.1/X.509 libraries, over half a
30
+ * megabyte) is loaded only once a mailbox is actually unlocked and a build is due, so an inbox that never unlocks - the
31
+ * common case - never downloads it.
32
+ */
33
+ let builder: Promise<typeof import("./localIndexBuilder.js")> | undefined;
34
+ const loadBuilder = () =>
35
+ (builder ??= import("./localIndexBuilder.js").catch((err) => {
36
+ // A failed download is tried again the next time one is due.
37
+ builder = undefined;
38
+ throw err;
39
+ }));
40
+ import { destroyLocalIndex, pruneInaccessibleLocalIndexes } from "./localIndexRpcClient.js";
41
+
42
+ export const POLL_INTERVAL_MS = 5_000;
43
+
44
+ export interface LocalIndexLifecycleProps {
45
+ mailboxUid?: string;
46
+ mailboxKeys?: PublicKey[];
47
+ folders: Folder[];
48
+ /** Every mailbox the signed-in user can currently access. Once known, indexes on this device for any
49
+ * other mailbox (e.g. left behind by a different user who never signed out) are removed. */
50
+ accessibleMailboxUids?: string[];
51
+ }
52
+
53
+ /** Renders nothing - pure side-effect component, mounted by `MailShell.tsx`. */
54
+ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMailboxUids }: LocalIndexLifecycleProps) {
55
+ // mailboxUid -> whether it was unlocked at the last check. An entry exists for every mailbox whose
56
+ // build this component started; only ever changed by a real observed transition, never reset by an
57
+ // effect re-run.
58
+ const trackedRef = useRef(new Map<string, boolean>());
59
+ // The latest props, for the long-lived polling interval below.
60
+ const latestRef = useRef({ mailboxUid, folders });
61
+ latestRef.current = { mailboxUid, folders };
62
+
63
+ function startBuildIfUnlocked(targetMailboxUid: string, targetFolders: Folder[]) {
64
+ const unlocked = getUnlockedKeys(targetMailboxUid);
65
+ if (!unlocked || targetFolders.length === 0 || trackedRef.current.get(targetMailboxUid)) {
66
+ return;
67
+ }
68
+ trackedRef.current.set(targetMailboxUid, true);
69
+ // Never an unhandled rejection - a broken local index is best-effort infrastructure, not a build
70
+ // the rest of the app depends on.
71
+ loadBuilder()
72
+ .then(({ buildLocalIndex }) => buildLocalIndex(targetMailboxUid, unlocked, targetFolders))
73
+ .catch(() => undefined);
74
+ }
75
+
76
+ useEffect(() => {
77
+ if (mailboxUid) {
78
+ startBuildIfUnlocked(mailboxUid, folders);
79
+ }
80
+ }, [mailboxUid, folders]);
81
+
82
+ useEffect(() => {
83
+ const interval = setInterval(() => {
84
+ for (const [trackedUid, wasUnlocked] of trackedRef.current) {
85
+ if (wasUnlocked && !getUnlockedKeys(trackedUid)) {
86
+ // A present-to-absent transition: something just destroyed this mailbox's keys.
87
+ trackedRef.current.set(trackedUid, false);
88
+ // Stop the pass still running with the now-destroyed keys; the destroy itself also makes
89
+ // the Worker reject anything that pass still sends.
90
+ loadBuilder()
91
+ .then(({ cancelLocalIndexBuild }) => cancelLocalIndexBuild(trackedUid))
92
+ .catch(() => undefined);
93
+ void destroyLocalIndex(trackedUid);
94
+ }
95
+ }
96
+ // The active mailbox was (re-)unlocked since the last check, e.g. via an on-demand unlock prompt.
97
+ const { mailboxUid: activeUid, folders: activeFolders } = latestRef.current;
98
+ if (activeUid) {
99
+ startBuildIfUnlocked(activeUid, activeFolders);
100
+ }
101
+ }, POLL_INTERVAL_MS);
102
+ return () => clearInterval(interval);
103
+ }, []);
104
+
105
+ // A stable string key so a re-render with an equal-but-new array doesn't re-scan OPFS.
106
+ const accessibleKey = accessibleMailboxUids && accessibleMailboxUids.length > 0 ? JSON.stringify([...accessibleMailboxUids].sort()) : "";
107
+ useEffect(() => {
108
+ if (accessibleKey) {
109
+ void pruneInaccessibleLocalIndexes(JSON.parse(accessibleKey) as string[]);
110
+ }
111
+ }, [accessibleKey]);
112
+
113
+ return null;
114
+ }
@@ -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,24 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ComponentType } from "react";
6
+ import AppRouter, { RoutedPageComponent } from "../shared/navigation/AppRouter.js";
7
+ import { wwwRoutes } from "./_routes.js";
8
+
9
+ /**
10
+ * Makes a `www` page the entry of the client-side router: the page's default export is `routedPage("/its/route", Page)`.
11
+ * `@rapidrest/react` server-renders and hydrates exactly that component, so what it renders - the router, with the persistent
12
+ * app frame (`AppChrome`) and `Page` inside it - is the whole client tree, and later pages replace `Page` without a page load.
13
+ * `path` is the page's route template, the same as its entry in `_routes.ts`.
14
+ *
15
+ * The result carries the plain `Page` as `.page`, which is what the router shows when it loads this module for a navigation
16
+ * (rendering the wrapped default export there would nest a second router).
17
+ */
18
+ export function routedPage<P extends object>(path: string, Page: ComponentType<P>): RoutedPageComponent<P> {
19
+ function RoutedPage(props: P) {
20
+ return <AppRouter routes={wwwRoutes} initialPath={path} initialPage={Page} pageProps={props} />;
21
+ }
22
+ RoutedPage.page = Page;
23
+ return RoutedPage;
24
+ }
@@ -0,0 +1,35 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import type { RouteDefinition } from "../shared/navigation/routes.js";
6
+
7
+ /**
8
+ * Every `apps/www` page the client-side router can show without a page load (see `apps/shared/navigation/AppRouter.tsx`), in
9
+ * the order they are matched: literal routes before the parameterized ones that would also match them. Each page loads with a
10
+ * dynamic `import()`, which is what makes it a chunk of its own - only the page a document was rendered for is in its first
11
+ * JavaScript. Keep it in step with the files under `apps/www`; a page missing from here still works, it is just reached with a
12
+ * page load. (The file starts with `_` so `@rapidrest/react` doesn't treat it as a page.)
13
+ *
14
+ * The four app-rail pages are `idlePrefetch`ed: downloaded when the browser is idle after the first page has loaded.
15
+ */
16
+ export const wwwRoutes: readonly RouteDefinition[] = [
17
+ { path: "/", active: "mail", load: () => import("./index.js"), idlePrefetch: true },
18
+ { path: "/messages/:uid", active: "mail", load: () => import("./messages/[uid].js") },
19
+ { path: "/calendar", active: "calendar", load: () => import("./calendar/index.js"), idlePrefetch: true },
20
+ { path: "/contacts", active: "contacts", load: () => import("./contacts/index.js"), idlePrefetch: true },
21
+ { path: "/contacts/:uid", active: "contacts", load: () => import("./contacts/[uid].js") },
22
+ { path: "/tasks", active: "tasks", load: () => import("./tasks/index.js"), idlePrefetch: true },
23
+ { path: "/settings/auto-reply", active: "settings", load: () => import("./settings/auto-reply/index.js") },
24
+ { path: "/settings/encryption", active: "settings", load: () => import("./settings/encryption/index.js") },
25
+ { path: "/settings/filters", active: "settings", load: () => import("./settings/filters/index.js") },
26
+ { path: "/settings/filters/new", active: "settings", load: () => import("./settings/filters/new/index.js") },
27
+ { path: "/settings/filters/:uid", active: "settings", load: () => import("./settings/filters/[uid].js") },
28
+ { path: "/settings/labels", active: "settings", load: () => import("./settings/labels/index.js") },
29
+ { path: "/settings/privacy", active: "settings", load: () => import("./settings/privacy/index.js") },
30
+ { path: "/settings/read-receipts", active: "settings", load: () => import("./settings/read-receipts/index.js") },
31
+ { path: "/settings/sharing", active: "settings", load: () => import("./settings/sharing/index.js") },
32
+ { path: "/settings/signatures", active: "settings", load: () => import("./settings/signatures/index.js") },
33
+ { path: "/settings/signatures/new", active: "settings", load: () => import("./settings/signatures/new/index.js") },
34
+ { path: "/settings/signatures/:uid", active: "settings", load: () => import("./settings/signatures/[uid].js") },
35
+ ];
@@ -2,6 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
+ import { routedPage } from "../_routedPage.js";
5
6
  import React, { useEffect, useMemo, useRef, useState } from "react";
6
7
  import { DndContext, DragEndEvent, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
7
8
  import { HiOutlineBars3 } from "react-icons/hi2";
@@ -36,8 +37,11 @@ import SplitDayView from "../../shared/components/calendar/SplitDayView.js";
36
37
  import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
37
38
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
38
39
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
40
+ import { SHORTCUTS, ShortcutDef } from "../../shared/keyboard/keymap.js";
41
+ import { useShortcut } from "../../shared/keyboard/useShortcut.js";
42
+ import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
39
43
 
40
- export default function CalendarPage(props: CalendarShellProps) {
44
+ function CalendarPage(props: CalendarShellProps) {
41
45
  return (
42
46
  <CalendarShell {...props}>
43
47
  <CalendarContent userUid={props.userUid} />
@@ -48,6 +52,35 @@ export default function CalendarPage(props: CalendarShellProps) {
48
52
  type ViewType = "month" | "week" | "workWeek" | "day" | "split";
49
53
  const VIEW_TYPES: ViewType[] = ["month", "week", "workWeek", "day", "split"];
50
54
  const VIEW_LABELS: Record<ViewType, string> = { month: "Month", week: "Week", workWeek: "Work Week", day: "Day", split: "Split" };
55
+ /** The shortcut that switches to a view (Outlook's Ctrl+Alt+1-4); the split view has none. */
56
+ const VIEW_SHORTCUTS: Partial<Record<ViewType, ShortcutDef>> = {
57
+ day: SHORTCUTS.calendar.day,
58
+ workWeek: SHORTCUTS.calendar.workWeek,
59
+ week: SHORTCUTS.calendar.week,
60
+ month: SHORTCUTS.calendar.month,
61
+ };
62
+
63
+ /** One of the view switcher's buttons, with its shortcut in the tooltip. */
64
+ function ViewButton({ view, current, onSelect }: { view: ViewType; current: ViewType; onSelect: (view: ViewType) => void }) {
65
+ const shortcut = VIEW_SHORTCUTS[view];
66
+ const hint = useShortcutProps(VIEW_LABELS[view], shortcut ?? SHORTCUTS.calendar.day, !!shortcut);
67
+ return (
68
+ <button
69
+ type="button"
70
+ onClick={() => onSelect(view)}
71
+ {...hint}
72
+ className={[
73
+ "px-3 h-7 text-sm rounded-sm whitespace-nowrap",
74
+ current === view ? "bg-primary text-white" : "hover:bg-surface-alt",
75
+ (view === "workWeek" || view === "split") && "hidden md:inline-block",
76
+ ]
77
+ .filter(Boolean)
78
+ .join(" ")}
79
+ >
80
+ {VIEW_LABELS[view]}
81
+ </button>
82
+ );
83
+ }
51
84
 
52
85
  interface ModalState {
53
86
  occurrence: CalendarOccurrence | null;
@@ -254,6 +287,20 @@ function CalendarContent({ userUid }: { userUid?: string }) {
254
287
  setModal(null);
255
288
  }
256
289
 
290
+ // Keyboard shortcuts - the toolbar's own actions. (A dialog open - the event editor - silences them.)
291
+ useShortcut(SHORTCUTS.calendar.create, () => openNewEvent(), { enabled: !!mailboxUid && !!folderUid });
292
+ useShortcut(SHORTCUTS.calendar.today, goToday);
293
+ useShortcut(SHORTCUTS.calendar.previous, () => shiftView(-1));
294
+ useShortcut(SHORTCUTS.calendar.next, () => shiftView(1));
295
+ useShortcut(SHORTCUTS.calendar.day, () => setView("day"));
296
+ useShortcut(SHORTCUTS.calendar.workWeek, () => setView("workWeek"));
297
+ useShortcut(SHORTCUTS.calendar.week, () => setView("week"));
298
+ useShortcut(SHORTCUTS.calendar.month, () => setView("month"));
299
+ const newEventHint = useShortcutProps("New event", SHORTCUTS.calendar.create, !!mailboxUid && !!folderUid);
300
+ const previousHint = useShortcutProps("Previous", SHORTCUTS.calendar.previous);
301
+ const todayHint = useShortcutProps("Today", SHORTCUTS.calendar.today);
302
+ const nextHint = useShortcutProps("Next", SHORTCUTS.calendar.next);
303
+
257
304
  function handleSaved() {
258
305
  setModal(null);
259
306
  reload();
@@ -333,37 +380,24 @@ function CalendarContent({ userUid }: { userUid?: string }) {
333
380
  >
334
381
  <HiOutlineBars3 size={20} aria-hidden="true" />
335
382
  </button>
336
- <Button type="button" onClick={() => openNewEvent()} className="!w-auto shrink-0">
383
+ <Button type="button" onClick={() => openNewEvent()} className="!w-auto shrink-0" {...newEventHint}>
337
384
  + New event
338
385
  </Button>
339
386
  <div className="flex items-center gap-1">
340
- <button type="button" onClick={() => shiftView(-1)} aria-label="Previous" className="w-7 h-7 text-sm rounded-sm hover:bg-surface-alt">
387
+ <button type="button" onClick={() => shiftView(-1)} aria-label="Previous" {...previousHint} className="w-7 h-7 text-sm rounded-sm hover:bg-surface-alt">
341
388
  &lsaquo;
342
389
  </button>
343
- <button type="button" onClick={goToday} className="px-2 h-7 text-sm rounded-sm hover:bg-surface-alt">
390
+ <button type="button" onClick={goToday} {...todayHint} className="px-2 h-7 text-sm rounded-sm hover:bg-surface-alt">
344
391
  Today
345
392
  </button>
346
- <button type="button" onClick={() => shiftView(1)} aria-label="Next" className="w-7 h-7 text-sm rounded-sm hover:bg-surface-alt">
393
+ <button type="button" onClick={() => shiftView(1)} aria-label="Next" {...nextHint} className="w-7 h-7 text-sm rounded-sm hover:bg-surface-alt">
347
394
  &rsaquo;
348
395
  </button>
349
396
  </div>
350
397
  <h1 className="hidden md:block text-lg font-bold tracking-tight">{title}</h1>
351
398
  <div className="ml-auto flex gap-1 overflow-x-auto">
352
399
  {VIEW_TYPES.map((v) => (
353
- <button
354
- key={v}
355
- type="button"
356
- onClick={() => setView(v)}
357
- className={[
358
- "px-3 h-7 text-sm rounded-sm whitespace-nowrap",
359
- view === v ? "bg-primary text-white" : "hover:bg-surface-alt",
360
- (v === "workWeek" || v === "split") && "hidden md:inline-block",
361
- ]
362
- .filter(Boolean)
363
- .join(" ")}
364
- >
365
- {VIEW_LABELS[v]}
366
- </button>
400
+ <ViewButton key={v} view={v} current={view} onSelect={setView} />
367
401
  ))}
368
402
  </div>
369
403
  </div>
@@ -436,3 +470,5 @@ function CalendarContent({ userUid }: { userUid?: string }) {
436
470
  </div>
437
471
  );
438
472
  }
473
+
474
+ export default routedPage("/calendar", CalendarPage);