@rapidmx/web-client 0.5.0 → 0.7.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 (224) hide show
  1. package/README.md +88 -0
  2. package/apps/shared/components/admin/layout/AdminShell.tsx +25 -8
  3. package/apps/shared/components/admin/settings/BrandingForm.tsx +398 -385
  4. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
  5. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +159 -152
  6. package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
  7. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
  8. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
  9. package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
  10. package/apps/shared/components/calendar/layout/CalendarShell.tsx +194 -192
  11. package/apps/shared/components/contacts/layout/ContactsShell.tsx +199 -197
  12. package/apps/shared/components/layout/AppShell.tsx +288 -270
  13. package/apps/shared/components/mail/ConversationList.tsx +225 -33
  14. package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
  15. package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
  16. package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
  17. package/apps/shared/components/mail/MenuButton.tsx +384 -0
  18. package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
  19. package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
  20. package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
  21. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
  22. package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
  23. package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
  24. package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
  25. package/apps/shared/components/mail/compose/recipients.ts +104 -0
  26. package/apps/shared/components/mail/labelMenu.tsx +278 -0
  27. package/apps/shared/components/mail/layout/MailShell.tsx +31 -4
  28. package/apps/shared/components/mail/listPreferences.ts +228 -0
  29. package/apps/shared/components/settings/layout/SettingsShell.tsx +23 -10
  30. package/apps/shared/components/tasks/layout/TasksShell.tsx +201 -199
  31. package/apps/shared/plugins/pluginNav.ts +73 -0
  32. package/apps/shared/search/localIndexRpcClient.ts +3 -1
  33. package/apps/shared/styles/app.css +28 -5
  34. package/apps/www/index.tsx +1854 -1314
  35. package/apps/www/messages/[uid].tsx +5 -4
  36. package/dist/apps/admin/_404.d.ts +2 -0
  37. package/dist/apps/admin/_500.d.ts +2 -0
  38. package/dist/apps/admin/_layout.d.ts +8 -0
  39. package/dist/apps/admin/audit-log/index.d.ts +11 -0
  40. package/dist/apps/admin/branding/index.d.ts +3 -0
  41. package/dist/apps/admin/data-requests/index.d.ts +3 -0
  42. package/dist/apps/admin/distribution-lists/[uid].d.ts +7 -0
  43. package/dist/apps/admin/distribution-lists/index.d.ts +3 -0
  44. package/dist/apps/admin/distribution-lists/new/index.d.ts +3 -0
  45. package/dist/apps/admin/domains/[uid].d.ts +7 -0
  46. package/dist/apps/admin/domains/index.d.ts +3 -0
  47. package/dist/apps/admin/domains/new/index.d.ts +3 -0
  48. package/dist/apps/admin/encryption-policy/index.d.ts +3 -0
  49. package/dist/apps/admin/escrow-scopes/[uid].d.ts +21 -0
  50. package/dist/apps/admin/escrow-scopes/index.d.ts +3 -0
  51. package/dist/apps/admin/escrow-scopes/new/index.d.ts +3 -0
  52. package/dist/apps/admin/index.d.ts +3 -0
  53. package/dist/apps/admin/ingest-queue/index.d.ts +4 -0
  54. package/dist/apps/admin/mailbox-policy/index.d.ts +3 -0
  55. package/dist/apps/admin/mailboxes/[uid].d.ts +7 -0
  56. package/dist/apps/admin/mailboxes/new/index.d.ts +3 -0
  57. package/dist/apps/admin/plugins/index.d.ts +3 -0
  58. package/dist/apps/admin/quarantine/index.d.ts +6 -0
  59. package/dist/apps/admin/retention-policy/index.d.ts +3 -0
  60. package/dist/apps/admin/setup/index.d.ts +3 -0
  61. package/dist/apps/admin/transport-rules/[uid].d.ts +7 -0
  62. package/dist/apps/admin/transport-rules/_transportRuleConfig.d.ts +21 -0
  63. package/dist/apps/admin/transport-rules/index.d.ts +3 -0
  64. package/dist/apps/admin/transport-rules/new/index.d.ts +3 -0
  65. package/dist/apps/escrow/_layout.d.ts +10 -0
  66. package/dist/apps/escrow/audit-log/index.d.ts +9 -0
  67. package/dist/apps/escrow/index.d.ts +3 -0
  68. package/dist/apps/escrow/matters/[uid].d.ts +7 -0
  69. package/dist/apps/escrow/matters/new/index.d.ts +3 -0
  70. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.d.ts +17 -0
  71. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +32 -0
  72. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +32 -0
  73. package/dist/apps/shared/components/admin/layout/AdminShell.js +12 -5
  74. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.d.ts +12 -0
  75. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.d.ts +6 -0
  76. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.d.ts +19 -0
  77. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +12 -0
  78. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +13 -0
  79. package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
  80. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +12 -0
  81. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +15 -0
  82. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
  83. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.d.ts +9 -0
  84. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.d.ts +19 -0
  85. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +13 -0
  86. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +2 -2
  87. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +11 -0
  88. package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
  89. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +12 -0
  90. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
  91. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.d.ts +14 -0
  92. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
  93. package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +36 -0
  94. package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
  95. package/dist/apps/shared/components/admin/signOut.d.ts +23 -0
  96. package/dist/apps/shared/components/admin/usePagedList.d.ts +39 -0
  97. package/dist/apps/shared/components/calendar/CalendarListSidebar.d.ts +26 -0
  98. package/dist/apps/shared/components/calendar/EventModal.d.ts +46 -0
  99. package/dist/apps/shared/components/calendar/MonthView.d.ts +15 -0
  100. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +20 -0
  101. package/dist/apps/shared/components/calendar/ResourcePicker.d.ts +19 -0
  102. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +28 -0
  103. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +17 -0
  104. package/dist/apps/shared/components/calendar/allDay.d.ts +51 -0
  105. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +40 -0
  106. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +2 -2
  107. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +23 -0
  108. package/dist/apps/shared/components/contacts/ContactForm.d.ts +20 -0
  109. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +40 -0
  110. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +25 -0
  111. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +40 -0
  112. package/dist/apps/shared/components/contacts/contactKeys.d.ts +29 -0
  113. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +21 -0
  114. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +2 -2
  115. package/dist/apps/shared/components/escrow/layout/EscrowShell.d.ts +25 -0
  116. package/dist/apps/shared/components/forms/StringListField.d.ts +22 -0
  117. package/dist/apps/shared/components/layout/AppShell.d.ts +49 -0
  118. package/dist/apps/shared/components/layout/AppShell.js +15 -13
  119. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +18 -0
  120. package/dist/apps/shared/components/layout/KeyEnrollmentGate.d.ts +51 -0
  121. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +13 -0
  122. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +66 -0
  123. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +45 -0
  124. package/dist/apps/shared/components/layout/UserMenu.d.ts +23 -0
  125. package/dist/apps/shared/components/mail/ConversationList.d.ts +47 -0
  126. package/dist/apps/shared/components/mail/ConversationList.js +84 -10
  127. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +62 -0
  128. package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
  129. package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
  130. package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
  131. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
  132. package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
  133. package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
  134. package/dist/apps/shared/components/mail/MenuButton.js +211 -0
  135. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +101 -0
  136. package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
  137. package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
  138. package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
  139. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +73 -0
  140. package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
  141. package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +15 -0
  142. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +37 -0
  143. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
  144. package/dist/apps/shared/components/mail/compose/EmojiPicker.d.ts +14 -0
  145. package/dist/apps/shared/components/mail/compose/GifPicker.d.ts +13 -0
  146. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
  147. package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
  148. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +43 -0
  149. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
  150. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.d.ts +16 -0
  151. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +27 -0
  152. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
  153. package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
  154. package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
  155. package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
  156. package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
  157. package/dist/apps/shared/components/mail/labelMenu.js +134 -0
  158. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +65 -0
  159. package/dist/apps/shared/components/mail/layout/MailShell.js +17 -5
  160. package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
  161. package/dist/apps/shared/components/mail/listPreferences.js +186 -0
  162. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +33 -0
  163. package/dist/apps/shared/components/mail/verificationSeals.d.ts +22 -0
  164. package/dist/apps/shared/components/mail/writableMailboxes.d.ts +15 -0
  165. package/dist/apps/shared/components/rules/RuleBuilder.d.ts +70 -0
  166. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +41 -0
  167. package/dist/apps/shared/components/settings/layout/SettingsShell.js +19 -10
  168. package/dist/apps/shared/components/tasks/TasksSidebar.d.ts +41 -0
  169. package/dist/apps/shared/components/tasks/TasksToolbar.d.ts +14 -0
  170. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +24 -0
  171. package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -2
  172. package/dist/apps/shared/mail/findWellKnownFolderUid.d.ts +5 -0
  173. package/dist/apps/shared/mail/listAllPages.d.ts +17 -0
  174. package/dist/apps/shared/plugins/pluginNav.d.ts +39 -0
  175. package/dist/apps/shared/plugins/pluginNav.js +33 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +13 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.d.ts +36 -0
  178. package/dist/apps/shared/search/localIndexBuilder.d.ts +85 -0
  179. package/dist/apps/shared/search/localIndexKey.d.ts +4 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.d.ts +71 -0
  181. package/dist/apps/shared/search/localIndexRpcClient.js +3 -1
  182. package/dist/apps/shared/search/localIndexSchema.d.ts +111 -0
  183. package/dist/apps/shared/search/localIndexSizePreference.d.ts +27 -0
  184. package/dist/apps/shared/search/localIndexStorage.d.ts +28 -0
  185. package/dist/apps/shared/search/localIndexVFS.d.ts +101 -0
  186. package/dist/apps/shared/search/localIndexWorker.d.ts +145 -0
  187. package/dist/apps/shared/search/searchTier2.d.ts +30 -0
  188. package/dist/apps/shared/styles/app.css +28 -5
  189. package/dist/apps/www/_404.d.ts +2 -0
  190. package/dist/apps/www/_500.d.ts +2 -0
  191. package/dist/apps/www/_layout.d.ts +14 -0
  192. package/dist/apps/www/calendar/index.d.ts +3 -0
  193. package/dist/apps/www/contacts/[uid].d.ts +13 -0
  194. package/dist/apps/www/contacts/index.d.ts +3 -0
  195. package/dist/apps/www/index.d.ts +3 -0
  196. package/dist/apps/www/index.js +506 -112
  197. package/dist/apps/www/messages/[uid].d.ts +11 -0
  198. package/dist/apps/www/messages/[uid].js +2 -3
  199. package/dist/apps/www/settings/auto-reply/index.d.ts +4 -0
  200. package/dist/apps/www/settings/encryption/index.d.ts +7 -0
  201. package/dist/apps/www/settings/filters/[uid].d.ts +8 -0
  202. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.d.ts +11 -0
  203. package/dist/apps/www/settings/filters/index.d.ts +4 -0
  204. package/dist/apps/www/settings/filters/new/index.d.ts +4 -0
  205. package/dist/apps/www/settings/labels/index.d.ts +4 -0
  206. package/dist/apps/www/settings/privacy/index.d.ts +4 -0
  207. package/dist/apps/www/settings/read-receipts/index.d.ts +4 -0
  208. package/dist/apps/www/settings/sharing/index.d.ts +4 -0
  209. package/dist/apps/www/settings/signatures/[uid].d.ts +8 -0
  210. package/dist/apps/www/settings/signatures/index.d.ts +4 -0
  211. package/dist/apps/www/settings/signatures/new/index.d.ts +4 -0
  212. package/dist/apps/www/settings/signatures/signatureDefaults.d.ts +10 -0
  213. package/dist/apps/www/tasks/index.d.ts +3 -0
  214. package/package.json +3 -3
  215. package/apps/shared/components/booking/AvailabilityEditor.tsx +0 -118
  216. package/apps/www/settings/booking-types/[uid].tsx +0 -306
  217. package/apps/www/settings/booking-types/index.tsx +0 -98
  218. package/apps/www/settings/booking-types/new/index.tsx +0 -212
  219. package/apps/www/settings/focused-inbox/index.tsx +0 -150
  220. package/dist/apps/shared/components/booking/AvailabilityEditor.js +0 -45
  221. package/dist/apps/www/settings/booking-types/[uid].js +0 -134
  222. package/dist/apps/www/settings/booking-types/index.js +0 -29
  223. package/dist/apps/www/settings/booking-types/new/index.js +0 -86
  224. package/dist/apps/www/settings/focused-inbox/index.js +0 -71
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import "../../styles/app.css";
7
7
  import { useEffect, useRef, useState } from "react";
8
- import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope, HiOutlineUsers } from "react-icons/hi2";
8
+ import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope, HiOutlinePuzzlePiece, HiOutlineUsers, } from "react-icons/hi2";
9
9
  import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
10
10
  import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
11
11
  import { stopImpersonating } from "@rapidmx/react-shared/mail/mailApi.js";
@@ -21,6 +21,7 @@ import { SIGN_OUT_CHANNEL, destroyAllLocalIndexes } from "../../search/localInde
21
21
  import { authApiFetch } from "@rapidmx/react-shared/util/api.js";
22
22
  import { destroyUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
23
23
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
24
+ import { mergePluginNavItems } from "../../plugins/pluginNav.js";
24
25
  /** How long sign-out waits for auth-server's logout before navigating anyway. */
25
26
  export const LOGOUT_TIMEOUT_MS = 3000;
26
27
  /**
@@ -50,15 +51,13 @@ export const APPS = [
50
51
  { id: "contacts", href: "/contacts", label: "Contacts", icon: HiOutlineUsers },
51
52
  { id: "tasks", href: "/tasks", label: "Tasks", icon: HiOutlineClipboardDocumentList },
52
53
  ];
53
- /** The header title for every valid `active` value — a superset of `APPS`' own labels since `"settings"`
54
- * has no rail icon (and so no `AppDef`) but still needs a header title. */
55
- const ACTIVE_LABELS = {
56
- mail: "Mail",
57
- calendar: "Calendar",
58
- contacts: "Contacts",
59
- tasks: "Tasks",
60
- settings: "Settings",
61
- };
54
+ /** Ids plugin `appRail` items can't take besides `APPS`' own - `"settings"` has no rail icon but is still a
55
+ * core `active` value. */
56
+ const RESERVED_APP_IDS = ["settings"];
57
+ /** `APPS` followed by the plugins' `appRail` items (generic icon), core ids winning - see `mergePluginNavItems`. */
58
+ export function appRailItems(pluginNav) {
59
+ return mergePluginNavItems(APPS, pluginNav?.appRail, ({ id, href, label }) => ({ id, href, label, icon: HiOutlinePuzzlePiece }), RESERVED_APP_IDS);
60
+ }
62
61
  /**
63
62
  * The persistent chrome shared by every webmail app (Mail, Calendar, Contacts, Tasks): a left icon rail for
64
63
  * switching apps (full page loads — see `MailShell`'s original doc comment on this framework having no
@@ -66,7 +65,7 @@ const ACTIVE_LABELS = {
66
65
  * Each app's own shell (e.g. `MailShell`) renders its own contextual sidebar + content as `children`, inside
67
66
  * the area to the right of the icon rail and below the header.
68
67
  */
69
- export default function AppShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, children, }) {
68
+ export default function AppShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }) {
70
69
  const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
71
70
  const signingOutRef = useRef(false);
72
71
  const { branding, iconSrc } = useBranding();
@@ -155,10 +154,13 @@ export default function AppShell({ active, userUid, authServerUrl, impersonating
155
154
  if (!userUid) {
156
155
  return _jsx("div", { className: "min-h-screen" });
157
156
  }
158
- return (_jsx(UnlockPromptProvider, { children: _jsx(ComposeProvider, { userUid: userUid, trusted: !!trusted && !impersonating, children: _jsxs("div", { className: "min-h-screen flex flex-col bg-surface-alt", children: [_jsx(BrandingHeader, { branding: branding }), impersonating && (_jsxs("div", { className: "h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4", children: [_jsxs("span", { children: ["You are viewing as ", _jsx("strong", { children: userUid }), "."] }), _jsx("button", { type: "button", onClick: handleStopImpersonating, disabled: stoppingImpersonation, className: "underline hover:no-underline disabled:opacity-60", children: stoppingImpersonation ? "Returning to admin…" : "Return to admin" })] })), _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Apps", className: "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1", children: [_jsx("img", { src: iconSrc, width: "96", height: "96", alt: "", className: "mb-3" }), APPS.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
157
+ const apps = appRailItems(pluginNav);
158
+ // The header title: "settings" has no rail item, everything else is labelled by its own rail item.
159
+ const activeLabel = active === "settings" ? "Settings" : apps.find((app) => app.id === active)?.label;
160
+ return (_jsx(UnlockPromptProvider, { children: _jsx(ComposeProvider, { userUid: userUid, trusted: !!trusted && !impersonating, children: _jsxs("div", { className: "min-h-screen flex flex-col bg-surface-alt", children: [_jsx(BrandingHeader, { branding: branding }), impersonating && (_jsxs("div", { className: "h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4", children: [_jsxs("span", { children: ["You are viewing as ", _jsx("strong", { children: userUid }), "."] }), _jsx("button", { type: "button", onClick: handleStopImpersonating, disabled: stoppingImpersonation, className: "underline hover:no-underline disabled:opacity-60", children: stoppingImpersonation ? "Returning to admin…" : "Return to admin" })] })), _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Apps", className: "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1", children: [_jsx("img", { src: iconSrc, width: "96", height: "96", alt: "", className: "mb-3" }), apps.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
159
161
  "w-10 h-10 flex items-center justify-center rounded-sm",
160
162
  id === active
161
163
  ? "bg-primary/10 text-primary-dark"
162
164
  : "text-text-muted hover:bg-surface-alt hover:text-text",
163
- ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(BottomTabBar, { apps: APPS, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: ACTIVE_LABELS[active] }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut, showAdminLink: trusted, showSettingsLink: true })] }), _jsx("div", { className: "flex-1 flex min-h-0 pb-14 md:pb-0", children: children })] })] }), _jsx(BrandingFooter, { branding: branding })] }) }) }));
165
+ ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(BottomTabBar, { apps: apps, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeLabel }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut, showAdminLink: trusted, showSettingsLink: true })] }), _jsx("div", { className: "flex-1 flex min-h-0 pb-14 md:pb-0", children: children })] })] }), _jsx(BrandingFooter, { branding: branding })] }) }) }));
164
166
  }
@@ -0,0 +1,18 @@
1
+ import React from "react";
2
+ import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
3
+ /**
4
+ * Sanitizes admin-configured branding HTML before it's rendered. The server sanitizes on save too; this also
5
+ * covers anything stored before that. Returns `""` where there's no DOM to sanitize with (server-side
6
+ * rendering), so unsanitized HTML is never emitted.
7
+ */
8
+ export declare function sanitizeBrandingHtml(html: string): string;
9
+ /**
10
+ * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized - shared by
11
+ * the webmail and admin chromes. The escrow console deliberately renders no branding HTML at all.
12
+ */
13
+ export declare function BrandingHeader({ branding }: {
14
+ branding: Branding | null;
15
+ }): React.JSX.Element;
16
+ export declare function BrandingFooter({ branding }: {
17
+ branding: Branding | null;
18
+ }): React.JSX.Element;
@@ -0,0 +1,51 @@
1
+ import React from "react";
2
+ import { PublicKey } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
3
+ export interface KeyEnrollmentGateProps {
4
+ /** Undefined while the caller's mailbox hasn't resolved yet (or has none) - renders `children`
5
+ * unchanged in that case, so this component can be mounted unconditionally at a stable tree
6
+ * position (see this component's own doc comment for why that matters). */
7
+ mailboxUid?: string;
8
+ mailboxAddress?: string;
9
+ /** The mailbox's currently-published public keys (`Mailbox.keys`) - needed to unlock an
10
+ * already-enrolled vault (see `unlockWithPassword()`'s own signature), not just to provision a new
11
+ * one. Treated as empty when undefined - a mailbox with no public keys published yet has nothing to
12
+ * unlock, so this only affects the already-enrolled path. */
13
+ mailboxKeys?: PublicKey[];
14
+ /**
15
+ * `true` (default) preserves this component's original behavior: a mailbox with an existing vault
16
+ * but no unlocked session blocks `children` behind a full-page unlock form. Set to `false` for a
17
+ * mount point that shouldn't block merely because a mailbox resolved - `MailShell` does this, since
18
+ * unlocking is only actually required to sign/encrypt a compose, read an already-encrypted message,
19
+ * or change encryption settings (see `UnlockPromptProvider.tsx`'s `useUnlockPrompt()`, which those
20
+ * specific call sites use instead). Has no effect on first-time provisioning
21
+ * (`setup_password`/`enrolling`/`show_recovery_codes`), which still always blocks regardless - that
22
+ * only ever happens once per mailbox and is a genuine prerequisite, not the source of "unlock keeps
23
+ * popping up" friction this prop exists to avoid.
24
+ */
25
+ blocking?: boolean;
26
+ /**
27
+ * `true` when the caller may provision this mailbox's first encryption key - i.e. they own it and aren't
28
+ * an administrator impersonating them. `false` (default, so a caller that forgets it never provisions) for
29
+ * a shared/delegated mailbox the caller doesn't own, or an impersonated session: a mailbox with no vault yet then
30
+ * renders `children` instead of the "Protect your mailbox" setup, so a delegate never enrolls keys
31
+ * (and recovery codes) for someone else's mailbox. Unlocking an existing vault is unaffected.
32
+ */
33
+ canProvision?: boolean;
34
+ children: React.ReactNode;
35
+ }
36
+ /**
37
+ * Gates a mailbox's normal content behind one-time E2E encryption key setup, per
38
+ * `specs/end-to-end_encryption.md`'s "Generate signing and encryption keypairs on the client's device on
39
+ * first sign-in." Rendered by `MailShell` wrapping its own returned content unconditionally, not only
40
+ * once a mailbox is resolved — mounting this component at a *different* tree position depending on
41
+ * `mailboxUid`'s readiness (e.g. only wrapping once ready, rendering bare content beforehand) would make
42
+ * `children` (real `AppShell` chrome) itself remount the moment `mailboxUid` resolves, tearing down any
43
+ * state/effects it had already started. Always wrapping keeps `AppShell` mounted continuously across
44
+ * that transition; this component simply passes `children` through untouched while `mailboxUid` is
45
+ * still unresolved, and only starts its own check once a real `mailboxUid` is supplied.
46
+ *
47
+ * A `getKeyVault()` failure (network error, server not yet wired up to serve this endpoint) also renders
48
+ * `children` unchanged rather than blocking mail entirely - encryption is optional and gradual by design
49
+ * (the spec's own "near zero frictionless experience" goal), not a hard prerequisite for reading mail.
50
+ */
51
+ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxKeys, blocking, canProvision, children, }: KeyEnrollmentGateProps): React.JSX.Element | null;
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ /**
3
+ * Rendered by each app's shell (Mail/Calendar/Contacts/Tasks) *instead of* the normal `AppShell`
4
+ * chrome (icon rail, header, folder tree, ...) when the caller has no mailbox — a full-screen,
5
+ * 404-style takeover, not a small message nested inside the rest of the client UI, since there's
6
+ * nothing else in the app for a mailbox-less caller to do yet. Attempts self-service
7
+ * auto-provisioning — see `BaseMailboxRoute.autoProvision()`'s own doc comment in `@rapidmx/restapi`
8
+ * for the full contract — which is itself a no-op (a 404) unless an admin has both turned it on
9
+ * (`mail:auto_provision:enabled`) and configured at least one domain (`mail:domains`). Safe to render
10
+ * unconditionally in that place: any failure (disabled, no registered username, the identity service
11
+ * unreachable) just falls back to the same plain "no mailbox" message this replaces.
12
+ */
13
+ export default function MailboxProvisioning(): React.JSX.Element;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Unlocking with a recovery code, and what has to happen after it. A recovery code is single-use: once it has opened
3
+ * the keys, the user may set a new encryption password (`replacePasswordWrap()`), and then the used code is removed
4
+ * (`consumeRecoveryCode()`). The order matters - react-shared's `replacePasswordWrap()` refuses unless another own
5
+ * unlock method remains, and the not-yet-consumed code is that method.
6
+ *
7
+ * `UnlockPromptProvider` and `KeyEnrollmentGate` both unlock with `startRecoveryUnlock()`, settle whatever was waiting
8
+ * on the unlock straight away, and then show `RecoveryFollowUpModal` for the follow-up steps.
9
+ */
10
+ import React from "react";
11
+ import { type PublicKey } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
12
+ /** The same minimum every other "set an encryption password" form in this app uses. */
13
+ export declare const MIN_PASSWORD_LENGTH = 8;
14
+ /** Where recovery codes are regenerated. */
15
+ export declare const ENCRYPTION_SETTINGS_HREF = "/settings/encryption";
16
+ /** Deliberately generic: a wrong code and a vault with no recovery codes fail the same way. */
17
+ export declare const RECOVERY_CODE_ERROR = "That recovery code didn't work.";
18
+ export declare const REPLACE_ROTATED_MESSAGE = "Your encryption keys were changed on another device after you unlocked, so your password wasn't changed and your recovery code wasn't used. Reload the page and unlock again.";
19
+ export declare const REPLACE_MULTIPLE_PASSWORDS_MESSAGE = "This mailbox has more than one encryption password, so it can't be replaced here. Nothing was changed. Remove the extra password in Settings > Encryption first.";
20
+ export declare const REPLACE_NO_OTHER_METHOD_MESSAGE = "Your password wasn't changed, because this mailbox would be left with no other way to unlock. Your recovery code may already have been used in another window. Nothing was changed.";
21
+ export declare const REPLACE_ADD_FAILED_RESTORED_MESSAGE = "Your new password couldn't be saved, so your old password still works. Nothing was changed. Try again.";
22
+ export declare const REPLACE_ADD_FAILED_NOT_RESTORED_MESSAGE = "Your new password couldn't be saved, and your old password couldn't be put back, so this mailbox has no password right now. Your recovery code still works. Try again, or keep the code for now.";
23
+ export declare const REPLACE_LOCKED_MESSAGE = "Your encryption keys were locked before your new password could be set, so nothing was changed and your recovery code still works. Unlock with it again to set a new password.";
24
+ export declare const REPLACE_GENERIC_MESSAGE = "Your new password couldn't be saved. Nothing was changed. Try again.";
25
+ export declare const CONSUME_FAILED_MESSAGE = "The recovery code you used couldn't be removed, so it may still work. Regenerate your recovery codes in Settings > Encryption to make sure it can't be used again.";
26
+ /** What a successful recovery-code unlock hands to `RecoveryFollowUpModal`. */
27
+ export interface RecoveryFollowUp {
28
+ mailboxUid: string;
29
+ recoveryMethodId?: string;
30
+ /** The codes left once the used one is consumed. */
31
+ remainingRecoveryCodes: number;
32
+ /** The vault's generation read *before* the unlock - see `startRecoveryUnlock()`. */
33
+ expectedMasterKeyGeneration?: number;
34
+ unopenableKeys: string[];
35
+ }
36
+ /**
37
+ * Unlocks `mailboxUid` with a recovery code (normalized by react-shared, so spacing and case don't matter). The
38
+ * vault's `masterKeyGeneration` is read first: if another device rotates the master key at any point after that read,
39
+ * the generation passed to `replacePasswordWrap()` later is stale and the replacement is refused, never written with
40
+ * a dead master key. A failed read just means no generation check. Rejects exactly as `unlockWithRecoveryCode()` does.
41
+ */
42
+ export declare function startRecoveryUnlock(mailboxUid: string, mailboxKeys: PublicKey[], code: string): Promise<RecoveryFollowUp>;
43
+ /** A "use a recovery code instead" / "use your password instead" switch for an unlock form. */
44
+ export declare function UnlockModeToggle({ mode, disabled, onChange, }: {
45
+ mode: "password" | "recovery";
46
+ disabled: boolean;
47
+ onChange: (mode: "password" | "recovery") => void;
48
+ }): React.JSX.Element;
49
+ export interface RecoveryFollowUpModalProps {
50
+ followUp: RecoveryFollowUp;
51
+ /** `false` hides the dialog without losing its progress (e.g. while another unlock dialog is on top). */
52
+ open?: boolean;
53
+ onDone: () => void;
54
+ }
55
+ /**
56
+ * The steps after a recovery-code unlock, shown once the keys are already open:
57
+ *
58
+ * 1. Offer a new encryption password. Optional ("Skip") - unless this was the last code, where it's required before the
59
+ * code is used up (otherwise only a forgotten password would remain); then "Keep this code for now" closes without
60
+ * consuming anything.
61
+ * 2. Remove the used code. A failure is reported but never blocks anything - the keys are already open.
62
+ * 3. Say how many codes are left, nudging towards regenerating them at two or fewer.
63
+ *
64
+ * Mount it with a `key` per follow-up so each unlock starts from step 1.
65
+ */
66
+ export declare function RecoveryFollowUpModal({ followUp, open, onDone }: RecoveryFollowUpModalProps): React.JSX.Element;
@@ -0,0 +1,45 @@
1
+ import React from "react";
2
+ import { UnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
3
+ import type { PublicKey } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
4
+ /** Shown instead of "Incorrect password" when the password was right but the mailbox's encryption key itself
5
+ * won't open (`UnopenableEncryptionKeyError`) - retrying can't fix that, so it must not read as a typo. */
6
+ export declare const UNOPENABLE_ENCRYPTION_KEY_MESSAGE = "Your password is correct, but one of your keys couldn't be opened, so encrypted mail can't be read or sent from this mailbox. Trying again won't help - contact support.";
7
+ /** `UNOPENABLE_ENCRYPTION_KEY_MESSAGE`'s counterpart for a recovery-code unlock. */
8
+ export declare const UNOPENABLE_ENCRYPTION_KEY_RECOVERY_MESSAGE = "Your recovery code is correct, but one of your keys couldn't be opened, so encrypted mail can't be read or sent from this mailbox. Trying again won't help - contact support.";
9
+ /** The error text for a failed `unlockWithPassword()` (or, with `method` `"recovery"`, `unlockWithRecoveryCode()`). */
10
+ export declare function unlockErrorMessage(err: unknown, method?: "password" | "recovery"): string;
11
+ /**
12
+ * A non-blocking notice after an unlock that succeeded without some signing keys (`UnlockResult.unopenableKeys`):
13
+ * mail can still be read, but not signed with those keys on this device.
14
+ */
15
+ export declare function UnopenableKeysNotice({ fingerprints, onDismiss }: {
16
+ fingerprints: string[];
17
+ onDismiss: () => void;
18
+ }): React.JSX.Element;
19
+ interface UnlockPromptContextValue {
20
+ /**
21
+ * Resolves immediately with this mailbox's already-unlocked keys if it has any this session -
22
+ * showing no UI at all. Otherwise shows an unlock dialog on top of whatever the user is currently
23
+ * doing and resolves once they enter the correct password, or rejects if they dismiss it.
24
+ */
25
+ requestUnlock(mailboxUid: string, mailboxKeys: PublicKey[]): Promise<UnlockedKeys>;
26
+ }
27
+ /**
28
+ * The on-demand counterpart to `KeyEnrollmentGate`'s blocking, first-sign-in-only provisioning flow.
29
+ * Per `specs/end-to-end_encryption.md`, unlocking is only actually *required* to (1) sign or encrypt an
30
+ * outgoing message, (2) read an already-encrypted message, or (3) change encryption settings that touch
31
+ * the master key - not merely to have a mailbox with an existing vault resolve. Mounted once in
32
+ * `AppShell.tsx` (shared by every top-level app shell: Mail, Calendar, Contacts, Tasks, Settings), so
33
+ * any of those three call sites (`ComposeWindow`'s sign/encrypt toggles, `MessageDetailPane`'s encrypted-
34
+ * message view, `Settings > Encryption`) can request an unlock right where it's needed via
35
+ * `useUnlockPrompt()`, instead of the whole app being blocked behind an unlock screen the moment a
36
+ * mailbox happens to resolve - see `KeyEnrollmentGate`'s `blocking` prop, which `MailShell` now sets to
37
+ * `false` for exactly this reason.
38
+ */
39
+ export declare function UnlockPromptProvider({ children }: {
40
+ children: React.ReactNode;
41
+ }): React.JSX.Element;
42
+ /** Throws outside an `<UnlockPromptProvider>` - every app shell mounts one (see `AppShell.tsx`), so
43
+ * reaching this from any real page is a wiring bug, not a runtime condition to degrade gracefully from. */
44
+ export declare function useUnlockPrompt(): UnlockPromptContextValue;
45
+ export {};
@@ -0,0 +1,23 @@
1
+ import React from "react";
2
+ export interface UserMenuProps {
3
+ userUid: string;
4
+ /** auth-server's base URL — where the caller's own profile (name/avatar) is fetched from, if configured. */
5
+ authServerUrl?: string;
6
+ onSignOut: () => void;
7
+ /** Shows an "Admin" item linking to `/admin`, above "Sign Out" — pass only for a trusted-role caller. */
8
+ showAdminLink?: boolean;
9
+ /** Shows a "Settings" item, above "Admin"/"Sign Out" — an account-scoped surface (like "Admin"), not
10
+ * a content-scoped one, so it lives here rather than as a 5th `AppShell` rail icon. Links straight to
11
+ * `/settings/auto-reply`, the only settings section that exists today; repoint this at a real
12
+ * `/settings` landing page once a second section (Mail Filters, Signatures) makes one worth building. */
13
+ showSettingsLink?: boolean;
14
+ }
15
+ /**
16
+ * The top-right "who am I" menu shared by `MailShell` and `AdminShell`: an avatar-button trigger that opens a
17
+ * dropdown showing the caller's avatar/name, an optional "Admin" link (trusted-role callers only), and
18
+ * "Sign Out". Name/avatar come from auth-server's own profile endpoint (`GET /api/profiles/me`) — this
19
+ * service has no local user directory (see `.claude/NOTES.md`) — and fall back to the bare uid/its first
20
+ * letter when unset or unreachable, which is a fully valid, expected state for most password-registered
21
+ * accounts (only OIDC sign-in currently populates a real avatar).
22
+ */
23
+ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, showSettingsLink }: UserMenuProps): React.JSX.Element;
@@ -0,0 +1,47 @@
1
+ import React from "react";
2
+ import { Message } from "@rapidmx/react-shared/mail/mailApi.js";
3
+ import { ConversationSummary } from "@rapidmx/react-shared/mail/conversationsApi.js";
4
+ export interface ConversationListProps {
5
+ conversations: ConversationSummary[];
6
+ /** The mailbox the conversations were listed from - `listConversationMessages()` is mailbox-scoped. */
7
+ mailboxUid: string;
8
+ /** The message currently open in the reading pane, so the row standing for it can be marked. */
9
+ selectedUid: string | null;
10
+ /**
11
+ * Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
12
+ * stands for, or the conversation's own `latestMessageUid` for a parent row. The conversation goes
13
+ * with the uid because the pane shows the whole thread, not just that message.
14
+ */
15
+ onOpenMessage: (conversation: ConversationSummary, uid: string) => void;
16
+ /** Newer copies of messages this list already fetched - the reading pane marks the message it opens as
17
+ * read, which this list would otherwise keep showing as unread until the thread is collapsed and
18
+ * expanded again. */
19
+ messageOverrides?: Record<string, Message>;
20
+ /** Turns each parent row into a checkbox row: select mode here ticks whole conversations, since a
21
+ * conversation is what this list's rows are. A child row stays a plain "open this message" button - a
22
+ * mixed conversation/message selection has no sensible bulk semantics (see `MailListToolbar`). */
23
+ selectMode?: boolean;
24
+ /** The `conversationId`s currently ticked. */
25
+ selectedConversationIds?: Set<string>;
26
+ onToggleSelected?: (conversation: ConversationSummary) => void;
27
+ /**
28
+ * Whether a conversation's own messages read newest first, matching the order sense the rows themselves
29
+ * are in ("Newest on top"). `listConversationMessages()` always answers oldest first - the order a
30
+ * thread is read in - so this reverses that copy for display rather than asking for it differently.
31
+ */
32
+ newestFirst?: boolean;
33
+ }
34
+ /**
35
+ * The nested, Outlook-style conversation list: one parent row per conversation - its participants, subject,
36
+ * message count, unread count, flag/attachment hints and the latest message's preview - expanding via its
37
+ * own chevron into the conversation's individual messages as child rows, fetched on first expand with
38
+ * `listConversationMessages()` (one request for the whole thread, across folders, oldest first).
39
+ *
40
+ * Opening a row opens the whole conversation in `ConversationThreadPane`, positioned at the message the row
41
+ * stands for: the latest one for a parent row, that one for a child row.
42
+ *
43
+ * The chevron is a button of its own beside the row's own button rather than inside it (a button can't
44
+ * nest inside a button), so expanding and opening are separately reachable by keyboard and each carries
45
+ * its own name.
46
+ */
47
+ export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, selectMode, selectedConversationIds, onToggleSelected, newestFirst, }: ConversationListProps): React.JSX.Element;
@@ -1,17 +1,91 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- /** The "By conversation" counterpart to the per-message list `apps/www/index.tsx` renders inline for
3
- * "By date" — conversations are mailbox-wide (see `conversationsApi.ts`), so there's no per-folder
4
- * empty/loading state to distinguish here beyond what the page itself already handles. */
5
- export default function ConversationList({ conversations, selectedId, onSelect }) {
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useState } from "react";
7
+ import { HiChevronDown, HiChevronRight, HiOutlineFlag, HiOutlinePaperClip } from "react-icons/hi2";
8
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
+ import { listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
10
+ function participantNames(conversation) {
11
+ return conversation.participants.map((participant) => participant.displayName || participant.address).join(", ");
12
+ }
13
+ /**
14
+ * The nested, Outlook-style conversation list: one parent row per conversation - its participants, subject,
15
+ * message count, unread count, flag/attachment hints and the latest message's preview - expanding via its
16
+ * own chevron into the conversation's individual messages as child rows, fetched on first expand with
17
+ * `listConversationMessages()` (one request for the whole thread, across folders, oldest first).
18
+ *
19
+ * Opening a row opens the whole conversation in `ConversationThreadPane`, positioned at the message the row
20
+ * stands for: the latest one for a parent row, that one for a child row.
21
+ *
22
+ * The chevron is a button of its own beside the row's own button rather than inside it (a button can't
23
+ * nest inside a button), so expanding and opening are separately reachable by keyboard and each carries
24
+ * its own name.
25
+ */
26
+ export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, selectMode, selectedConversationIds, onToggleSelected, newestFirst, }) {
27
+ const [expanded, setExpanded] = useState(new Set());
28
+ const [messagesById, setMessagesById] = useState({});
29
+ const [loadingIds, setLoadingIds] = useState(new Set());
30
+ const [errorsById, setErrorsById] = useState({});
31
+ function toggle(conversation) {
32
+ const id = conversation.conversationId;
33
+ setExpanded((prev) => {
34
+ const next = new Set(prev);
35
+ if (next.has(id)) {
36
+ next.delete(id);
37
+ }
38
+ else {
39
+ next.add(id);
40
+ }
41
+ return next;
42
+ });
43
+ if (expanded.has(id) || messagesById[id] || loadingIds.has(id)) {
44
+ return;
45
+ }
46
+ setLoadingIds((prev) => new Set(prev).add(id));
47
+ setErrorsById((prev) => {
48
+ const next = { ...prev };
49
+ delete next[id];
50
+ return next;
51
+ });
52
+ listConversationMessages(mailboxUid, id)
53
+ .then((loaded) => setMessagesById((prev) => ({ ...prev, [id]: loaded })))
54
+ .catch((err) => setErrorsById((prev) => ({
55
+ ...prev,
56
+ [id]: err instanceof ApiRequestError ? err.message : "Could not load this conversation's messages.",
57
+ })))
58
+ .finally(() => setLoadingIds((prev) => {
59
+ const next = new Set(prev);
60
+ next.delete(id);
61
+ return next;
62
+ }));
63
+ }
6
64
  if (conversations.length === 0) {
7
- return _jsx("p", { className: "p-4 text-sm text-text-muted", children: "No conversations in this mailbox." });
65
+ return _jsx("p", { className: "p-4 text-sm text-text-muted", children: "No conversations in this folder." });
8
66
  }
9
67
  return (_jsx("ul", { children: conversations.map((conversation) => {
68
+ const id = conversation.conversationId;
69
+ const isExpanded = expanded.has(id);
10
70
  const unread = conversation.unreadCount > 0;
11
- return (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => onSelect(conversation), className: [
12
- "w-full text-left px-4 py-3 border-b border-border",
13
- conversation.conversationId === selectedId ? "bg-primary/10" : "hover:bg-surface-alt",
14
- unread ? "font-semibold" : "",
15
- ].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: conversation.participants.map((p) => p.displayName || p.address).join(", ") }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: "text-sm truncate", children: conversation.subject || "(no subject)" }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/10 text-primary-dark font-semibold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && _jsx("span", { "aria-label": "Has attachments", children: "\uD83D\uDCCE" })] })] }) }, conversation.conversationId));
71
+ const loaded = messagesById[id];
72
+ // `listConversationMessages()` answers oldest first; the rows read in whichever sense the
73
+ // list itself is arranged in. Copied before reversing - the fetched array is cached.
74
+ const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
75
+ const panelId = `conversation-messages-${id}`;
76
+ const ticked = selectedConversationIds?.has(id) ?? false;
77
+ return (_jsxs("li", { children: [_jsxs("div", { className: [
78
+ "flex items-stretch border-b border-border",
79
+ conversation.latestMessageUid === selectedUid || ticked
80
+ ? "bg-primary/10"
81
+ : "hover:bg-surface-alt",
82
+ ].join(" "), children: [selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: "shrink-0 px-2 text-text-muted hover:text-text", children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("button", { type: "button", onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["flex-1 min-w-0 text-left pr-4 py-3", unread ? "font-semibold" : ""].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: participantNames(conversation) }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: "text-sm truncate", children: conversation.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/10 text-primary-dark font-semibold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] })] }), _jsxs("ul", { id: panelId, hidden: !isExpanded, children: [errorsById[id] && (_jsx("li", { role: "alert", className: "pl-8 pr-4 py-2 text-xs text-danger border-b border-border", children: errorsById[id] })), loadingIds.has(id) && (_jsx("li", { className: "pl-8 pr-4 py-2 text-xs text-text-muted border-b border-border", children: "Loading messages\u2026" })), children?.map((fetched) => {
83
+ const message = messageOverrides?.[fetched.uid] ?? fetched;
84
+ return (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => onOpenMessage(conversation, message.uid), className: [
85
+ "w-full text-left pl-8 pr-4 py-2 border-b border-border",
86
+ message.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt",
87
+ message.flags.read ? "" : "font-semibold",
88
+ ].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: message.from.displayName || message.from.address }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: message.bodyPreview }), message.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" })), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }) }, message.uid));
89
+ })] })] }, id));
16
90
  }) }));
17
91
  }
@@ -0,0 +1,62 @@
1
+ import React from "react";
2
+ import { Folder, Message } from "@rapidmx/react-shared/mail/mailApi.js";
3
+ import { ConversationSummary } from "@rapidmx/react-shared/mail/conversationsApi.js";
4
+ import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
5
+ /** One request's worth of the thread. The server's own default for `listConversationMessages()`. */
6
+ export declare const THREAD_PAGE_SIZE = 100;
7
+ /**
8
+ * How many of a conversation's messages this pane will load. The server reads at most
9
+ * `CONVERSATION_SCAN_LIMIT` (500) messages when it groups conversations, so a `ConversationSummary` never
10
+ * describes more than this many anyway; the cap is here so a thread that somehow reports more can't turn
11
+ * into an unbounded run of requests. Past it the pane says so and the rest stay readable from the list.
12
+ */
13
+ export declare const THREAD_MESSAGE_LIMIT = 500;
14
+ export interface ConversationThreadPaneProps {
15
+ conversation: ConversationSummary | null;
16
+ /** The mailbox the conversation was listed from - `listConversationMessages()` is mailbox-scoped. */
17
+ mailboxUid: string;
18
+ /**
19
+ * The message the reader opened: the thread is scrolled to it, it takes focus, and it is the *oldest*
20
+ * message left expanded (see `expandedFrom()`). `null`, or a uid this thread doesn't hold, falls back
21
+ * to the newest message - which is also what a parent row means by "open the conversation".
22
+ */
23
+ selectedUid: string | null;
24
+ /** The mailbox's full folder list. A conversation spans folders (an Inbox message and the Sent Items
25
+ * copy of its reply), so each message's own folder type is looked up against its own `folderUid`. */
26
+ folders: Folder[];
27
+ /** The mailbox's labels, passed through to every expanded message's own `MessageDetailPane`. */
28
+ labels?: Label[];
29
+ /**
30
+ * A newer copy of one of the thread's messages - read, flagged, labelled, classified, recalled - for
31
+ * the caller's own list to stay in step with what was done in here.
32
+ *
33
+ * `previous` is the copy this pane held before the change, where it has one: the conversation row this
34
+ * thread came from is a *summary* (a message count, an unread count), so a list showing those rows has
35
+ * no way to tell "this message has just been read" from "this already-read message was relabelled"
36
+ * without it - which is what left a row reporting "2 unread" after both had been read.
37
+ */
38
+ onMessagePatched: (updated: Message, previous?: Message) => void;
39
+ /** A message that left the folder being listed (archived, or a scheduled send sent back to Drafts). */
40
+ onMessageRemoved: (updated: Message) => void;
41
+ onLabelCreated?: (label: Label) => void;
42
+ /** A folder created from a message's own Move to prompt, for the folder sidebar to pick up. */
43
+ onFolderCreated?: (folder: Folder) => void;
44
+ }
45
+ /**
46
+ * The reading pane for the conversation list: the whole thread, **newest at the top**, opened at the message
47
+ * the reader picked. Every message from that one through to the newest is expanded - which in this order is
48
+ * the opened message and the entries above it - and the older ones, below it, are collapsed to a one-line
49
+ * summary (sender, date, preview) that expands on click or Enter. So opening the newest message shows just
50
+ * the top entry expanded, and opening 5 of 10 expands 10 down to 5.
51
+ *
52
+ * Newest first regardless of how the *list* is sorted: the list's own order arranges rows to pick from,
53
+ * while this is one conversation being read, and the entry a conversation row stands for - its latest
54
+ * message - is the one that should be at the top of the pane every time it is opened.
55
+ *
56
+ * Each *expanded* message is a `MessageDetailPane` of its own rather than a reimplementation of it, so the
57
+ * signature and verification badges, the verification-seal and decryption behaviour, the labels chips and
58
+ * menu, the attachments and Reply/Reply All/Forward/Archive all behave exactly as they do in the
59
+ * single-message pane, and each acts on the message it belongs to. A collapsed message mounts none of
60
+ * that - mounting a body iframe per message up front would be wasteful in a long thread.
61
+ */
62
+ export default function ConversationThreadPane({ conversation, mailboxUid, selectedUid, folders, labels, onMessagePatched, onMessageRemoved, onLabelCreated, onFolderCreated, }: ConversationThreadPaneProps): React.JSX.Element;