@rapidmx/web-client 0.15.1 → 0.16.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 (189) hide show
  1. package/README.md +373 -373
  2. package/apps/admin/branding/index.tsx +39 -39
  3. package/apps/admin/data-requests/index.tsx +482 -480
  4. package/apps/admin/domains/[uid].tsx +164 -164
  5. package/apps/admin/escrow-scopes/[uid].tsx +348 -348
  6. package/apps/admin/index.tsx +127 -124
  7. package/apps/admin/mailboxes/[uid].tsx +269 -224
  8. package/apps/admin/mailboxes/new/index.tsx +28 -28
  9. package/apps/admin/plugins/index.tsx +15 -15
  10. package/apps/admin/retention-policy/index.tsx +39 -39
  11. package/apps/admin/signing-certificates/index.tsx +343 -343
  12. package/apps/escrow/audit-log/index.tsx +196 -196
  13. package/apps/escrow/matters/[uid].tsx +621 -621
  14. package/apps/shared/auth/adminAccess.ts +99 -99
  15. package/apps/shared/components/admin/layout/AdminShell.tsx +378 -378
  16. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
  17. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
  18. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
  19. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  20. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  21. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +50 -3
  22. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  23. package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1545
  24. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  25. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  26. package/apps/shared/components/admin/setup/SetupWizard.tsx +444 -444
  27. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  28. package/apps/shared/components/calendar/EventEditor.tsx +5 -10
  29. package/apps/shared/components/calendar/EventFormParts.tsx +16 -47
  30. package/apps/shared/components/calendar/EventModal.tsx +206 -206
  31. package/apps/shared/components/calendar/GuestInput.tsx +100 -0
  32. package/apps/shared/components/calendar/MonthView.tsx +185 -185
  33. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
  34. package/apps/shared/components/calendar/RequestChangeForm.tsx +23 -54
  35. package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
  36. package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
  37. package/apps/shared/components/calendar/allDay.ts +124 -124
  38. package/apps/shared/components/calendar/eventForm.ts +2 -2
  39. package/apps/shared/components/calendar/eventFormat.ts +49 -22
  40. package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
  41. package/apps/shared/components/contacts/ContactForm.tsx +383 -383
  42. package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
  43. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
  44. package/apps/shared/components/contacts/layout/ContactsShell.tsx +217 -216
  45. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  46. package/apps/shared/components/layout/AppShell.tsx +480 -480
  47. package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
  48. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  49. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
  50. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  51. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  52. package/apps/shared/components/layout/UserMenu.tsx +439 -424
  53. package/apps/shared/components/mail/ConversationList.tsx +323 -323
  54. package/apps/shared/components/mail/ConversationThreadPane.tsx +612 -491
  55. package/apps/shared/components/mail/MailSelectionBar.tsx +236 -227
  56. package/apps/shared/components/mail/MessageDetailPane.tsx +1559 -1559
  57. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
  58. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  59. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1677
  60. package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
  61. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  62. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  63. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  64. package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
  65. package/apps/shared/components/mail/layout/MailShell.tsx +137 -113
  66. package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
  67. package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
  68. package/apps/shared/components/mail/verificationSeals.ts +125 -125
  69. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  70. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  71. package/apps/shared/components/settings/layout/SettingsShell.tsx +255 -254
  72. package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
  73. package/apps/shared/components/tasks/layout/TasksShell.tsx +218 -217
  74. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  75. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  76. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  77. package/apps/shared/keyboard/dispatch.ts +124 -124
  78. package/apps/shared/keyboard/format.ts +89 -89
  79. package/apps/shared/keyboard/keymap.ts +114 -114
  80. package/apps/shared/keyboard/registry.ts +65 -65
  81. package/apps/shared/keyboard/targets.ts +79 -79
  82. package/apps/shared/mail/folderCounts.ts +17 -0
  83. package/apps/shared/mail/folderTree.ts +143 -143
  84. package/apps/shared/mail/listAllPages.ts +39 -39
  85. package/apps/shared/mail/newMailNotifications.ts +171 -171
  86. package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
  87. package/apps/shared/mail/outbox/sendJob.ts +19 -1
  88. package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
  89. package/apps/shared/mail/primaryMailbox.ts +71 -0
  90. package/apps/shared/mail/useCollapsedSections.ts +146 -0
  91. package/apps/shared/mail/useMailConnection.ts +205 -203
  92. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  93. package/apps/shared/mail/useMarkMessageRead.ts +47 -47
  94. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  95. package/apps/shared/navigation/AppRouter.tsx +300 -300
  96. package/apps/shared/navigation/routerContext.tsx +83 -83
  97. package/apps/shared/notifications/store.ts +560 -560
  98. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
  99. package/apps/shared/search/crossMailboxSearch.ts +169 -0
  100. package/apps/shared/search/localIndexBuilder.ts +481 -481
  101. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  102. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  103. package/apps/shared/signing/enrollmentView.ts +251 -251
  104. package/apps/shared/signing/useNow.ts +19 -19
  105. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
  106. package/apps/shared/styles/app.css +396 -386
  107. package/apps/www/calendar/index.tsx +578 -571
  108. package/apps/www/contacts/[uid].tsx +109 -109
  109. package/apps/www/contacts/index.tsx +6 -0
  110. package/apps/www/index.tsx +2847 -2479
  111. package/apps/www/messages/[uid].tsx +135 -135
  112. package/apps/www/settings/auto-reply/index.tsx +133 -133
  113. package/apps/www/settings/encryption/index.tsx +1287 -1287
  114. package/apps/www/settings/filters/[uid].tsx +176 -176
  115. package/apps/www/settings/filters/index.tsx +102 -102
  116. package/apps/www/settings/filters/new/index.tsx +140 -140
  117. package/apps/www/settings/labels/index.tsx +204 -204
  118. package/apps/www/settings/privacy/index.tsx +492 -492
  119. package/apps/www/settings/profile/index.tsx +248 -248
  120. package/apps/www/settings/read-receipts/index.tsx +147 -147
  121. package/apps/www/settings/sharing/index.tsx +256 -256
  122. package/apps/www/settings/signatures/[uid].tsx +172 -172
  123. package/apps/www/settings/signatures/index.tsx +88 -88
  124. package/apps/www/settings/signatures/new/index.tsx +135 -135
  125. package/apps/www/tasks/index.tsx +649 -649
  126. package/dist/apps/admin/data-requests/index.js +1 -1
  127. package/dist/apps/admin/index.js +2 -1
  128. package/dist/apps/admin/mailboxes/[uid].js +12 -2
  129. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
  130. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
  131. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
  132. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
  133. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
  134. package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +25 -3
  136. package/dist/apps/shared/components/calendar/EventEditor.js +5 -10
  137. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -2
  138. package/dist/apps/shared/components/calendar/EventFormParts.js +5 -16
  139. package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
  140. package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
  141. package/dist/apps/shared/components/calendar/RequestChangeForm.js +11 -22
  142. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -2
  143. package/dist/apps/shared/components/calendar/eventFormat.d.ts +21 -8
  144. package/dist/apps/shared/components/calendar/eventFormat.js +50 -23
  145. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -2
  146. package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
  147. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  148. package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
  149. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -3
  150. package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
  151. package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
  152. package/dist/apps/shared/components/layout/UserMenu.d.ts +4 -1
  153. package/dist/apps/shared/components/layout/UserMenu.js +6 -5
  154. package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
  155. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -1
  156. package/dist/apps/shared/components/mail/MailSelectionBar.js +3 -3
  157. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -4
  158. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
  159. package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
  160. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
  161. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
  162. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
  163. package/dist/apps/shared/components/mail/layout/MailShell.js +64 -45
  164. package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
  165. package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
  166. package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
  167. package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
  168. package/dist/apps/shared/components/settings/layout/SettingsShell.js +6 -5
  169. package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
  170. package/dist/apps/shared/components/tasks/layout/TasksShell.js +4 -3
  171. package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
  172. package/dist/apps/shared/mail/folderCounts.js +12 -0
  173. package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
  174. package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
  175. package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
  176. package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
  177. package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
  178. package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
  179. package/dist/apps/shared/mail/primaryMailbox.js +61 -0
  180. package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
  181. package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
  182. package/dist/apps/shared/mail/useMailConnection.js +3 -1
  183. package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
  184. package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
  185. package/dist/apps/shared/styles/app.css +396 -386
  186. package/dist/apps/www/calendar/index.js +3 -2
  187. package/dist/apps/www/contacts/index.js +3 -1
  188. package/dist/apps/www/index.js +426 -189
  189. package/package.json +2 -2
@@ -9,6 +9,7 @@ import { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js"
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 FloatingActionButton from "../../layout/FloatingActionButton.js";
12
13
  import { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
13
14
  import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
14
15
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
@@ -17,15 +18,19 @@ import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
17
18
  import { LiveUpdates, NO_LIVE_UPDATES } from "../../../mail/useMailLiveUpdates.js";
18
19
  import { MAILBOX_LIST_LIMIT, MailConnectionContext, useMailConnection } from "../../../mail/useMailConnection.js";
19
20
  import { folderRows } from "../../../mail/folderTree.js";
21
+ import { primaryMailboxUid } from "../../../mail/primaryMailbox.js";
20
22
  import {
21
23
  CountTracker,
22
24
  FolderBadge,
23
25
  FolderCount,
24
26
  badgeFor,
25
- badgeLabel,
26
27
  countOfFolder,
27
28
  inboxUnreadTotal,
29
+ unreadBadgeTotal,
28
30
  } from "../../../mail/folderCounts.js";
31
+ import { ALL_MAILBOXES_SECTION, useSidebarSections } from "../../../mail/useCollapsedSections.js";
32
+ import FolderBadgeChip from "./FolderBadgeChip.js";
33
+ import SidebarSection from "./SidebarSection.js";
29
34
  import { useUnreadTitle } from "../../../mail/useUnreadTitle.js";
30
35
  import { pendingCountFor, usePendingSends } from "../../../mail/outbox/pendingSends.js";
31
36
  import OutboxBadge from "../OutboxBadge.js";
@@ -143,21 +148,6 @@ function aggregateBadge(mailboxFolders: MailboxFolders[], type: AggregateFolderT
143
148
  return badgeFor(type, sum);
144
149
  }
145
150
 
146
- /** A folder's badge: an accent pill with the unread count, or - for the folders that show how many they hold - plain muted text. */
147
- function FolderBadgeChip({ badge }: { badge: FolderBadge }) {
148
- return (
149
- <span
150
- className={[
151
- "text-xs rounded-pill py-0.5 px-1.5",
152
- badge.kind === "unread" ? "font-bold bg-primary/15 text-primary-dark" : "font-medium text-text-muted",
153
- ].join(" ")}
154
- >
155
- <span aria-hidden="true">{badge.value}</span>
156
- <span className="sr-only"> {badgeLabel(badge)}</span>
157
- </span>
158
- );
159
- }
160
-
161
151
  type Status = "checking" | "error" | "ready";
162
152
 
163
153
  export { MAILBOX_LIST_LIMIT };
@@ -200,16 +190,13 @@ function ComposeButton({ mailboxUid }: { mailboxUid?: string }) {
200
190
  function MobileComposeButton({ mailboxUid }: { mailboxUid?: string }) {
201
191
  const { openCompose } = useCompose();
202
192
  return (
203
- <button
204
- type="button"
205
- aria-label="New message"
193
+ <FloatingActionButton
194
+ label="New message"
195
+ icon={HiOutlinePencilSquare}
206
196
  onClick={() => openCompose({ mailboxUid })}
207
197
  onPointerEnter={prefetchComposeWindow}
208
198
  onFocus={prefetchComposeWindow}
209
- className="md:hidden fixed right-4 bottom-[4.5rem] z-30 w-12 h-12 flex items-center justify-center rounded-full bg-primary text-white shadow-lg hover:bg-primary-dark"
210
- >
211
- <HiOutlinePencilSquare size={22} aria-hidden="true" />
212
- </button>
199
+ />
213
200
  );
214
201
  }
215
202
 
@@ -239,6 +226,11 @@ function MailShortcuts({ mailboxUid }: { mailboxUid?: string }) {
239
226
  * behavior change from before this, and a multi-mailbox user sees every mailbox's folders (and the new
240
227
  * aggregate section) simultaneously, matching the shared-mailbox feature's own "display as a separate set
241
228
  * of folders" requirement.
229
+ *
230
+ * An address that names nothing opens "All Mailboxes > Inbox" when there is more than one mailbox (that is when the section exists), and
231
+ * the one mailbox's own Inbox otherwise; a `?mailboxUid=`, `?folderUid=` or `?aggregate=` always wins. Nothing is selected until the address
232
+ * has been read. What needs a single mailbox while an all-mailboxes view is open (Compose, the new-message shortcut, key unlocking) uses the
233
+ * primary mailbox.
242
234
  */
243
235
  export default function MailShell({
244
236
  userUid,
@@ -263,6 +255,8 @@ export default function MailShell({
263
255
  const [requestedFolderUid, setRequestedFolderUid] = useState<string | null>(null);
264
256
  const [requestedAggregateType, setRequestedAggregateType] = useState<string | null>(null);
265
257
  const [drawerOpen, setDrawerOpen] = useState(false);
258
+ // Whether the URL has been read yet (see below): until then the selection is not known, and the sidebar sections have nothing to open for.
259
+ const [selectionRead, setSelectionRead] = useState(false);
266
260
 
267
261
  // The selection lives in the URL, and the router (`AppRouter`) changes the URL without a page load when a folder link is
268
262
  // clicked - so it is read from the router's location, which updates, rather than once from `window.location`. Still read
@@ -273,6 +267,7 @@ export default function MailShell({
273
267
  setRequestedMailboxUid(params.get("mailboxUid"));
274
268
  setRequestedFolderUid(params.get("folderUid"));
275
269
  setRequestedAggregateType(params.get("aggregate"));
270
+ setSelectionRead(true);
276
271
  }, [search]);
277
272
  // Choosing a folder (or mailbox) changes the URL without a page load now, so the drawer that held the choice - which used to go
278
273
  // with the page - is closed here.
@@ -280,14 +275,23 @@ export default function MailShell({
280
275
  setDrawerOpen(false);
281
276
  }, [search]);
282
277
 
283
- const aggregateFolderType: AggregateFolderType | undefined = isAggregateFolderType(requestedAggregateType)
284
- ? requestedAggregateType
285
- : undefined;
278
+ // What the address asks for. Nothing is selected until it has been read (the first render, before the effect above): choosing a
279
+ // default then would list a folder - the most expensive being every mailbox's - only to throw it away for the one the address names.
280
+ const requestedAggregate: AggregateFolderType | undefined =
281
+ selectionRead && isAggregateFolderType(requestedAggregateType) ? requestedAggregateType : undefined;
282
+ const requestedMailbox: string | undefined =
283
+ selectionRead && requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined;
286
284
 
287
- const mailboxUid: string | undefined = aggregateFolderType
288
- ? undefined
289
- : (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
290
- mailboxes[0]?.uid;
285
+ // The mailbox the caller has not chosen: their own, never a shared one that merely sorts first. It is what compose, the new-message
286
+ // shortcut and everything else that needs a single mailbox use while an all-mailboxes view (which has none) is open.
287
+ const defaultMailboxUid = primaryMailboxUid(mailboxes, userUid);
288
+
289
+ // With several mailboxes the address that names nothing opens "All Mailboxes > Inbox", the merged Inbox; with one there is no such
290
+ // section, so it opens that mailbox's own Inbox. A mailbox, a folder or an all-mailboxes view in the address wins over either.
291
+ const aggregateByDefault =
292
+ selectionRead && !requestedAggregate && !requestedMailbox && requestedFolderUid === null && mailboxes.length > 1;
293
+ const aggregateFolderType: AggregateFolderType | undefined = requestedAggregate ?? (aggregateByDefault ? "inbox" : undefined);
294
+ const mailboxUid: string | undefined = aggregateFolderType || !selectionRead ? undefined : (requestedMailbox ?? defaultMailboxUid);
291
295
 
292
296
  const selectedMailboxFolders = mailboxFolders.find((mf) => mf.mailbox.uid === mailboxUid)?.folders ?? [];
293
297
  const folderUid: string | undefined = aggregateFolderType
@@ -296,13 +300,21 @@ export default function MailShell({
296
300
  selectedMailboxFolders.find((f) => f.type === "inbox")?.uid;
297
301
 
298
302
  // The mailbox `KeyEnrollmentGate`/`LocalIndexLifecycle`/`ComposeButton` treat as "the" mailbox when
299
- // there's no single selected one to use (aggregate mode) - the caller's own owned mailbox if they
300
- // have one, else whichever accessible mailbox happens to be first. See MailShell's own doc comment on
303
+ // there's no single selected one to use (aggregate mode) - the caller's primary mailbox (`primaryMailbox()`). See MailShell's own doc comment on
301
304
  // this being an accepted limitation: an aggregate-view message from a *different*, not-yet-visited
302
305
  // mailbox may still need that mailbox's own folder view opened directly to unlock/decrypt it.
303
- const defaultMailboxUid = mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
304
306
  const activeMailboxUid = mailboxUid ?? defaultMailboxUid;
305
307
 
308
+ // Which sidebar sections are open: "All mailboxes" and the primary mailbox unless the reader chose otherwise. Any of them can be collapsed;
309
+ // a change of the open folder into another section opens that one (see `useSidebarSections()`); the first selection, once the address has been read
310
+ // and the mailboxes are here, is where a load lands, not a navigation. A lone mailbox has nothing to collapse, so its section is never a toggle.
311
+ const sections = useSidebarSections({
312
+ userUid,
313
+ primaryUid: defaultMailboxUid,
314
+ activeId: aggregateFolderType ? ALL_MAILBOXES_SECTION : mailboxUid,
315
+ });
316
+ const collapsible = mailboxes.length > 1;
317
+
306
318
  const counts = folderCounts.counts;
307
319
  const trackMessageChange = folderCounts.track;
308
320
  // `(3) Acme: Mail` in the tab strip while there is unread mail in an Inbox - kept by the frame when there is one.
@@ -345,7 +357,8 @@ export default function MailShell({
345
357
  // A function, not a plain JSX constant — it's rendered twice (desktop `<aside>` + mobile
346
358
  // `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
347
359
  // unmounted).
348
- const sidebarContent = () => (
360
+ // `idPrefix` keeps each section's folder list's id (which its heading's `aria-controls` names) unique between the two.
361
+ const sidebarContent = (idPrefix: string) => (
349
362
  <>
350
363
  <div className="p-3">
351
364
  <ComposeButton mailboxUid={defaultMailboxUid} />
@@ -357,91 +370,102 @@ export default function MailShell({
357
370
  ) : (
358
371
  <nav className="flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3">
359
372
  {mailboxes.length > 1 && (
360
- <div>
361
- <div className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5">
362
- All Mailboxes
363
- </div>
364
- <div className="flex flex-col gap-0.5">
365
- {AGGREGATE_FOLDER_TYPES.map((type) => {
366
- const badge = aggregateBadge(mailboxFolders, type, counts);
367
- return (
368
- <a
369
- key={type}
370
- href={`/?aggregate=${encodeURIComponent(type)}`}
371
- className={[
372
- "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
373
- aggregateFolderType === type
374
- ? "bg-primary/10 text-primary-dark font-semibold"
375
- : "text-text hover:bg-surface-alt",
376
- ].join(" ")}
377
- >
378
- <span className={badge?.kind === "unread" ? "font-semibold" : undefined}>{FOLDER_LABELS[type]}</span>
379
- {badge && <FolderBadgeChip badge={badge} />}
380
- </a>
381
- );
382
- })}
383
- </div>
384
- </div>
385
- )}
386
- {mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (
387
- <div key={mailbox.uid}>
388
- <div className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5 truncate">
389
- {mailbox.displayName}
390
- {mailbox.ownerUserUid ? "" : " (shared)"}
391
- </div>
392
- {mailboxError && (
393
- <div className="px-2.5 pb-1">
394
- <Alert>{mailboxError}</Alert>
395
- </div>
396
- )}
397
- <div className="flex flex-col gap-0.5">
398
- {folderRows(folders, pendingCountFor(mailbox.uid, pendingSends)).map((row) => {
399
- if (row.kind === "placeholder") {
400
- // A folder the server has not made (or told us about) yet - the Outbox and Sent Items are made with the first message
401
- // sent - shown the moment a message is on its way. Nothing to open; the real folder replaces it when it arrives.
402
- const sending = pendingCountFor(mailbox.uid, pendingSends);
403
- return (
404
- <div
405
- key={`placeholder-${row.type}`}
406
- data-folder-placeholder={row.type}
407
- className="flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted"
408
- >
409
- <span>{FOLDER_LABELS[row.type]}</span>
410
- {row.type === "outbox" && <OutboxBadge total={sending} pendingHere={sending} />}
411
- </div>
412
- );
413
- }
414
- const folder = row.folder;
415
- const badge = badgeFor(folder.type, countOfFolder(folder, counts));
416
- return (
373
+ <SidebarSection
374
+ domId={`${idPrefix}-mail-section-${ALL_MAILBOXES_SECTION}`}
375
+ label="All Mailboxes"
376
+ collapsible={collapsible}
377
+ expanded={sections.isExpanded(ALL_MAILBOXES_SECTION)}
378
+ onToggle={() => sections.toggle(ALL_MAILBOXES_SECTION)}
379
+ unread={unreadBadgeTotal(mailboxFolders.flatMap((mf) => mf.folders), counts)}
380
+ >
381
+ {AGGREGATE_FOLDER_TYPES.map((type) => {
382
+ const badge = aggregateBadge(mailboxFolders, type, counts);
383
+ return (
417
384
  <a
418
- key={folder.uid}
419
- href={`/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`}
385
+ key={type}
386
+ href={`/?aggregate=${encodeURIComponent(type)}`}
420
387
  className={[
421
388
  "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
422
- folder.uid === folderUid
389
+ aggregateFolderType === type
423
390
  ? "bg-primary/10 text-primary-dark font-semibold"
424
391
  : "text-text hover:bg-surface-alt",
425
392
  ].join(" ")}
426
393
  >
427
- <span className={badge?.kind === "unread" ? "font-semibold" : undefined}>
428
- {FOLDER_LABELS[folder.type] ?? folder.name}
429
- </span>
430
- {badge &&
431
- (folder.type === "outbox" ? (
432
- <OutboxBadge
433
- total={badge.value}
434
- status={outboxStatuses[folder.uid]}
435
- pendingHere={pendingCountFor(mailbox.uid, pendingSends)}
436
- />
437
- ) : (
438
- <FolderBadgeChip badge={badge} />
439
- ))}
394
+ <span className={badge?.kind === "unread" ? "font-semibold" : undefined}>{FOLDER_LABELS[type]}</span>
395
+ {badge && <FolderBadgeChip badge={badge} />}
440
396
  </a>
397
+ );
398
+ })}
399
+ </SidebarSection>
400
+ )}
401
+ {mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (
402
+ <SidebarSection
403
+ key={mailbox.uid}
404
+ domId={`${idPrefix}-mail-section-${mailbox.uid}`}
405
+ label={
406
+ <>
407
+ {mailbox.displayName}
408
+ {mailbox.ownerUserUid ? "" : " (shared)"}
409
+ </>
410
+ }
411
+ collapsible={collapsible}
412
+ expanded={sections.isExpanded(mailbox.uid)}
413
+ onToggle={() => sections.toggle(mailbox.uid)}
414
+ unread={unreadBadgeTotal(folders, counts)}
415
+ notice={
416
+ mailboxError && (
417
+ <div className="px-2.5 pb-1">
418
+ <Alert>{mailboxError}</Alert>
419
+ </div>
420
+ )
421
+ }
422
+ >
423
+ {folderRows(folders, pendingCountFor(mailbox.uid, pendingSends)).map((row) => {
424
+ if (row.kind === "placeholder") {
425
+ // A folder the server has not made (or told us about) yet - the Outbox and Sent Items are made with the first message
426
+ // sent - shown the moment a message is on its way. Nothing to open; the real folder replaces it when it arrives.
427
+ const sending = pendingCountFor(mailbox.uid, pendingSends);
428
+ return (
429
+ <div
430
+ key={`placeholder-${row.type}`}
431
+ data-folder-placeholder={row.type}
432
+ className="flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted"
433
+ >
434
+ <span>{FOLDER_LABELS[row.type]}</span>
435
+ {row.type === "outbox" && <OutboxBadge total={sending} pendingHere={sending} />}
436
+ </div>
441
437
  );
442
- })}
443
- </div>
444
- </div>
438
+ }
439
+ const folder = row.folder;
440
+ const badge = badgeFor(folder.type, countOfFolder(folder, counts));
441
+ return (
442
+ <a
443
+ key={folder.uid}
444
+ href={`/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`}
445
+ className={[
446
+ "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
447
+ folder.uid === folderUid
448
+ ? "bg-primary/10 text-primary-dark font-semibold"
449
+ : "text-text hover:bg-surface-alt",
450
+ ].join(" ")}
451
+ >
452
+ <span className={badge?.kind === "unread" ? "font-semibold" : undefined}>
453
+ {FOLDER_LABELS[folder.type] ?? folder.name}
454
+ </span>
455
+ {badge &&
456
+ (folder.type === "outbox" ? (
457
+ <OutboxBadge
458
+ total={badge.value}
459
+ status={outboxStatuses[folder.uid]}
460
+ pendingHere={pendingCountFor(mailbox.uid, pendingSends)}
461
+ />
462
+ ) : (
463
+ <FolderBadgeChip badge={badge} />
464
+ ))}
465
+ </a>
466
+ );
467
+ })}
468
+ </SidebarSection>
445
469
  ))}
446
470
  </nav>
447
471
  )}
@@ -451,9 +475,9 @@ export default function MailShell({
451
475
  inner = (
452
476
  <>
453
477
  <MailShortcuts mailboxUid={defaultMailboxUid} />
454
- <aside className="hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col">{sidebarContent()}</aside>
455
- <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Folders">
456
- <div className="flex flex-col">{sidebarContent()}</div>
478
+ <aside className="hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col">{sidebarContent("desktop")}</aside>
479
+ <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Folders" fullScreen>
480
+ <div className="flex flex-col">{sidebarContent("mobile")}</div>
457
481
  </Drawer>
458
482
  <main className="flex-1 min-w-0 overflow-y-auto">
459
483
  <div className="md:hidden flex items-center gap-2 p-3">
@@ -0,0 +1,66 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ReactNode } from "react";
6
+ import { HiChevronDown } from "react-icons/hi2";
7
+ import FolderBadgeChip from "./FolderBadgeChip.js";
8
+
9
+ export interface SidebarSectionProps {
10
+ /** Unique per rendering of the sidebar (it is on screen twice on a phone: beside the list and in the drawer): names the folder list the heading controls. */
11
+ domId: string;
12
+ /** The heading's text. */
13
+ label: ReactNode;
14
+ /** Whether the heading is a toggle at all. Not for a lone mailbox, whose section has nothing to be hidden for. */
15
+ collapsible: boolean;
16
+ expanded: boolean;
17
+ onToggle: () => void;
18
+ /** The unread mail in the section's folders that carry an unread count (see `unreadBadgeTotal()`), shown on the heading while it is collapsed (and only when there is some) so new mail in a hidden section is still noticed. */
19
+ unread?: number;
20
+ /** Shown between the heading and the folders, collapsed or not: a failure to load the section's folders is not something to hide. */
21
+ notice?: ReactNode;
22
+ /** The section's folder rows. */
23
+ children: ReactNode;
24
+ }
25
+
26
+ /**
27
+ * One section of Mail's sidebar ("All mailboxes", or one mailbox): a heading over a list of folders. When `collapsible` the heading is a
28
+ * disclosure button (`aria-expanded`, `aria-controls` the list, a chevron that turns) and the list is `hidden` while it is collapsed -
29
+ * kept in the document, so the button's `aria-controls` always names something and nothing is fetched or rebuilt on opening it.
30
+ */
31
+ export default function SidebarSection({ domId, label, collapsible, expanded, onToggle, unread = 0, notice, children }: SidebarSectionProps) {
32
+ const shown = !collapsible || expanded;
33
+ return (
34
+ <div>
35
+ {collapsible ? (
36
+ <h2 className="mb-1">
37
+ <button
38
+ type="button"
39
+ aria-expanded={expanded}
40
+ aria-controls={domId}
41
+ onClick={onToggle}
42
+ className={[
43
+ "flex w-full items-center gap-1 rounded-sm px-2.5 py-0.5 text-left text-xs font-bold uppercase tracking-wide text-text-muted",
44
+ "focus-visible:outline-2 focus-visible:outline-primary",
45
+ "hover:bg-surface-alt hover:text-text",
46
+ ].join(" ")}
47
+ >
48
+ <HiChevronDown
49
+ size={14}
50
+ aria-hidden="true"
51
+ className={["shrink-0 transition-transform", expanded ? "" : "-rotate-90"].join(" ")}
52
+ />
53
+ <span className="min-w-0 flex-1 truncate">{label}</span>
54
+ {!expanded && unread > 0 && <FolderBadgeChip badge={{ kind: "unread", value: unread }} />}
55
+ </button>
56
+ </h2>
57
+ ) : (
58
+ <div className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5 truncate">{label}</div>
59
+ )}
60
+ {notice}
61
+ <div id={domId} hidden={!shown} className={shown ? "flex flex-col gap-0.5" : "hidden"}>
62
+ {children}
63
+ </div>
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,68 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
7
+ import type { OutgoingReply } from "../../../mail/outbox/outgoingReplies.js";
8
+ import MailAddress, { RecipientLine } from "../MailAddress.js";
9
+ import MessageBody from "./MessageBody.js";
10
+ import { BODY_FONT_STYLE, CardShell, SenderAvatar } from "./MessageCard.js";
11
+
12
+ /**
13
+ * A reply or forward that has just been sent, drawn where its message will be once the server has filed it: the sender, who it went to, its body as
14
+ * it was composed, and one line saying where it is - "Sending...", "Sent", or, when it did not go, "Not sent: <why>" with the same ways out the
15
+ * pop-up offers (Retry, Open draft ...). It reads like the message's own card, so the real message replaces it without the thread rearranging.
16
+ *
17
+ * The body goes through the reading pane's own sanitizing frame (`MessageBody`), exactly as a received message's does.
18
+ */
19
+ export default function PendingMessageCard({
20
+ reply,
21
+ headerRef,
22
+ }: {
23
+ reply: OutgoingReply;
24
+ /** Registered so the pane can put the focus here when the message first appears. Focusable, but not in the tab order. */
25
+ headerRef: (node: HTMLHeadingElement | null) => void;
26
+ }) {
27
+ const failed = reply.state === "failed";
28
+ return (
29
+ <CardShell>
30
+ <div className="flex items-center gap-3 px-4 py-3">
31
+ <SenderAvatar from={reply.sender} />
32
+ <div className="flex-1 min-w-0">
33
+ <div className="flex flex-wrap items-baseline justify-between gap-x-2 text-sm">
34
+ <h2 ref={headerRef} tabIndex={-1} style={BODY_FONT_STYLE} className="flex-1 min-w-[13rem] font-semibold outline-none focus-visible:outline-2 focus-visible:outline-primary">
35
+ <MailAddress recipient={reply.sender} />
36
+ </h2>
37
+ <div
38
+ // Its own element for each: an alert is announced when it appears, which a status that changed its role would not be.
39
+ key={failed ? "failed" : "progress"}
40
+ role={failed ? "alert" : "status"}
41
+ data-testid="pending-message-status"
42
+ data-state={reply.state}
43
+ className={["flex items-center gap-1.5 text-xs shrink-0", failed ? "font-semibold text-danger" : "text-text-muted"].join(" ")}
44
+ >
45
+ {reply.state === "sending" && <span aria-hidden="true" className="rr-sending-dot h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />}
46
+ <span>{failed ? `Not sent: ${reply.failure}` : reply.state === "sending" ? "Sending…" : "Sent"}</span>
47
+ </div>
48
+ </div>
49
+ <RecipientLine label="To" recipients={reply.to} />
50
+ <RecipientLine label="Cc" recipients={reply.cc} />
51
+ <RecipientLine label="Bcc" recipients={reply.bcc} />
52
+ </div>
53
+ </div>
54
+ {failed && (
55
+ <div className="flex flex-wrap gap-2 px-4 pb-3">
56
+ {reply.actions.map((action) => (
57
+ <Button key={action.label} type="button" variant="secondary" className="!w-auto" onClick={action.onClick}>
58
+ {action.label}
59
+ </Button>
60
+ ))}
61
+ </div>
62
+ )}
63
+ <div className="px-4 pb-4">
64
+ <MessageBody messageUid={reply.uid} messageVersion={0} title={`Message: ${reply.subject}`} content={{ kind: "html", html: reply.html }} />
65
+ </div>
66
+ </CardShell>
67
+ );
68
+ }