@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 Drawer from "@rapidmx/react-shared/components/overlays/Drawer.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 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,7 +18,11 @@ import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
17
18
  import { 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";
20
- import { badgeFor, badgeLabel, countOfFolder, inboxUnreadTotal, } from "../../../mail/folderCounts.js";
21
+ import { primaryMailboxUid } from "../../../mail/primaryMailbox.js";
22
+ import { badgeFor, countOfFolder, inboxUnreadTotal, unreadBadgeTotal, } from "../../../mail/folderCounts.js";
23
+ import { ALL_MAILBOXES_SECTION, useSidebarSections } from "../../../mail/useCollapsedSections.js";
24
+ import FolderBadgeChip from "./FolderBadgeChip.js";
25
+ import SidebarSection from "./SidebarSection.js";
21
26
  import { useUnreadTitle } from "../../../mail/useUnreadTitle.js";
22
27
  import { pendingCountFor, usePendingSends } from "../../../mail/outbox/pendingSends.js";
23
28
  import OutboxBadge from "../OutboxBadge.js";
@@ -66,13 +71,6 @@ function aggregateBadge(mailboxFolders, type, counts) {
66
71
  }, { unread: 0, total: 0 });
67
72
  return badgeFor(type, sum);
68
73
  }
69
- /** A folder's badge: an accent pill with the unread count, or - for the folders that show how many they hold - plain muted text. */
70
- function FolderBadgeChip({ badge }) {
71
- return (_jsxs("span", { className: [
72
- "text-xs rounded-pill py-0.5 px-1.5",
73
- badge.kind === "unread" ? "font-bold bg-primary/15 text-primary-dark" : "font-medium text-text-muted",
74
- ].join(" "), children: [_jsx("span", { "aria-hidden": "true", children: badge.value }), _jsxs("span", { className: "sr-only", children: [" ", badgeLabel(badge)] })] }));
75
- }
76
74
  export { MAILBOX_LIST_LIMIT };
77
75
  /**
78
76
  * A separate component (not inlined into `MailShell`'s own render) so `useCompose()` resolves against
@@ -98,7 +96,7 @@ function ComposeButton({ mailboxUid }) {
98
96
  */
99
97
  function MobileComposeButton({ mailboxUid }) {
100
98
  const { openCompose } = useCompose();
101
- return (_jsx("button", { type: "button", "aria-label": "New message", onClick: () => openCompose({ mailboxUid }), onPointerEnter: prefetchComposeWindow, onFocus: prefetchComposeWindow, 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", children: _jsx(HiOutlinePencilSquare, { size: 22, "aria-hidden": "true" }) }));
99
+ return (_jsx(FloatingActionButton, { label: "New message", icon: HiOutlinePencilSquare, onClick: () => openCompose({ mailboxUid }), onPointerEnter: prefetchComposeWindow, onFocus: prefetchComposeWindow }));
102
100
  }
103
101
  /**
104
102
  * Mail's keyboard shortcuts that don't belong to a list or a message: "New message". A component of its own for the reason `ComposeButton`
@@ -125,6 +123,11 @@ function MailShortcuts({ mailboxUid }) {
125
123
  * behavior change from before this, and a multi-mailbox user sees every mailbox's folders (and the new
126
124
  * aggregate section) simultaneously, matching the shared-mailbox feature's own "display as a separate set
127
125
  * of folders" requirement.
126
+ *
127
+ * An address that names nothing opens "All Mailboxes > Inbox" when there is more than one mailbox (that is when the section exists), and
128
+ * the one mailbox's own Inbox otherwise; a `?mailboxUid=`, `?folderUid=` or `?aggregate=` always wins. Nothing is selected until the address
129
+ * has been read. What needs a single mailbox while an all-mailboxes view is open (Compose, the new-message shortcut, key unlocking) uses the
130
+ * primary mailbox.
128
131
  */
129
132
  export default function MailShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }) {
130
133
  // The mailboxes, their folders and the live connection come from the persistent app frame when there is one (`AppChrome` owns them, so
@@ -140,6 +143,8 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
140
143
  const [requestedFolderUid, setRequestedFolderUid] = useState(null);
141
144
  const [requestedAggregateType, setRequestedAggregateType] = useState(null);
142
145
  const [drawerOpen, setDrawerOpen] = useState(false);
146
+ // 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.
147
+ const [selectionRead, setSelectionRead] = useState(false);
143
148
  // The selection lives in the URL, and the router (`AppRouter`) changes the URL without a page load when a folder link is
144
149
  // clicked - so it is read from the router's location, which updates, rather than once from `window.location`. Still read
145
150
  // in an effect, never during the first render, so the server render and the hydrating render agree.
@@ -149,31 +154,44 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
149
154
  setRequestedMailboxUid(params.get("mailboxUid"));
150
155
  setRequestedFolderUid(params.get("folderUid"));
151
156
  setRequestedAggregateType(params.get("aggregate"));
157
+ setSelectionRead(true);
152
158
  }, [search]);
153
159
  // Choosing a folder (or mailbox) changes the URL without a page load now, so the drawer that held the choice - which used to go
154
160
  // with the page - is closed here.
155
161
  useEffect(() => {
156
162
  setDrawerOpen(false);
157
163
  }, [search]);
158
- const aggregateFolderType = isAggregateFolderType(requestedAggregateType)
159
- ? requestedAggregateType
160
- : undefined;
161
- const mailboxUid = aggregateFolderType
162
- ? undefined
163
- : (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
164
- mailboxes[0]?.uid;
164
+ // What the address asks for. Nothing is selected until it has been read (the first render, before the effect above): choosing a
165
+ // default then would list a folder - the most expensive being every mailbox's - only to throw it away for the one the address names.
166
+ const requestedAggregate = selectionRead && isAggregateFolderType(requestedAggregateType) ? requestedAggregateType : undefined;
167
+ const requestedMailbox = selectionRead && requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined;
168
+ // The mailbox the caller has not chosen: their own, never a shared one that merely sorts first. It is what compose, the new-message
169
+ // shortcut and everything else that needs a single mailbox use while an all-mailboxes view (which has none) is open.
170
+ const defaultMailboxUid = primaryMailboxUid(mailboxes, userUid);
171
+ // With several mailboxes the address that names nothing opens "All Mailboxes > Inbox", the merged Inbox; with one there is no such
172
+ // section, so it opens that mailbox's own Inbox. A mailbox, a folder or an all-mailboxes view in the address wins over either.
173
+ const aggregateByDefault = selectionRead && !requestedAggregate && !requestedMailbox && requestedFolderUid === null && mailboxes.length > 1;
174
+ const aggregateFolderType = requestedAggregate ?? (aggregateByDefault ? "inbox" : undefined);
175
+ const mailboxUid = aggregateFolderType || !selectionRead ? undefined : (requestedMailbox ?? defaultMailboxUid);
165
176
  const selectedMailboxFolders = mailboxFolders.find((mf) => mf.mailbox.uid === mailboxUid)?.folders ?? [];
166
177
  const folderUid = aggregateFolderType
167
178
  ? undefined
168
179
  : (requestedFolderUid && selectedMailboxFolders.some((f) => f.uid === requestedFolderUid) ? requestedFolderUid : undefined) ??
169
180
  selectedMailboxFolders.find((f) => f.type === "inbox")?.uid;
170
181
  // The mailbox `KeyEnrollmentGate`/`LocalIndexLifecycle`/`ComposeButton` treat as "the" mailbox when
171
- // there's no single selected one to use (aggregate mode) - the caller's own owned mailbox if they
172
- // have one, else whichever accessible mailbox happens to be first. See MailShell's own doc comment on
182
+ // there's no single selected one to use (aggregate mode) - the caller's primary mailbox (`primaryMailbox()`). See MailShell's own doc comment on
173
183
  // this being an accepted limitation: an aggregate-view message from a *different*, not-yet-visited
174
184
  // mailbox may still need that mailbox's own folder view opened directly to unlock/decrypt it.
175
- const defaultMailboxUid = mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
176
185
  const activeMailboxUid = mailboxUid ?? defaultMailboxUid;
186
+ // Which sidebar sections are open: "All mailboxes" and the primary mailbox unless the reader chose otherwise. Any of them can be collapsed;
187
+ // a change of the open folder into another section opens that one (see `useSidebarSections()`); the first selection, once the address has been read
188
+ // 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.
189
+ const sections = useSidebarSections({
190
+ userUid,
191
+ primaryUid: defaultMailboxUid,
192
+ activeId: aggregateFolderType ? ALL_MAILBOXES_SECTION : mailboxUid,
193
+ });
194
+ const collapsible = mailboxes.length > 1;
177
195
  const counts = folderCounts.counts;
178
196
  const trackMessageChange = folderCounts.track;
179
197
  // `(3) Acme: Mail` in the tab strip while there is unread mail in an Inbox - kept by the frame when there is one.
@@ -201,32 +219,33 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
201
219
  // A function, not a plain JSX constant — it's rendered twice (desktop `<aside>` + mobile
202
220
  // `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
203
221
  // unmounted).
204
- const sidebarContent = () => (_jsxs(_Fragment, { children: [_jsx("div", { className: "p-3", children: _jsx(ComposeButton, { mailboxUid: defaultMailboxUid }) }), foldersLoading ? (_jsx("div", { className: "flex-1 overflow-y-auto px-3 pb-3", children: _jsx(SkeletonList, { count: 6, className: "pt-1" }) })) : (_jsxs("nav", { className: "flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3", children: [mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5", children: "All Mailboxes" }), _jsx("div", { className: "flex flex-col gap-0.5", children: AGGREGATE_FOLDER_TYPES.map((type) => {
205
- const badge = aggregateBadge(mailboxFolders, type, counts);
206
- return (_jsxs("a", { href: `/?aggregate=${encodeURIComponent(type)}`, className: [
207
- "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
208
- aggregateFolderType === type
209
- ? "bg-primary/10 text-primary-dark font-semibold"
210
- : "text-text hover:bg-surface-alt",
211
- ].join(" "), children: [_jsx("span", { className: badge?.kind === "unread" ? "font-semibold" : undefined, children: FOLDER_LABELS[type] }), badge && _jsx(FolderBadgeChip, { badge: badge })] }, type));
212
- }) })] })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsxs("div", { children: [_jsxs("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5 truncate", children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }), mailboxError && (_jsx("div", { className: "px-2.5 pb-1", children: _jsx(Alert, { children: mailboxError }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: folderRows(folders, pendingCountFor(mailbox.uid, pendingSends)).map((row) => {
213
- if (row.kind === "placeholder") {
214
- // A folder the server has not made (or told us about) yet - the Outbox and Sent Items are made with the first message
215
- // sent - shown the moment a message is on its way. Nothing to open; the real folder replaces it when it arrives.
216
- const sending = pendingCountFor(mailbox.uid, pendingSends);
217
- return (_jsxs("div", { "data-folder-placeholder": row.type, className: "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted", children: [_jsx("span", { children: FOLDER_LABELS[row.type] }), row.type === "outbox" && _jsx(OutboxBadge, { total: sending, pendingHere: sending })] }, `placeholder-${row.type}`));
218
- }
219
- const folder = row.folder;
220
- const badge = badgeFor(folder.type, countOfFolder(folder, counts));
221
- return (_jsxs("a", { href: `/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`, className: [
222
- "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
223
- folder.uid === folderUid
224
- ? "bg-primary/10 text-primary-dark font-semibold"
225
- : "text-text hover:bg-surface-alt",
226
- ].join(" "), children: [_jsx("span", { className: badge?.kind === "unread" ? "font-semibold" : undefined, children: FOLDER_LABELS[folder.type] ?? folder.name }), badge &&
227
- (folder.type === "outbox" ? (_jsx(OutboxBadge, { total: badge.value, status: outboxStatuses[folder.uid], pendingHere: pendingCountFor(mailbox.uid, pendingSends) })) : (_jsx(FolderBadgeChip, { badge: badge })))] }, folder.uid));
228
- }) })] }, mailbox.uid)))] }))] }));
229
- inner = (_jsxs(_Fragment, { children: [_jsx(MailShortcuts, { mailboxUid: defaultMailboxUid }), _jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent() }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", children: _jsx("div", { className: "flex flex-col", children: sidebarContent() }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsxs("div", { className: "md:hidden flex items-center gap-2 p-3", children: [_jsx("button", { type: "button", className: "shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("div", { ref: setMobileSearchSlot, className: "flex-1 min-w-0" })] }), _jsx(MobileComposeButton, { mailboxUid: defaultMailboxUid }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
222
+ // `idPrefix` keeps each section's folder list's id (which its heading's `aria-controls` names) unique between the two.
223
+ const sidebarContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsx("div", { className: "p-3", children: _jsx(ComposeButton, { mailboxUid: defaultMailboxUid }) }), foldersLoading ? (_jsx("div", { className: "flex-1 overflow-y-auto px-3 pb-3", children: _jsx(SkeletonList, { count: 6, className: "pt-1" }) })) : (_jsxs("nav", { className: "flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3", children: [mailboxes.length > 1 && (_jsx(SidebarSection, { domId: `${idPrefix}-mail-section-${ALL_MAILBOXES_SECTION}`, label: "All Mailboxes", collapsible: collapsible, expanded: sections.isExpanded(ALL_MAILBOXES_SECTION), onToggle: () => sections.toggle(ALL_MAILBOXES_SECTION), unread: unreadBadgeTotal(mailboxFolders.flatMap((mf) => mf.folders), counts), children: AGGREGATE_FOLDER_TYPES.map((type) => {
224
+ const badge = aggregateBadge(mailboxFolders, type, counts);
225
+ return (_jsxs("a", { href: `/?aggregate=${encodeURIComponent(type)}`, className: [
226
+ "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
227
+ aggregateFolderType === type
228
+ ? "bg-primary/10 text-primary-dark font-semibold"
229
+ : "text-text hover:bg-surface-alt",
230
+ ].join(" "), children: [_jsx("span", { className: badge?.kind === "unread" ? "font-semibold" : undefined, children: FOLDER_LABELS[type] }), badge && _jsx(FolderBadgeChip, { badge: badge })] }, type));
231
+ }) })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsx(SidebarSection, { domId: `${idPrefix}-mail-section-${mailbox.uid}`, label: _jsxs(_Fragment, { children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }), collapsible: collapsible, expanded: sections.isExpanded(mailbox.uid), onToggle: () => sections.toggle(mailbox.uid), unread: unreadBadgeTotal(folders, counts), notice: mailboxError && (_jsx("div", { className: "px-2.5 pb-1", children: _jsx(Alert, { children: mailboxError }) })), children: folderRows(folders, pendingCountFor(mailbox.uid, pendingSends)).map((row) => {
232
+ if (row.kind === "placeholder") {
233
+ // A folder the server has not made (or told us about) yet - the Outbox and Sent Items are made with the first message
234
+ // sent - shown the moment a message is on its way. Nothing to open; the real folder replaces it when it arrives.
235
+ const sending = pendingCountFor(mailbox.uid, pendingSends);
236
+ return (_jsxs("div", { "data-folder-placeholder": row.type, className: "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted", children: [_jsx("span", { children: FOLDER_LABELS[row.type] }), row.type === "outbox" && _jsx(OutboxBadge, { total: sending, pendingHere: sending })] }, `placeholder-${row.type}`));
237
+ }
238
+ const folder = row.folder;
239
+ const badge = badgeFor(folder.type, countOfFolder(folder, counts));
240
+ return (_jsxs("a", { href: `/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`, className: [
241
+ "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
242
+ folder.uid === folderUid
243
+ ? "bg-primary/10 text-primary-dark font-semibold"
244
+ : "text-text hover:bg-surface-alt",
245
+ ].join(" "), children: [_jsx("span", { className: badge?.kind === "unread" ? "font-semibold" : undefined, children: FOLDER_LABELS[folder.type] ?? folder.name }), badge &&
246
+ (folder.type === "outbox" ? (_jsx(OutboxBadge, { total: badge.value, status: outboxStatuses[folder.uid], pendingHere: pendingCountFor(mailbox.uid, pendingSends) })) : (_jsx(FolderBadgeChip, { badge: badge })))] }, folder.uid));
247
+ }) }, mailbox.uid)))] }))] }));
248
+ inner = (_jsxs(_Fragment, { children: [_jsx(MailShortcuts, { mailboxUid: defaultMailboxUid }), _jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", fullScreen: true, children: _jsx("div", { className: "flex flex-col", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsxs("div", { className: "md:hidden flex items-center gap-2 p-3", children: [_jsx("button", { type: "button", className: "shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("div", { ref: setMobileSearchSlot, className: "flex-1 min-w-0" })] }), _jsx(MobileComposeButton, { mailboxUid: defaultMailboxUid }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
230
249
  }
231
250
  // Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
232
251
  // resolved yet - KeyEnrollmentGate itself passes `children` through untouched until a real
@@ -0,0 +1,23 @@
1
+ import React, { ReactNode } from "react";
2
+ export interface SidebarSectionProps {
3
+ /** 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. */
4
+ domId: string;
5
+ /** The heading's text. */
6
+ label: ReactNode;
7
+ /** Whether the heading is a toggle at all. Not for a lone mailbox, whose section has nothing to be hidden for. */
8
+ collapsible: boolean;
9
+ expanded: boolean;
10
+ onToggle: () => void;
11
+ /** 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. */
12
+ unread?: number;
13
+ /** Shown between the heading and the folders, collapsed or not: a failure to load the section's folders is not something to hide. */
14
+ notice?: ReactNode;
15
+ /** The section's folder rows. */
16
+ children: ReactNode;
17
+ }
18
+ /**
19
+ * One section of Mail's sidebar ("All mailboxes", or one mailbox): a heading over a list of folders. When `collapsible` the heading is a
20
+ * disclosure button (`aria-expanded`, `aria-controls` the list, a chevron that turns) and the list is `hidden` while it is collapsed -
21
+ * kept in the document, so the button's `aria-controls` always names something and nothing is fetched or rebuilt on opening it.
22
+ */
23
+ export default function SidebarSection({ domId, label, collapsible, expanded, onToggle, unread, notice, children }: SidebarSectionProps): React.JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { HiChevronDown } from "react-icons/hi2";
3
+ import FolderBadgeChip from "./FolderBadgeChip.js";
4
+ /**
5
+ * One section of Mail's sidebar ("All mailboxes", or one mailbox): a heading over a list of folders. When `collapsible` the heading is a
6
+ * disclosure button (`aria-expanded`, `aria-controls` the list, a chevron that turns) and the list is `hidden` while it is collapsed -
7
+ * kept in the document, so the button's `aria-controls` always names something and nothing is fetched or rebuilt on opening it.
8
+ */
9
+ export default function SidebarSection({ domId, label, collapsible, expanded, onToggle, unread = 0, notice, children }) {
10
+ const shown = !collapsible || expanded;
11
+ return (_jsxs("div", { children: [collapsible ? (_jsx("h2", { className: "mb-1", children: _jsxs("button", { type: "button", "aria-expanded": expanded, "aria-controls": domId, onClick: onToggle, className: [
12
+ "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",
13
+ "focus-visible:outline-2 focus-visible:outline-primary",
14
+ "hover:bg-surface-alt hover:text-text",
15
+ ].join(" "), children: [_jsx(HiChevronDown, { size: 14, "aria-hidden": "true", className: ["shrink-0 transition-transform", expanded ? "" : "-rotate-90"].join(" ") }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: label }), !expanded && unread > 0 && _jsx(FolderBadgeChip, { badge: { kind: "unread", value: unread } })] }) })) : (_jsx("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5 truncate", children: label })), notice, _jsx("div", { id: domId, hidden: !shown, className: shown ? "flex flex-col gap-0.5" : "hidden", children: children })] }));
16
+ }
@@ -0,0 +1,14 @@
1
+ import React from "react";
2
+ import type { OutgoingReply } from "../../../mail/outbox/outgoingReplies.js";
3
+ /**
4
+ * 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
5
+ * 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
6
+ * pop-up offers (Retry, Open draft ...). It reads like the message's own card, so the real message replaces it without the thread rearranging.
7
+ *
8
+ * The body goes through the reading pane's own sanitizing frame (`MessageBody`), exactly as a received message's does.
9
+ */
10
+ export default function PendingMessageCard({ reply, headerRef, }: {
11
+ reply: OutgoingReply;
12
+ /** Registered so the pane can put the focus here when the message first appears. Focusable, but not in the tab order. */
13
+ headerRef: (node: HTMLHeadingElement | null) => void;
14
+ }): React.JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
3
+ import MailAddress, { RecipientLine } from "../MailAddress.js";
4
+ import MessageBody from "./MessageBody.js";
5
+ import { BODY_FONT_STYLE, CardShell, SenderAvatar } from "./MessageCard.js";
6
+ /**
7
+ * 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
8
+ * 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
9
+ * pop-up offers (Retry, Open draft ...). It reads like the message's own card, so the real message replaces it without the thread rearranging.
10
+ *
11
+ * The body goes through the reading pane's own sanitizing frame (`MessageBody`), exactly as a received message's does.
12
+ */
13
+ export default function PendingMessageCard({ reply, headerRef, }) {
14
+ const failed = reply.state === "failed";
15
+ return (_jsxs(CardShell, { children: [_jsxs("div", { className: "flex items-center gap-3 px-4 py-3", children: [_jsx(SenderAvatar, { from: reply.sender }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsxs("div", { className: "flex flex-wrap items-baseline justify-between gap-x-2 text-sm", children: [_jsx("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", children: _jsx(MailAddress, { recipient: reply.sender }) }), _jsxs("div", { role: failed ? "alert" : "status", "data-testid": "pending-message-status", "data-state": reply.state, className: ["flex items-center gap-1.5 text-xs shrink-0", failed ? "font-semibold text-danger" : "text-text-muted"].join(" "), children: [reply.state === "sending" && _jsx("span", { "aria-hidden": "true", className: "rr-sending-dot h-1.5 w-1.5 shrink-0 rounded-full bg-primary" }), _jsx("span", { children: failed ? `Not sent: ${reply.failure}` : reply.state === "sending" ? "Sending…" : "Sent" })] }, failed ? "failed" : "progress")] }), _jsx(RecipientLine, { label: "To", recipients: reply.to }), _jsx(RecipientLine, { label: "Cc", recipients: reply.cc }), _jsx(RecipientLine, { label: "Bcc", recipients: reply.bcc })] })] }), failed && (_jsx("div", { className: "flex flex-wrap gap-2 px-4 pb-3", children: reply.actions.map((action) => (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: action.onClick, children: action.label }, action.label))) })), _jsx("div", { className: "px-4 pb-4", children: _jsx(MessageBody, { messageUid: reply.uid, messageVersion: 0, title: `Message: ${reply.subject}`, content: { kind: "html", html: reply.html } }) })] }));
16
+ }
@@ -13,6 +13,7 @@ import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedbac
13
13
  import AppShell from "../../layout/AppShell.js";
14
14
  import { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
15
15
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
16
+ import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
16
17
  import { mergePluginNavItems } from "../../../plugins/pluginNav.js";
17
18
  /** Every settings section with its own sidebar entry. */
18
19
  export const SETTINGS_SECTIONS = [
@@ -73,7 +74,7 @@ export default function SettingsShell({ active, userUid, authServerUrl, imperson
73
74
  }
74
75
  listMailboxes({ limit: 100 })
75
76
  .then((result) => {
76
- setMailboxes(result);
77
+ setMailboxes(orderMailboxes(result, userUid));
77
78
  setStatus("ready");
78
79
  })
79
80
  .catch((err) => {
@@ -82,7 +83,7 @@ export default function SettingsShell({ active, userUid, authServerUrl, imperson
82
83
  });
83
84
  }, [userUid]);
84
85
  const mailboxUid = (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
85
- mailboxes[0]?.uid;
86
+ primaryMailboxUid(mailboxes, userUid);
86
87
  const contextValue = useMemo(() => ({ mailboxUid, mailboxes }), [mailboxUid, mailboxes]);
87
88
  // A full-screen takeover, not nested inside the rest of the app's chrome — same as ContactsShell/
88
89
  // TasksShell, there's nothing else for a mailbox-less caller to configure here yet.
@@ -110,8 +111,8 @@ export default function SettingsShell({ active, userUid, authServerUrl, imperson
110
111
  }, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsx("nav", { "aria-label": "Settings sections", className: "flex flex-col gap-0.5", children: sections.map((section) => (_jsx("a", {
111
112
  // `inner` (this whole branch) is only ever computed once `status === "ready"`
112
113
  // and `mailboxUid` has already resolved truthy — a falsy `mailboxUid` returns
113
- // `<MailboxProvisioning />` above instead (and TypeScript's own narrowing of
114
- // this `const` already reflects that, no `!` needed) — so no fallback is
114
+ // `<MailboxProvisioning />` above instead (which is what the `!` below records,
115
+ // `primaryMailboxUid()` being honest that it may find none) — so no fallback is
115
116
  // needed here, matching this plan's established "dead guard the UI
116
117
  // structurally can't trigger" removal precedent (e.g.
117
118
  // `DomainDetailContent.handleVerify`).
@@ -119,7 +120,7 @@ export default function SettingsShell({ active, userUid, authServerUrl, imperson
119
120
  "block px-2.5 py-1.5 rounded-sm text-sm",
120
121
  section.id === active ? "bg-primary/10 text-primary-dark font-medium" : "text-text hover:bg-surface-alt",
121
122
  ].join(" "), children: section.label }, section.id))) })] }));
122
- inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Settings", children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [_jsx("button", { type: "button", className: "md:hidden m-3 self-start w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open settings menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(SettingsShellContext.Provider, { value: contextValue, children: children })] })] }));
123
+ inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Settings", fullScreen: true, children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [_jsx("button", { type: "button", className: "md:hidden m-3 self-start w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open settings menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(SettingsShellContext.Provider, { value: contextValue, children: children })] })] }));
123
124
  }
124
125
  return (_jsx(AppShell, { active: "settings", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner }));
125
126
  }
@@ -79,5 +79,5 @@ export default function TasksSidebar({ mailboxUid, tasks, userUid, active, onSel
79
79
  // `Drawer` (see `ContactsSidebar` for the identical pattern and the duplicate-id reasoning behind
80
80
  // the `idPrefix`).
81
81
  const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx(NavItem, { label: "My Day", count: myDayCount, active: active.type === "myDay", onClick: () => handleSelect({ type: "myDay" }) }), _jsx(NavItem, { label: "Important", count: importantCount, active: active.type === "important", onClick: () => handleSelect({ type: "important" }) }), _jsx(NavItem, { label: "Planned", count: plannedCount, active: active.type === "planned", onClick: () => handleSelect({ type: "planned" }) }), _jsx(NavItem, { label: "Assigned to me", count: assignedToMeCount, active: active.type === "assignedToMe", onClick: () => handleSelect({ type: "assignedToMe" }) }), _jsx(NavItem, { label: "Flagged email", active: active.type === "flagged", onClick: () => handleSelect({ type: "flagged" }) })] }), _jsx(NavItem, { label: "Tasks", count: tasks.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] })] }));
82
- return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "lg:hidden m-3 mb-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open tasks menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("nav", { "aria-label": "Tasks", className: "hidden lg:flex w-56 shrink-0 border-r border-border flex-col gap-4 p-3 overflow-y-auto", children: navContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Tasks", children: _jsx("div", { className: "flex flex-col gap-4", children: navContent("mobile") }) })] }));
82
+ return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "lg:hidden m-3 mb-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open tasks menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("nav", { "aria-label": "Tasks", className: "hidden lg:flex w-56 shrink-0 border-r border-border flex-col gap-4 p-3 overflow-y-auto", children: navContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Tasks", fullScreen: true, children: _jsx("div", { className: "flex flex-col gap-4", children: navContent("mobile") }) })] }));
83
83
  }
@@ -13,6 +13,7 @@ import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedbac
13
13
  import AppShell from "../../layout/AppShell.js";
14
14
  import { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
15
15
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
16
+ import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
16
17
  const TasksShellContext = createContext({ mailboxes: [] });
17
18
  /** Reads the mailbox/folder Tasks is currently showing, as resolved by the enclosing `TasksShell`. */
18
19
  export function useTasksShell() {
@@ -51,7 +52,7 @@ export default function TasksShell({ userUid, authServerUrl, impersonating, impe
51
52
  }
52
53
  listMailboxes({ limit: 100 })
53
54
  .then((result) => {
54
- setMailboxes(result);
55
+ setMailboxes(orderMailboxes(result, userUid));
55
56
  setStatus("ready");
56
57
  })
57
58
  .catch((err) => {
@@ -60,7 +61,7 @@ export default function TasksShell({ userUid, authServerUrl, impersonating, impe
60
61
  });
61
62
  }, [userUid]);
62
63
  const mailboxUid = (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
63
- mailboxes[0]?.uid;
64
+ primaryMailboxUid(mailboxes, userUid);
64
65
  useEffect(() => {
65
66
  if (!mailboxUid) {
66
67
  setFolders([]);
@@ -98,7 +99,7 @@ export default function TasksShell({ userUid, authServerUrl, impersonating, impe
98
99
  }, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), folderError && _jsx(Alert, { children: folderError })] }));
99
100
  // With a single mailbox and no error this column would be empty: no 224 px of nothing beside the tasks' own menu.
100
101
  const hasSidebar = mailboxes.length > 1 || !!folderError;
101
- inner = (_jsxs(_Fragment, { children: [hasSidebar && (_jsx("aside", { className: "hidden lg:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3", children: sidebarContent("desktop") })), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Mailbox", children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [hasSidebar && (_jsx("button", { type: "button", className: "lg:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open mailbox switcher", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) })), _jsx(TasksShellContext.Provider, { value: contextValue, children: children })] })] }));
102
+ inner = (_jsxs(_Fragment, { children: [hasSidebar && (_jsx("aside", { className: "hidden lg:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3", children: sidebarContent("desktop") })), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Mailbox", fullScreen: true, children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [hasSidebar && (_jsx("button", { type: "button", className: "lg:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open mailbox switcher", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) })), _jsx(TasksShellContext.Provider, { value: contextValue, children: children })] })] }));
102
103
  }
103
104
  return (_jsx(AppShell, { active: "tasks", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner }));
104
105
  }
@@ -46,6 +46,16 @@ export declare function badgeLabel(badge: FolderBadge): string;
46
46
  export declare function inboxUnreadTotal(mailboxFolders: {
47
47
  folders: Folder[];
48
48
  }[], counts: Record<string, FolderCount>): number;
49
+ /**
50
+ * The folder types whose unread count is left out of a collapsed sidebar section's heading badge: Drafts and Outbox (their badges
51
+ * count messages held, not unread mail), and Sent Items, Deleted Items and Junk Email (an unread count there is noise, not mail
52
+ * to notice). It matches the folders `badgeFor()` shows no unread badge for; every other type - Inbox, Archive, the user's own
53
+ * folders - counts, so mail that a rule filed out of the Inbox is not overlooked in a hidden section. Change it here to change that.
54
+ */
55
+ export declare const UNREAD_BADGE_EXCLUDED_FOLDER_TYPES: readonly FolderType[];
56
+ /** The unread messages in the `folders` that carry an unread count (all but `UNREAD_BADGE_EXCLUDED_FOLDER_TYPES`) - what a collapsed
57
+ * sidebar section shows on its heading, so new mail in a hidden section is still noticed. */
58
+ export declare function unreadBadgeTotal(folders: readonly Folder[], counts: Record<string, FolderCount>): number;
49
59
  /** How long after the last change of this page's own has finished a server-published count is trusted again: one published
50
60
  * about an earlier state of the folder could otherwise arrive late and put back what the change just took away. */
51
61
  export declare const COUNT_QUIET_MS = 1500;
@@ -51,6 +51,18 @@ export function badgeLabel(badge) {
51
51
  export function inboxUnreadTotal(mailboxFolders, counts) {
52
52
  return mailboxFolders.reduce((total, entry) => total + entry.folders.filter((folder) => folder.type === "inbox").reduce((sum, folder) => sum + countOfFolder(folder, counts).unread, 0), 0);
53
53
  }
54
+ /**
55
+ * The folder types whose unread count is left out of a collapsed sidebar section's heading badge: Drafts and Outbox (their badges
56
+ * count messages held, not unread mail), and Sent Items, Deleted Items and Junk Email (an unread count there is noise, not mail
57
+ * to notice). It matches the folders `badgeFor()` shows no unread badge for; every other type - Inbox, Archive, the user's own
58
+ * folders - counts, so mail that a rule filed out of the Inbox is not overlooked in a hidden section. Change it here to change that.
59
+ */
60
+ export const UNREAD_BADGE_EXCLUDED_FOLDER_TYPES = ["drafts", "outbox", "sent_items", "deleted_items", "junk"];
61
+ /** The unread messages in the `folders` that carry an unread count (all but `UNREAD_BADGE_EXCLUDED_FOLDER_TYPES`) - what a collapsed
62
+ * sidebar section shows on its heading, so new mail in a hidden section is still noticed. */
63
+ export function unreadBadgeTotal(folders, counts) {
64
+ return folders.reduce((total, folder) => (UNREAD_BADGE_EXCLUDED_FOLDER_TYPES.includes(folder.type) ? total : total + countOfFolder(folder, counts).unread), 0);
65
+ }
54
66
  /** How long after the last change of this page's own has finished a server-published count is trusted again: one published
55
67
  * about an earlier state of the folder could otherwise arrive late and put back what the change just took away. */
56
68
  export const COUNT_QUIET_MS = 1500;
@@ -0,0 +1,80 @@
1
+ import type { Message } from "@rapidmx/react-shared/mail/mailApi.js";
2
+ import type { MailAddressLike } from "@rapidmx/react-shared/mail/mailAddress.js";
3
+ import type { NotificationAction } from "../../notifications/store.js";
4
+ import type { SendRequest } from "./sendJob.js";
5
+ /**
6
+ * A reply (or forward) this tab has sent, as the open conversation shows it until the server's own copy is in the thread.
7
+ *
8
+ * This is a *view* of the background send that is already there, not a second state machine: `startSend()` (`sendJob.ts`) opens an entry, and the
9
+ * places that already report how a send went close it - the client-side failure (`notifyFailure()`), the server's own outcome (`handleSendEvent()`
10
+ * in `sendOutcomes.ts`: `send-succeeded`, `send-failed`) and a synchronous send. The Outbox indicator, the pop-ups and this entry therefore agree,
11
+ * because they are all told by the same code at the same moment.
12
+ *
13
+ * Why the message has to be shown from here for a while: a message the server accepted is in Outbox, which has no `conversationId` yet - it is
14
+ * resolved when the message is relayed and filed in Sent Items (`ScheduledSendJob`), keeping the same `uid`. Until then no fetch of the
15
+ * conversation can return it, so the thread pane draws it from what was composed.
16
+ *
17
+ * `sending` covers everything up to the server's answer (assembling, the request, the queue in Outbox); `failed` is a send that did not go and is
18
+ * the sender's to resolve (`actions` are the very ones the pop-up offers: Retry, Open draft ...); `sent` is a message the server has relayed and
19
+ * that is waiting for the thread to be read again to find its Sent Items copy.
20
+ */
21
+ export type OutgoingState = "sending" | "failed" | "sent";
22
+ export interface OutgoingReply {
23
+ /** The draft's uid - which is also the uid of the message in Outbox and of its Sent Items copy, and so what the real message replaces this by. */
24
+ uid: string;
25
+ mailboxUid: string;
26
+ /** What the message replies to (`Message.inReplyTo` / `references`): how the pane knows which conversation it belongs to. */
27
+ inReplyTo?: string;
28
+ references: string[];
29
+ sender: MailAddressLike;
30
+ to: MailAddressLike[];
31
+ cc: MailAddressLike[];
32
+ bcc: MailAddressLike[];
33
+ subject: string;
34
+ /** The composed body, as the editor wrote it. */
35
+ html: string;
36
+ startedAt: number;
37
+ state: OutgoingState;
38
+ /** `failed`: why. */
39
+ failure?: string;
40
+ /** `failed`: what the sender can do about it. */
41
+ actions: NotificationAction[];
42
+ /** `sent`: how many times the thread was read without finding the message - see `settleOutgoing()`. */
43
+ misses: number;
44
+ }
45
+ /** A sent message that a read of its conversation has not found this many times is not part of that conversation (the server filed it elsewhere). */
46
+ export declare const MAX_SETTLE_MISSES = 2;
47
+ /**
48
+ * Starts showing `request`'s message in the conversation it replies to - or does nothing for a message that replies to nothing (a new message
49
+ * starts a conversation of its own, it is never part of an open one) and for a send-later message (it is not sent yet). Called again for the same
50
+ * message - a Retry - it starts over as `sending`.
51
+ *
52
+ * The thread is `request.threading` (the compose session's), else what the draft itself records: a draft opened again from "Open draft" is a
53
+ * session with no threading of its own, but the server kept it on the message.
54
+ */
55
+ export declare function trackOutgoing(request: SendRequest): void;
56
+ /** The send did not go: `failure` is why, `actions` what can be done (Retry, Open draft ...). */
57
+ export declare function failOutgoing(uid: string, failure: string, actions: NotificationAction[]): void;
58
+ /** A failed send is being tried again. */
59
+ export declare function markOutgoingSending(uid: string): void;
60
+ /** The server has relayed the message: its Sent Items copy is what the conversation shows next. */
61
+ export declare function markOutgoingSent(uid: string): void;
62
+ /** Stops showing a message - it went back to Drafts to be edited. */
63
+ export declare function forgetOutgoing(uid: string): void;
64
+ /** Whether `reply` continues the conversation `messages` are: it answers one of them, or its `References` chain runs through one. */
65
+ export declare function belongsToThread(reply: OutgoingReply, messages: readonly Pick<Message, "messageId">[]): boolean;
66
+ /** The uids of the messages sent from here whose real copy is in `messages`, the thread as it was just read. */
67
+ export declare function adoptedOutgoing(mailboxUid: string, messages: readonly Message[]): string[];
68
+ /**
69
+ * Called with a mailbox's conversation as it was just read, `messages` being the whole thread: forgets the messages whose real copy is now in it
70
+ * (`adoptedOutgoing()`), and gives up on a *sent* message that belongs to this thread but that the read did not find `MAX_SETTLE_MISSES` times in a
71
+ * row - the server filed it in a conversation of its own, and it is not to be shown here. A message of another conversation is left alone.
72
+ *
73
+ * A pane draws the messages it has read *before* it calls this, so that there is never a moment with neither the pending card nor the real one.
74
+ */
75
+ export declare function settleOutgoing(mailboxUid: string, messages: readonly Message[]): void;
76
+ export declare function getOutgoingReplies(): readonly OutgoingReply[];
77
+ /** The messages this tab has sent that a conversation may still need to show, oldest first. */
78
+ export declare function useOutgoingReplies(): readonly OutgoingReply[];
79
+ /** Forgets everything - for a test. */
80
+ export declare function resetOutgoingReplies(): void;