@rapidmx/web-client 0.16.0 → 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/README.md +36 -28
  2. package/apps/admin/distribution-lists/new/index.tsx +3 -1
  3. package/apps/admin/domains/[uid].tsx +3 -1
  4. package/apps/admin/domains/new/index.tsx +3 -1
  5. package/apps/admin/escrow-scopes/[uid].tsx +3 -1
  6. package/apps/admin/escrow-scopes/new/index.tsx +3 -1
  7. package/apps/admin/index.tsx +3 -1
  8. package/apps/admin/mailboxes/[uid].tsx +3 -1
  9. package/apps/admin/mailboxes/new/index.tsx +3 -1
  10. package/apps/admin/transport-rules/new/index.tsx +3 -1
  11. package/apps/escrow/matters/new/index.tsx +3 -1
  12. package/apps/shared/components/admin/layout/AdminShell.tsx +4 -1
  13. package/apps/shared/components/admin/setup/SetupWizard.tsx +3 -1
  14. package/apps/shared/components/calendar/layout/CalendarShell.tsx +2 -2
  15. package/apps/shared/components/contacts/layout/ContactsShell.tsx +2 -2
  16. package/apps/shared/components/layout/AppShell.tsx +24 -43
  17. package/apps/shared/components/mail/ConversationThreadPane.tsx +1 -0
  18. package/apps/shared/components/mail/EmptyFolderBar.tsx +41 -0
  19. package/apps/shared/components/mail/MailSelectionBar.tsx +19 -15
  20. package/apps/shared/components/mail/MenuButton.tsx +31 -11
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +202 -4
  22. package/apps/shared/components/mail/PermanentDeleteDialog.tsx +47 -0
  23. package/apps/shared/components/mail/layout/MailShell.tsx +36 -34
  24. package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -0
  25. package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -0
  26. package/apps/shared/components/mail/reading/messageExport.ts +59 -0
  27. package/apps/shared/components/mail/reading/printMessage.ts +99 -0
  28. package/apps/shared/components/mail/reading/useMessageActions.ts +443 -0
  29. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -2
  30. package/apps/shared/components/tasks/layout/TasksShell.tsx +2 -2
  31. package/apps/shared/keyboard/GlobalShortcuts.tsx +1 -1
  32. package/apps/shared/mail/folderOfType.ts +49 -0
  33. package/apps/shared/mail/permanentDelete.ts +160 -0
  34. package/apps/shared/mail/reportNotices.ts +66 -0
  35. package/apps/shared/mail/senderBlocking.ts +141 -0
  36. package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -0
  37. package/apps/shared/mail/usePermanentDelete.tsx +160 -0
  38. package/apps/shared/navigation/frameContext.tsx +1 -1
  39. package/apps/shared/navigation/index.ts +70 -0
  40. package/apps/shared/navigation/pageTitle.ts +19 -0
  41. package/apps/www/_shell.tsx +82 -0
  42. package/apps/www/calendar/index.tsx +5 -2
  43. package/apps/www/contacts/[uid].tsx +6 -3
  44. package/apps/www/contacts/index.tsx +8 -3
  45. package/apps/www/index.tsx +146 -31
  46. package/apps/www/messages/[uid].tsx +8 -4
  47. package/apps/www/settings/appearance/index.tsx +5 -2
  48. package/apps/www/settings/auto-reply/index.tsx +5 -2
  49. package/apps/www/settings/blocked-senders/index.tsx +303 -0
  50. package/apps/www/settings/encryption/index.tsx +5 -2
  51. package/apps/www/settings/filters/[uid].tsx +5 -2
  52. package/apps/www/settings/filters/index.tsx +5 -2
  53. package/apps/www/settings/filters/new/index.tsx +30 -5
  54. package/apps/www/settings/labels/index.tsx +5 -2
  55. package/apps/www/settings/privacy/index.tsx +5 -2
  56. package/apps/www/settings/profile/index.tsx +5 -2
  57. package/apps/www/settings/read-receipts/index.tsx +5 -2
  58. package/apps/www/settings/sharing/index.tsx +5 -2
  59. package/apps/www/settings/signatures/[uid].tsx +5 -2
  60. package/apps/www/settings/signatures/index.tsx +5 -2
  61. package/apps/www/settings/signatures/new/index.tsx +6 -3
  62. package/apps/www/tasks/index.tsx +7 -2
  63. package/dist/apps/admin/distribution-lists/new/index.js +3 -1
  64. package/dist/apps/admin/domains/[uid].js +3 -1
  65. package/dist/apps/admin/domains/new/index.js +3 -1
  66. package/dist/apps/admin/escrow-scopes/[uid].js +3 -1
  67. package/dist/apps/admin/escrow-scopes/new/index.js +3 -1
  68. package/dist/apps/admin/index.js +3 -1
  69. package/dist/apps/admin/mailboxes/[uid].js +3 -1
  70. package/dist/apps/admin/mailboxes/new/index.js +3 -1
  71. package/dist/apps/admin/transport-rules/new/index.js +3 -1
  72. package/dist/apps/escrow/matters/new/index.js +3 -1
  73. package/dist/apps/shared/components/admin/layout/AdminShell.js +4 -1
  74. package/dist/apps/shared/components/admin/setup/SetupWizard.js +3 -1
  75. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +2 -2
  76. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +2 -2
  77. package/dist/apps/shared/components/layout/AppShell.d.ts +8 -11
  78. package/dist/apps/shared/components/layout/AppShell.js +24 -33
  79. package/dist/apps/shared/components/mail/ConversationThreadPane.js +1 -1
  80. package/dist/apps/shared/components/mail/EmptyFolderBar.d.ts +18 -0
  81. package/dist/apps/shared/components/mail/EmptyFolderBar.js +10 -0
  82. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +9 -4
  83. package/dist/apps/shared/components/mail/MailSelectionBar.js +10 -12
  84. package/dist/apps/shared/components/mail/MenuButton.d.ts +9 -2
  85. package/dist/apps/shared/components/mail/MenuButton.js +15 -11
  86. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +5 -0
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +147 -7
  88. package/dist/apps/shared/components/mail/PermanentDeleteDialog.d.ts +19 -0
  89. package/dist/apps/shared/components/mail/PermanentDeleteDialog.js +11 -0
  90. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +13 -9
  91. package/dist/apps/shared/components/mail/layout/MailShell.js +26 -33
  92. package/dist/apps/shared/components/mail/reading/MessageMoreMenu.d.ts +56 -0
  93. package/dist/apps/shared/components/mail/reading/MessageMoreMenu.js +153 -0
  94. package/dist/apps/shared/components/mail/reading/MessageSourceDialog.d.ts +27 -0
  95. package/dist/apps/shared/components/mail/reading/MessageSourceDialog.js +27 -0
  96. package/dist/apps/shared/components/mail/reading/messageExport.d.ts +19 -0
  97. package/dist/apps/shared/components/mail/reading/messageExport.js +51 -0
  98. package/dist/apps/shared/components/mail/reading/printMessage.d.ts +30 -0
  99. package/dist/apps/shared/components/mail/reading/printMessage.js +70 -0
  100. package/dist/apps/shared/components/mail/reading/useMessageActions.d.ts +57 -0
  101. package/dist/apps/shared/components/mail/reading/useMessageActions.js +366 -0
  102. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  103. package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -2
  104. package/dist/apps/shared/keyboard/GlobalShortcuts.js +1 -1
  105. package/dist/apps/shared/mail/folderOfType.d.ts +13 -0
  106. package/dist/apps/shared/mail/folderOfType.js +40 -0
  107. package/dist/apps/shared/mail/permanentDelete.d.ts +62 -0
  108. package/dist/apps/shared/mail/permanentDelete.js +127 -0
  109. package/dist/apps/shared/mail/reportNotices.d.ts +21 -0
  110. package/dist/apps/shared/mail/reportNotices.js +47 -0
  111. package/dist/apps/shared/mail/senderBlocking.d.ts +26 -0
  112. package/dist/apps/shared/mail/senderBlocking.js +92 -0
  113. package/dist/apps/shared/mail/useMailboxUpdateAccess.d.ts +11 -0
  114. package/dist/apps/shared/mail/useMailboxUpdateAccess.js +52 -0
  115. package/dist/apps/shared/mail/usePermanentDelete.d.ts +35 -0
  116. package/dist/apps/shared/mail/usePermanentDelete.js +96 -0
  117. package/dist/apps/shared/navigation/frameContext.d.ts +1 -1
  118. package/dist/apps/shared/navigation/index.d.ts +27 -0
  119. package/dist/apps/shared/navigation/index.js +58 -0
  120. package/dist/apps/shared/navigation/pageTitle.d.ts +11 -0
  121. package/dist/apps/shared/navigation/pageTitle.js +12 -0
  122. package/dist/apps/www/_shell.d.ts +26 -0
  123. package/dist/apps/www/_shell.js +31 -0
  124. package/dist/apps/www/calendar/index.d.ts +7 -2
  125. package/dist/apps/www/calendar/index.js +4 -2
  126. package/dist/apps/www/contacts/[uid].d.ts +14 -3
  127. package/dist/apps/www/contacts/[uid].js +5 -3
  128. package/dist/apps/www/contacts/index.d.ts +7 -2
  129. package/dist/apps/www/contacts/index.js +6 -4
  130. package/dist/apps/www/index.d.ts +7 -2
  131. package/dist/apps/www/index.js +117 -19
  132. package/dist/apps/www/messages/[uid].d.ts +14 -3
  133. package/dist/apps/www/messages/[uid].js +7 -5
  134. package/dist/apps/www/settings/appearance/index.d.ts +8 -2
  135. package/dist/apps/www/settings/appearance/index.js +4 -2
  136. package/dist/apps/www/settings/auto-reply/index.d.ts +7 -2
  137. package/dist/apps/www/settings/auto-reply/index.js +4 -2
  138. package/dist/apps/www/settings/blocked-senders/index.d.ts +9 -0
  139. package/dist/apps/www/settings/blocked-senders/index.js +134 -0
  140. package/dist/apps/www/settings/encryption/index.d.ts +7 -2
  141. package/dist/apps/www/settings/encryption/index.js +4 -2
  142. package/dist/apps/www/settings/filters/[uid].d.ts +7 -2
  143. package/dist/apps/www/settings/filters/[uid].js +4 -2
  144. package/dist/apps/www/settings/filters/index.d.ts +7 -2
  145. package/dist/apps/www/settings/filters/index.js +4 -2
  146. package/dist/apps/www/settings/filters/new/index.d.ts +7 -2
  147. package/dist/apps/www/settings/filters/new/index.js +28 -5
  148. package/dist/apps/www/settings/labels/index.d.ts +7 -2
  149. package/dist/apps/www/settings/labels/index.js +4 -2
  150. package/dist/apps/www/settings/privacy/index.d.ts +7 -2
  151. package/dist/apps/www/settings/privacy/index.js +4 -2
  152. package/dist/apps/www/settings/profile/index.d.ts +7 -2
  153. package/dist/apps/www/settings/profile/index.js +4 -2
  154. package/dist/apps/www/settings/read-receipts/index.d.ts +7 -2
  155. package/dist/apps/www/settings/read-receipts/index.js +4 -2
  156. package/dist/apps/www/settings/sharing/index.d.ts +7 -2
  157. package/dist/apps/www/settings/sharing/index.js +4 -2
  158. package/dist/apps/www/settings/signatures/[uid].d.ts +7 -2
  159. package/dist/apps/www/settings/signatures/[uid].js +4 -2
  160. package/dist/apps/www/settings/signatures/index.d.ts +7 -2
  161. package/dist/apps/www/settings/signatures/index.js +4 -2
  162. package/dist/apps/www/settings/signatures/new/index.d.ts +7 -2
  163. package/dist/apps/www/settings/signatures/new/index.js +5 -3
  164. package/dist/apps/www/tasks/index.d.ts +7 -2
  165. package/dist/apps/www/tasks/index.js +5 -3
  166. package/package.json +4 -2
  167. package/apps/shared/navigation/AppRouter.tsx +0 -300
  168. package/apps/shared/navigation/idle.ts +0 -45
  169. package/apps/shared/navigation/routerContext.tsx +0 -83
  170. package/apps/shared/navigation/routes.ts +0 -72
  171. package/apps/www/_routedPage.tsx +0 -24
  172. package/apps/www/_routes.ts +0 -37
  173. package/dist/apps/shared/navigation/AppRouter.d.ts +0 -52
  174. package/dist/apps/shared/navigation/AppRouter.js +0 -242
  175. package/dist/apps/shared/navigation/idle.d.ts +0 -14
  176. package/dist/apps/shared/navigation/idle.js +0 -43
  177. package/dist/apps/shared/navigation/routerContext.d.ts +0 -37
  178. package/dist/apps/shared/navigation/routerContext.js +0 -56
  179. package/dist/apps/shared/navigation/routes.d.ts +0 -32
  180. package/dist/apps/shared/navigation/routes.js +0 -37
  181. package/dist/apps/www/_routedPage.d.ts +0 -12
  182. package/dist/apps/www/_routedPage.js +0 -19
  183. package/dist/apps/www/_routes.d.ts +0 -11
  184. package/dist/apps/www/_routes.js +0 -31
package/README.md CHANGED
@@ -21,39 +21,47 @@ import SettingsShell from "@rapidmx/web-client/shared/components/settings/layout
21
21
 
22
22
  ## Navigation without page loads
23
23
 
24
- `@rapidrest/react` has no router: every page is a server-rendered document that hydrates only its own page component. The
25
- `www` pages (Mail, Calendar, Contacts, Tasks, Settings and their subpages) therefore render a small client-side router
26
- themselves, and go between one another - and between the folders of Mail - without a page load:
27
-
28
- - each `apps/www` page's default export is `routedPage("/its/route", Page)` (`apps/www/_routedPage.tsx`). The server
29
- renders and the browser hydrates exactly what it did before; the first load is unchanged. `apps/www/_routes.ts` lists the
30
- pages, each with a dynamic `import()` so it is a chunk of its own (a test keeps it in step with the files);
31
- - one `AppShell` chrome (the app rail, header, user menu, impersonation banner, compose windows in progress, the unlock
32
- prompt and the idle-key timer) stays mounted, and only the page inside it is replaced. A page's own shell
33
- (`MailShell`, `CalendarShell`, ...) still renders `AppShell`; inside the router that is only its children;
34
- - **links stay ordinary links.** Every same-origin `<a href>` to a route in the table is taken over - plain left clicks
35
- only, so ctrl/cmd/shift/middle click, `target`, `download`, `#hash` links and links marked `data-full-reload` keep
36
- the browser's behaviour, and pages that are not in the table (the admin and escrow consoles, plugin pages, other sites) are
37
- reached with a page load. The address bar always holds the real, shareable URL (`/?mailboxUid=&folderUid=`), and back and
38
- forward work;
39
- - a page's code is fetched when the pointer, focus or a press reaches a link to it, and for the app rail's pages when the
40
- browser is idle after load (not with data saving on); if it can't be loaded the router falls back to a page load;
41
- - after a page change focus moves to the content region (`#app-content`), the window scrolls to the top, `document.title`
42
- follows the page and a polite live region announces it.
43
-
44
- Code that decides where to go uses the two hooks in `shared/navigation/AppRouter.js`:
24
+ All three apps use `@rapidrest/react`'s client-side router (`router = true` on the server's route, the client built with
25
+ `createViteConfig({ router })`; `@rapidrest/react` is a **peer dependency** here, `>=2.1.0 <3`, because the router's state lives
26
+ in a React context that this package must share with the server's one copy). The server still renders every URL, so first
27
+ loads, reloads and crawlers are unchanged; after the first load a click on an ordinary `<a href>` between two pages of an app
28
+ fetches the next page's props as JSON and swaps the page in. Back and forward work, the address bar always holds the real,
29
+ shareable URL, and anything the router cannot be sure of (a modified click, `target`, `download`, `data-router-ignore`, another
30
+ app's page - the consoles, plugin pages, other sites - or a failure) is left to the browser as a page load.
31
+
32
+ The admin and escrow pages each render their own shell, so the router just swaps the page. The webmail (`apps/www`) has an **app
33
+ shell**, `apps/www/_shell.tsx` (the router's persistent client layout), which the server renders around every page and the
34
+ browser hydrates with it as one root:
35
+
36
+ - the one `AppChrome` (the app rail, header, user menu, impersonation banner, compose windows in progress, the unlock prompt,
37
+ the idle-key timer, the mail connection and the notification pop-ups) stays mounted while only the page inside it is replaced.
38
+ A page's own shell (`MailShell`, `CalendarShell`, ...) still renders `AppShell`; inside the frame that is only its children;
39
+ - the shell gets the current page's props - `WwwRoute.fetchProps()`'s, the same for every page - so the rail's highlighted app
40
+ (from the route), the branding, the plugin navigation and the impersonation banner follow the server on every navigation. That is
41
+ one request to `fetchProps()` per page change (warm for most, since the router prefetches a plain link on hover and the four rail
42
+ pages' code when the browser is idle);
43
+ - **a folder is a shallow navigation.** `<a data-router-shallow>` (the Mail folder links), `navigate()` to the same path with another
44
+ query (`useNavigate()`, below) and `useSearchParams()`' setter keep the page instance and its state and only change the location;
45
+ - after a page change the router moves focus to the content region (`#app-content`), scrolls to the top and announces the page's
46
+ title in a polite live region (all set by the shell with `useNavigationEffects()`); the tab's title is each page's `title`
47
+ export (`pageTitle("Calendar")`: `Acme Mail: Calendar`), rendered by the server and sent with every navigation, with the unread
48
+ count kept in front of it; the content region is `aria-busy` while the next page loads.
49
+
50
+ Code that decides where to go uses `useNavigate()` from `shared/navigation/index.js` and reads the location with the library's
51
+ hooks:
45
52
 
46
53
  ```tsx
47
- import { useLocation, useNavigate } from "@rapidmx/web-client/shared/navigation/AppRouter.js";
54
+ import { useLocation, useSearchParams } from "@rapidrest/react/client";
55
+ import { useNavigate } from "@rapidmx/web-client/shared/navigation/index.js";
48
56
 
49
- const navigate = useNavigate(); // navigate("/contacts"), navigate("/?mailboxUid=a&folderUid=b", { replace: true })
50
- const { pathname, search, hash } = useLocation(); // empty until read after the first render, so server and browser agree
57
+ const navigate = useNavigate(); // navigate("/contacts"); navigate("/?mailboxUid=a&folderUid=b") keeps the page (same path)
58
+ const { pathname, search, hash } = useLocation(); // the request's on the server and while hydrating; `hash` is empty there
59
+ const [params, setParams] = useSearchParams(); // setParams({ folderUid: "x" }) is shallow by default
51
60
  ```
52
61
 
53
- `navigate()` changes the page without a load when the URL is a route of the app and is an ordinary navigation otherwise
54
- (outside the router too, where it is `window.location.href = ...`). Page props are the same for every `www` page except
55
- `params`, which the router recomputes from the URL. Plugin pages are not part of the router (they render their own
56
- `AppShell` chrome) and are opened with a page load.
62
+ `navigate()` changes the page without a load when the URL is a page of the app, and is an ordinary navigation otherwise (outside a
63
+ router too). An admin or escrow page that leads to another page of its console after a save goes there with `useRouter().navigate`.
64
+ Plugin pages are not part of the router (they render their own `AppShell` chrome) and are opened with a page load.
57
65
 
58
66
  The compose window, the reading pane, S/MIME and the emoji list are also chunks of their own, loaded on demand or fetched
59
67
  when the browser is idle, so a page's first JavaScript is React and what the first screen draws.
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { FormEvent, useState } from "react";
6
+ import { useRouter } from "@rapidrest/react/client";
6
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
8
  import { createDistributionList } from "@rapidmx/react-shared/admin/distributionListsApi.js";
8
9
  import AdminShell, { AdminShellProps } from "../../../shared/components/admin/layout/AdminShell.js";
@@ -22,6 +23,7 @@ export default function NewDistributionListPage(props: Omit<AdminShellProps, "ac
22
23
  }
23
24
 
24
25
  function NewDistributionListForm() {
26
+ const { navigate } = useRouter();
25
27
  const [primarySmtpAddress, setPrimarySmtpAddress] = useState("");
26
28
  const [name, setName] = useState("");
27
29
  const [description, setDescription] = useState("");
@@ -48,7 +50,7 @@ function NewDistributionListForm() {
48
50
  name: name.trim(),
49
51
  description: description.trim() || undefined,
50
52
  });
51
- window.location.href = `/admin/distribution-lists/${encodeURIComponent(list.uid)}`;
53
+ void navigate(`/admin/distribution-lists/${encodeURIComponent(list.uid)}`);
52
54
  } catch (err) {
53
55
  setError(err instanceof ApiRequestError ? err.message : "Could not create the distribution list.");
54
56
  } finally {
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useState } from "react";
6
+ import { useRouter } from "@rapidrest/react/client";
6
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
8
  import { deleteDomain, Domain, updateDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
8
9
  import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
@@ -24,6 +25,7 @@ export default function DomainDetailPage(props: Omit<AdminShellProps, "active">
24
25
  }
25
26
 
26
27
  function DomainDetailContent({ uid }: { uid: string }) {
28
+ const { navigate } = useRouter();
27
29
  const [domain, setDomain] = useState<Domain | null>(null);
28
30
  const [confirmingDelete, setConfirmingDelete] = useState(false);
29
31
  const [deleting, setDeleting] = useState(false);
@@ -48,7 +50,7 @@ function DomainDetailContent({ uid }: { uid: string }) {
48
50
  setDeleteError(null);
49
51
  try {
50
52
  await deleteDomain(domain!.uid, domain!.version);
51
- window.location.href = "/admin/domains";
53
+ void navigate("/admin/domains");
52
54
  } catch (err) {
53
55
  setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this domain.");
54
56
  setDeleting(false);
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { FormEvent, useEffect, useState } from "react";
6
+ import { useRouter } from "@rapidrest/react/client";
6
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
8
  import { createDomain, Domain, listDomains } from "@rapidmx/react-shared/admin/domainsApi.js";
8
9
  import AdminShell, { AdminShellProps } from "../../../shared/components/admin/layout/AdminShell.js";
@@ -22,6 +23,7 @@ export default function NewDomainPage(props: Omit<AdminShellProps, "active">) {
22
23
  }
23
24
 
24
25
  function NewDomainForm() {
26
+ const { navigate } = useRouter();
25
27
  const [name, setName] = useState("");
26
28
  const [aliasOf, setAliasOf] = useState("");
27
29
  // Only an existing, non-alias domain can be aliased (no chains) - see `Domain.aliasOf`'s own doc comment
@@ -48,7 +50,7 @@ function NewDomainForm() {
48
50
  setSaving(true);
49
51
  try {
50
52
  const domain = await createDomain({ name: name.trim(), aliasOf: aliasOf || undefined });
51
- window.location.href = `/admin/domains/${encodeURIComponent(domain.uid)}`;
53
+ void navigate(`/admin/domains/${encodeURIComponent(domain.uid)}`);
52
54
  } catch (err) {
53
55
  setError(err instanceof ApiRequestError ? err.message : "Could not create the domain.");
54
56
  } finally {
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { FormEvent, useEffect, useState } from "react";
6
+ import { useRouter } from "@rapidrest/react/client";
6
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
8
  import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
8
9
  import {
@@ -118,6 +119,7 @@ export default function EscrowScopeDetailPage(props: Omit<AdminShellProps, "acti
118
119
  }
119
120
 
120
121
  function EscrowScopeDetailContent({ uid, adminUid }: { uid: string; adminUid?: string }) {
122
+ const { navigate } = useRouter();
121
123
  const [original, setOriginal] = useState<EscrowScope | null>(null);
122
124
  const [name, setName] = useState("");
123
125
  const [description, setDescription] = useState("");
@@ -225,7 +227,7 @@ function EscrowScopeDetailContent({ uid, adminUid }: { uid: string; adminUid?: s
225
227
  setDeleteError(null);
226
228
  try {
227
229
  await deleteEscrowScope(original!.uid, original!.version);
228
- window.location.href = "/admin/escrow-scopes";
230
+ void navigate("/admin/escrow-scopes");
229
231
  } catch (err) {
230
232
  // Most commonly a 409 ("referenced by an existing Matter") — see `deleteEscrowScope()`'s own
231
233
  // doc comment - surfaced as-is rather than special-cased.
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { FormEvent, useState } from "react";
6
+ import { useRouter } from "@rapidrest/react/client";
6
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
8
  import { createEscrowScope } from "@rapidmx/react-shared/admin/escrowScopesApi.js";
8
9
  import AdminShell, { AdminShellProps } from "../../../shared/components/admin/layout/AdminShell.js";
@@ -26,6 +27,7 @@ export default function NewEscrowScopePage(props: Omit<AdminShellProps, "active"
26
27
  }
27
28
 
28
29
  function NewEscrowScopeForm({ adminUid }: { adminUid?: string }) {
30
+ const { navigate } = useRouter();
29
31
  const [name, setName] = useState("");
30
32
  const [description, setDescription] = useState("");
31
33
  const [keyAndHolders, setKeyAndHolders] = useState(emptyEscrowScopeKeyAndHoldersValue());
@@ -74,7 +76,7 @@ function NewEscrowScopeForm({ adminUid }: { adminUid?: string }) {
74
76
  requiredHolders: keyAndHolders.requiredHolders,
75
77
  notifySubjectOnAccess: keyAndHolders.notifySubjectOnAccess,
76
78
  });
77
- window.location.href = `/admin/escrow-scopes/${encodeURIComponent(created.uid)}`;
79
+ void navigate(`/admin/escrow-scopes/${encodeURIComponent(created.uid)}`);
78
80
  } catch (err) {
79
81
  setError(err instanceof ApiRequestError ? err.message : "Could not create the escrow scope.");
80
82
  } finally {
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useEffect, useState } from "react";
6
+ import { useRouter } from "@rapidrest/react/client";
6
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
8
  import { listMailboxes, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
8
9
  import { reopenSetup } from "@rapidmx/react-shared/admin/setupApi.js";
@@ -24,6 +25,7 @@ export default function MailboxesListPage(props: Omit<AdminShellProps, "active">
24
25
  }
25
26
 
26
27
  function MailboxesListContent() {
28
+ const { navigate } = useRouter();
27
29
  const [page, setPage] = useState(0);
28
30
  const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
29
31
  const [loading, setLoading] = useState(true);
@@ -47,7 +49,7 @@ function MailboxesListContent() {
47
49
  setReopening(true);
48
50
  try {
49
51
  await reopenSetup();
50
- window.location.href = "/admin/setup";
52
+ void navigate("/admin/setup");
51
53
  } catch (err) {
52
54
  setError(err instanceof ApiRequestError ? err.message : "Could not reopen setup.");
53
55
  setReopening(false);
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useEffect, useState } from "react";
6
+ import { useRouter } from "@rapidrest/react/client";
6
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
8
  import { deleteMailbox, getMailbox, impersonateUser, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
8
9
  import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
@@ -34,6 +35,7 @@ function MailboxDetailContent({
34
35
  currentUserUid,
35
36
  }: { uid: string; currentUserUid?: string } & Pick<AdminShellProps, "impersonationBaseUrl">) {
36
37
  const [mailbox, setMailbox] = useState<Mailbox | null>(null);
38
+ const { navigate } = useRouter();
37
39
  const [loading, setLoading] = useState(true);
38
40
  const [error, setError] = useState<string | null>(null);
39
41
  const [confirmingAccess, setConfirmingAccess] = useState(false);
@@ -84,7 +86,7 @@ function MailboxDetailContent({
84
86
  try {
85
87
  await deleteMailbox(mailbox!.uid, mailbox!.version, { erase: eraseData });
86
88
  // The Mailboxes page it goes to lists what a delete without `erase` kept, and how far an erasure has got.
87
- window.location.href = "/admin";
89
+ void navigate("/admin");
88
90
  } catch (err) {
89
91
  // Most commonly a 409 if this mailbox is a custodian on an open legal hold (restapi's own
90
92
  // `assertNotOnLegalHold()`, naming the blocking Matter uid(s)) - surfaced as-is, same as
@@ -3,10 +3,12 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React from "react";
6
+ import { useRouter } from "@rapidrest/react/client";
6
7
  import AdminShell, { AdminShellProps } from "../../../shared/components/admin/layout/AdminShell.js";
7
8
  import MailboxCreateForm from "../../../shared/components/admin/settings/MailboxCreateForm.js";
8
9
 
9
10
  export default function NewMailboxPage(props: Omit<AdminShellProps, "active">) {
11
+ const { navigate } = useRouter();
10
12
  return (
11
13
  <AdminShell {...props} active="mailboxes">
12
14
  <div className="max-w-xl">
@@ -19,7 +21,7 @@ export default function NewMailboxPage(props: Omit<AdminShellProps, "active">) {
19
21
  <MailboxCreateForm
20
22
  cancelHref="/admin"
21
23
  onCreated={(mailbox) => {
22
- window.location.href = `/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`;
24
+ void navigate(`/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`);
23
25
  }}
24
26
  />
25
27
  </div>
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { FormEvent, useState } from "react";
6
+ import { useRouter } from "@rapidrest/react/client";
6
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
8
  import {
8
9
  createTransportRule,
@@ -34,6 +35,7 @@ export default function NewTransportRulePage(props: Omit<AdminShellProps, "activ
34
35
  }
35
36
 
36
37
  function NewTransportRuleForm() {
38
+ const { navigate } = useRouter();
37
39
  const [name, setName] = useState("");
38
40
  const [rule, setRule] = useState<RuleBuilderValue<TransportRuleConditions, TransportRuleAction>>({
39
41
  enabled: true,
@@ -73,7 +75,7 @@ function NewTransportRuleForm() {
73
75
  setSaving(true);
74
76
  try {
75
77
  const created = await createTransportRule({ name: name.trim(), ...rule });
76
- window.location.href = `/admin/transport-rules/${encodeURIComponent(created.uid)}`;
78
+ void navigate(`/admin/transport-rules/${encodeURIComponent(created.uid)}`);
77
79
  } catch (err) {
78
80
  setError(err instanceof ApiRequestError ? err.message : "Could not create the transport rule.");
79
81
  } finally {
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { FormEvent, useState } from "react";
6
+ import { useRouter } from "@rapidrest/react/client";
6
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
8
  import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
8
9
  import { createMatter } from "@rapidmx/react-shared/admin/mattersApi.js";
@@ -24,6 +25,7 @@ export default function NewMatterPage(props: Omit<EscrowShellProps, "active">) {
24
25
  }
25
26
 
26
27
  function NewMatterForm() {
28
+ const { navigate } = useRouter();
27
29
  const [name, setName] = useState("");
28
30
  const [description, setDescription] = useState("");
29
31
  // No dropdown here: `EscrowScope` (BaseEscrowScopeRoute) is trusted-admin-only end to end, so this
@@ -72,7 +74,7 @@ function NewMatterForm() {
72
74
  dateRangeStart: new Date(dateRangeStart).toISOString(),
73
75
  dateRangeEnd: new Date(dateRangeEnd).toISOString(),
74
76
  });
75
- window.location.href = `/escrow/matters/${encodeURIComponent(created.uid)}`;
77
+ void navigate(`/escrow/matters/${encodeURIComponent(created.uid)}`);
76
78
  } catch (err) {
77
79
  setError(err instanceof ApiRequestError ? err.message : "Could not create the matter.");
78
80
  } finally {
@@ -4,6 +4,7 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import "../../../styles/app.css";
6
6
  import React, { PropsWithChildren, ReactNode, useEffect, useState } from "react";
7
+ import { useRouter } from "@rapidrest/react/client";
7
8
  import {
8
9
  HiOutlineClipboardDocumentList,
9
10
  HiOutlineClock,
@@ -184,6 +185,7 @@ export function adminNavItems(pluginNav?: PluginNav): NavItem[] {
184
185
  * 403 `api-103` (elevated, but not an administrator), any other 403, and 401 mean "no administrator access".
185
186
  */
186
187
  export default function AdminShell({ active, userUid, authServerUrl, pluginNav, children }: PropsWithChildren<AdminShellProps>) {
188
+ const { navigate } = useRouter();
187
189
  const [status, setStatus] = useState<Status>("checking");
188
190
  const [error, setError] = useState<string | null>(null);
189
191
  const [menuOpen, setMenuOpen] = useState(false);
@@ -207,7 +209,8 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
207
209
  if (active !== "setup") {
208
210
  try {
209
211
  if ((await getSetupStatus()).required) {
210
- window.location.href = SETUP_ITEM.href;
212
+ // Replacing this page in the history, which would otherwise send the back button round again.
213
+ void navigate(SETUP_ITEM.href, { replace: true });
211
214
  return;
212
215
  }
213
216
  } catch {
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { FormEvent, useEffect, useRef, useState } from "react";
6
+ import { useRouter } from "@rapidrest/react/client";
6
7
  import { HiCheck } from "react-icons/hi2";
7
8
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
9
  import { getRetentionPolicy } from "@rapidmx/react-shared/admin/retentionPolicyApi.js";
@@ -61,6 +62,7 @@ export interface SetupWizardProps {
61
62
  * except the domain can be passed over and configured later from the admin console.
62
63
  */
63
64
  export default function SetupWizard({ userUid }: SetupWizardProps) {
65
+ const { navigate } = useRouter();
64
66
  const [step, setStep] = useState<SetupStepId | null>(null);
65
67
  const [status, setStatus] = useState<SetupStatus | undefined>();
66
68
  const [domains, setDomains] = useState<Domain[]>([]);
@@ -162,7 +164,7 @@ export default function SetupWizard({ userUid }: SetupWizardProps) {
162
164
  // Let a step still being recorded land first, so it can't arrive after setup is marked finished.
163
165
  await progress.current.running;
164
166
  await completeSetup();
165
- window.location.href = "/admin";
167
+ void navigate("/admin");
166
168
  } catch (err) {
167
169
  setError(errorMessage(err, "Could not finish setup."));
168
170
  setFinishing(false);
@@ -9,7 +9,7 @@ import { Folder, Mailbox, listFolders, listMailboxes } from "@rapidmx/react-shar
9
9
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
11
11
  import AppShell, { AppShellProps } from "../../layout/AppShell.js";
12
- import { useLocationSearch } from "../../../navigation/AppRouter.js";
12
+ import { useLocation } from "../../../navigation/index.js";
13
13
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
14
14
  import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
15
15
 
@@ -84,7 +84,7 @@ export default function CalendarShell({
84
84
 
85
85
  // Read from the router's location, so a mailbox change (a link, or `navigate()`) takes effect without a page load - and in an
86
86
  // effect, not during render, so the server render and the hydrating render agree.
87
- const search = useLocationSearch();
87
+ const { search } = useLocation();
88
88
  useEffect(() => {
89
89
  setRequestedMailboxUid(new URLSearchParams(search).get("mailboxUid"));
90
90
  }, [search]);
@@ -10,7 +10,7 @@ import { Folder, Mailbox, listFolders, listMailboxes } from "@rapidmx/react-shar
10
10
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
11
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
12
12
  import AppShell, { AppShellProps } from "../../layout/AppShell.js";
13
- import { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
13
+ import { useLocation, useNavigate } from "../../../navigation/index.js";
14
14
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
15
15
  import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
16
16
 
@@ -60,7 +60,7 @@ export default function ContactsShell({
60
60
 
61
61
  // Read from the router's location, so a mailbox change (a link, or `navigate()`) takes effect without a page load - and in an
62
62
  // effect, not during render, so the server render and the hydrating render agree.
63
- const search = useLocationSearch();
63
+ const { search } = useLocation();
64
64
  const navigate = useNavigate();
65
65
  useEffect(() => {
66
66
  setRequestedMailboxUid(new URLSearchParams(search).get("mailboxUid"));
@@ -3,7 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import "../../styles/app.css";
6
- import React, { PropsWithChildren, useEffect, useRef, useState } from "react";
6
+ import React, { PropsWithChildren, useEffect, useLayoutEffect, useRef, useState } from "react";
7
7
  import type { IconType } from "react-icons";
8
8
  import {
9
9
  HiOutlineCalendarDays,
@@ -12,6 +12,7 @@ import {
12
12
  HiOutlinePuzzlePiece,
13
13
  HiOutlineUsers,
14
14
  } from "react-icons/hi2";
15
+ import { useRouter } from "@rapidrest/react/client";
15
16
  import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
16
17
  import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
17
18
  import { stopImpersonating } from "@rapidmx/react-shared/mail/mailApi.js";
@@ -34,7 +35,7 @@ import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
34
35
  import { mergePluginNavItems, PluginNav, PluginNavProps } from "../../plugins/pluginNav.js";
35
36
  import { useInAppFrame } from "../../navigation/frameContext.js";
36
37
  import { APP_HREFS } from "../../navigation/appHrefs.js";
37
- import { useNavigate } from "../../navigation/routerContext.js";
38
+ import { useNavigate } from "../../navigation/index.js";
38
39
  import { GlobalShortcuts } from "../../keyboard/GlobalShortcuts.js";
39
40
  import { ShortcutProvider } from "../../keyboard/ShortcutProvider.js";
40
41
  import ShortcutsDialog from "../../keyboard/ShortcutsDialog.js";
@@ -138,12 +139,9 @@ export function appRailItems(pluginNav?: PluginNav): NavItem[] {
138
139
  );
139
140
  }
140
141
 
141
- /** What only the persistent frame (`AppRouter`) passes to the chrome it keeps mounted. */
142
+ /** What only the persistent frame (`apps/www/_shell.tsx`) passes to the chrome it keeps mounted. */
142
143
  export interface AppChromeProps extends AppShellProps {
143
- /** Changes whenever the router shows a different page: the chrome then moves focus to the content, scrolls to its top,
144
- * sets the document title and announces the page. Absent outside the router, when nothing does. */
145
- routeKey?: string;
146
- /** The router is loading the next page's code - the content is `aria-busy`. */
144
+ /** The router is loading the next page - the content is `aria-busy`. */
147
145
  busy?: boolean;
148
146
  /** A screen that takes over the window (`FrameTakeover`) is showing: the rail, header, banner and footer are hidden - not
149
147
  * unmounted, so what is inside them (and the page in the content region) keeps its state. */
@@ -156,8 +154,8 @@ export interface AppChromeProps extends AppShellProps {
156
154
  * Each app's own shell (e.g. `MailShell`) renders its own contextual sidebar + content as `children`, inside
157
155
  * the area to the right of the icon rail and below the header.
158
156
  *
159
- * Rendered by `AppRouter`, once, for the life of the page, so that moving between apps replaces only `children` - see
160
- * `AppShell` below for what a page's own shell renders instead.
157
+ * Rendered by the webmail's app shell (`apps/www/_shell.tsx`, the router's persistent client layout), once, for the life of the
158
+ * page, so that moving between apps replaces only `children` - see `AppShell` below for what a page's own shell renders instead.
161
159
  */
162
160
  export function AppChrome({
163
161
  active,
@@ -170,7 +168,6 @@ export function AppChrome({
170
168
  branding: initialBranding,
171
169
  appearance,
172
170
  pluginNav,
173
- routeKey,
174
171
  busy,
175
172
  hideChrome,
176
173
  children,
@@ -198,6 +195,7 @@ export function AppChrome({
198
195
  // the router (`AppShell` rendered as a page's own chrome: tests, plugin pages) it stays off, and a Mail shell owns its connection itself.
199
196
  const inFrame = useInAppFrame();
200
197
  const navigate = useNavigate();
198
+ const { pathname } = useRouter();
201
199
  const mail = useMailConnection({ userUid, enabled: inFrame, open: navigate });
202
200
  // A signing certificate the user asked for is announced when it is issued (or fails), on whichever page they are - see the hook.
203
201
  useSigningEnrollmentWatcher({ userUid, mailboxes: mail.mailboxes, enabled: inFrame });
@@ -301,32 +299,21 @@ export function AppChrome({
301
299
  }
302
300
  }
303
301
 
304
- // What the persistent frame (`AppRouter`) needs on top of a page that never changes: the document title follows the page, and
305
- // after the router replaces the page the keyboard focus, the scroll position and a screen reader are told about it - a
306
- // page load did all of that by itself. Nothing here runs for a page shown outside the router (`routeKey` is undefined).
307
- const contentRef = useRef<HTMLDivElement>(null);
308
- const [announcement, setAnnouncement] = useState("");
309
- const firstRouteKeyRef = useRef(routeKey);
310
- const pageLabel = active === "settings" ? "Settings" : appRailItems(pluginNav).find((app) => app.id === active)?.label;
302
+ // The tab's title in the frame is each page's own (its `title` export: rendered by the server, set again by the router on every navigation),
303
+ // but `useBranding()` above sets it to the branding's title once its fetch answers - a title for a page that has none. The page's is put back:
304
+ // the layout effect reads it before that hook's effect runs, this one (declared after it) writes it back.
305
+ const pageTitleRef = useRef("");
306
+ useLayoutEffect(() => {
307
+ pageTitleRef.current = document.title;
308
+ }, [fetchedBranding?.title]);
311
309
  useEffect(() => {
312
- if (routeKey !== undefined) {
313
- const brand = branding?.title || branding?.companyName || "RapidMX";
314
- document.title = pageLabel ? `${brand}: ${pageLabel}` : brand;
310
+ if (inFrame) {
311
+ document.title = pageTitleRef.current;
315
312
  }
316
- }, [routeKey, pageLabel, branding]);
317
- // After the title effect above, so a page change (which rewrites the title) is followed by the unread count going back in front of it.
318
- useUnreadTitle(inboxUnreadTotal(mail.mailboxFolders, mail.folderCounts.counts), {
319
- enabled: inFrame,
320
- resetKey: `${routeKey}|${pageLabel}|${branding?.title}|${branding?.companyName}`,
321
- });
322
- useEffect(() => {
323
- if (routeKey === undefined || routeKey === firstRouteKeyRef.current) {
324
- return;
325
- }
326
- setAnnouncement(pageLabel ?? "");
327
- contentRef.current?.focus({ preventScroll: true });
328
- window.scrollTo(0, 0);
329
- }, [routeKey]);
313
+ }, [fetchedBranding?.title, inFrame]);
314
+ // The router sets the document's title to the page's own whenever the page changes, which drops the unread count this puts in front
315
+ // of it, so a new pathname is what puts the count back. (A folder change is shallow and keeps the title.)
316
+ useUnreadTitle(inboxUnreadTotal(mail.mailboxFolders, mail.folderCounts.counts), { enabled: inFrame, resetKey: pathname });
330
317
 
331
318
  if (!userUid) {
332
319
  return <div className="min-h-screen" />;
@@ -442,7 +429,6 @@ export function AppChrome({
442
429
  {/* The one pop-up stack for the whole app: right under the header row, so it never covers the account menu. */}
443
430
  <NotificationCenter />
444
431
  <div
445
- ref={contentRef}
446
432
  id="app-content"
447
433
  tabIndex={-1}
448
434
  aria-busy={busy || undefined}
@@ -452,11 +438,6 @@ export function AppChrome({
452
438
  </div>
453
439
  </div>
454
440
  </div>
455
- {routeKey !== undefined && (
456
- <div role="status" aria-live="polite" className="sr-only">
457
- {announcement}
458
- </div>
459
- )}
460
441
  {!hideChrome && <FrameBrandingFooter parsed={footer} userMenu={menuInFooter ? renderUserMenu("up") : undefined} appTitle={activeLabel} />}
461
442
  </div>
462
443
  </ComposeProvider>
@@ -470,9 +451,9 @@ export function AppChrome({
470
451
 
471
452
  /**
472
453
  * What a page's own shell (`MailShell`, `CalendarShell`, ...) renders around its content. Outside the client-side router it
473
- * is the whole `AppChrome`, as it always was. Inside it (`AppRouter`) the chrome is already mounted above the page - and stays
474
- * mounted as the page is replaced - so this is only its children; everything it would have been given comes from the router
475
- * frame instead (the props are the same for every page, and `active` is the route's).
454
+ * is the whole `AppChrome`, as it always was. Inside it (the router's app shell, `apps/www/_shell.tsx`) the chrome is already mounted above the
455
+ * page - and stays mounted as the page is replaced - so this is only its children; everything it would have been given comes from
456
+ * the shell instead (the props are the same for every page, and `active` is the route's).
476
457
  */
477
458
  export default function AppShell(props: PropsWithChildren<AppShellProps>) {
478
459
  const inFrame = useInAppFrame();
@@ -577,6 +577,7 @@ export default function ConversationThreadPane({
577
577
  onReceiptHandled={patchMessage}
578
578
  onScheduledSendCanceled={removeMessage}
579
579
  onArchived={removeMessage}
580
+ onChanged={patchMessage}
580
581
  labels={labels}
581
582
  onLabelsChanged={patchMessage}
582
583
  onLabelCreated={onLabelCreated}