@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
@@ -4,6 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
7
+ import { flushSync } from "react-dom";
7
8
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
9
  import { listAttachments } from "@rapidmx/react-shared/mail/mailApi.js";
9
10
  import { listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
@@ -11,8 +12,10 @@ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
12
  import MessageDetailPane from "./MessageDetailPane.js";
12
13
  import { EncryptedPreview, displaySubject } from "./reading/EncryptedPreview.js";
13
14
  import { CollapsedCard, SkeletonCards, SubjectCard } from "./reading/MessageCard.js";
15
+ import PendingMessageCard from "./reading/PendingMessageCard.js";
14
16
  import { useMailShell } from "./layout/MailShell.js";
15
17
  import { setReadState } from "../../mail/messageReadState.js";
18
+ import { adoptedOutgoing, belongsToThread, settleOutgoing, useOutgoingReplies } from "../../mail/outbox/outgoingReplies.js";
16
19
  import { dateClass, isUnread, senderClass } from "./unreadStyle.js";
17
20
  /** One request's worth of the thread. The server's own default for `listConversationMessages()`. */
18
21
  export const THREAD_PAGE_SIZE = 100;
@@ -77,6 +80,20 @@ async function loadThread(mailboxUid, conversationId) {
77
80
  messages.reverse();
78
81
  return { messages, truncated: more };
79
82
  }
83
+ /**
84
+ * `next` - the thread as it was just read again - for the pane to show in place of `previous`. A message the pane holds a newer copy of (one that
85
+ * was read, flagged or labelled here a moment ago, whose change the read may have started before) keeps that copy, and a read that changed
86
+ * nothing gives back `previous` itself, so the pane is not drawn again.
87
+ */
88
+ function mergeThread(previous, next) {
89
+ const held = new Map(previous.map((message) => [message.uid, message]));
90
+ const merged = next.map((message) => {
91
+ const own = held.get(message.uid);
92
+ return own && own.version > message.version ? own : message;
93
+ });
94
+ const unchanged = merged.length === previous.length && merged.every((message, index) => message.uid === previous[index].uid && message.version === previous[index].version);
95
+ return unchanged ? previous : merged;
96
+ }
80
97
  /** Whether the browser is showing a focus ring on `element` - it was focused from the keyboard (or by a key press's handler), not by a click. */
81
98
  function hasFocusRing(element) {
82
99
  try {
@@ -105,7 +122,9 @@ function hasFocusRing(element) {
105
122
  * that - mounting a body iframe per message up front would be wasteful in a long thread.
106
123
  */
107
124
  export default function ConversationThreadPane({ conversation, mailboxUid, selectedUid, folders, labels, onMessagePatched, onMessageRemoved, onLabelCreated, onFolderCreated, shortcuts, }) {
108
- const { trackMessageChange } = useMailShell();
125
+ const { trackMessageChange, live } = useMailShell();
126
+ // The replies and forwards this tab has sent, drawn at the top of the thread they continue until the server's own copy is in it.
127
+ const outgoing = useOutgoingReplies();
109
128
  const [messages, setMessages] = useState([]);
110
129
  const [attachmentsByUid, setAttachmentsByUid] = useState({});
111
130
  const [expandedUids, setExpandedUids] = useState(new Set());
@@ -134,11 +153,19 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
134
153
  /** The message whose header button had the focus when it was toggled: expanding or collapsing swaps that button for the other state's, so the
135
154
  * focus is put back on the new one. */
136
155
  const refocusRef = useRef(null);
156
+ /** The pending cards that have already been scrolled to, so that only a message that has just been sent takes the view and the focus. */
157
+ const announcedRef = useRef(new Set());
158
+ /** Messages that left the thread here (archived, moved, a scheduled send taken back): a read of the thread again still lists them, since a conversation spans folders. */
159
+ const removedRef = useRef(new Set());
160
+ /** The last live update this pane has answered - one already in the shell's hands when the pane opened is not news. */
161
+ const seenLiveRef = useRef(live);
137
162
  const conversationId = conversation?.conversationId;
138
163
  useEffect(() => {
139
164
  const generation = ++generationRef.current;
140
165
  markReadRequestedRef.current = new Set();
141
166
  attachmentsRequestedRef.current = new Set();
167
+ announcedRef.current = new Set();
168
+ removedRef.current = new Set();
142
169
  appliedSelectionRef.current = null;
143
170
  loadedIdRef.current = null;
144
171
  setMessages([]);
@@ -159,6 +186,8 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
159
186
  if (generation !== generationRef.current)
160
187
  return;
161
188
  loadedIdRef.current = conversationId;
189
+ // A message sent from here whose Sent Items copy is already in the thread is drawn as that copy, not as a pending card.
190
+ settleOutgoing(mailboxUid, loaded.messages);
162
191
  setMessages(loaded.messages);
163
192
  setTruncated(loaded.truncated);
164
193
  })
@@ -283,6 +312,77 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
283
312
  }
284
313
  }
285
314
  }, [expandedUids, messages]);
315
+ /**
316
+ * Reads the thread again, quietly: what is on screen stays until the answer is here (a read that fails changes nothing), a message that arrived
317
+ * is added at the top, and the reader's expanded and collapsed cards are left as they are. A message this tab sent - which the server has
318
+ * now filed in Sent Items under the same `uid` - takes the place of its pending card, expanded as that card was, with the focus if the
319
+ * card had it. A thread that has not finished its first load is not read again: that load is the fresh read.
320
+ */
321
+ function refreshThread() {
322
+ const id = loadedIdRef.current;
323
+ if (!id) {
324
+ return;
325
+ }
326
+ const generation = generationRef.current;
327
+ loadThread(mailboxUid, id).then((loaded) => {
328
+ if (generation !== generationRef.current)
329
+ return;
330
+ const current = loaded.messages.filter((message) => !removedRef.current.has(message.uid));
331
+ const adopted = adoptedOutgoing(mailboxUid, current);
332
+ const refocus = adopted.find((uid) => document.activeElement === headerRefs.current[uid]);
333
+ // Drawn first, and only then is the pending card forgotten: there is never a frame with neither it nor the real message.
334
+ flushSync(() => {
335
+ setMessages((previous) => mergeThread(previous, current));
336
+ setTruncated(loaded.truncated);
337
+ if (adopted.length > 0) {
338
+ setExpandedUids((previous) => new Set([...previous, ...adopted]));
339
+ }
340
+ if (refocus) {
341
+ setPendingFocusUid(refocus);
342
+ }
343
+ });
344
+ settleOutgoing(mailboxUid, current);
345
+ },
346
+ // Quietly: the next live update reads it again, and what is shown is still right.
347
+ () => undefined);
348
+ }
349
+ // Another message of this conversation may have arrived - a recipient's reply, one sent from another tab or device, or this tab's own reply
350
+ // filed in Sent Items - whenever the shell announces a live update that touched one of this mailbox's folders (or does not say which).
351
+ useEffect(() => {
352
+ if (live === seenLiveRef.current)
353
+ return;
354
+ seenLiveRef.current = live;
355
+ if (live.folderUids === null || folders.some((folder) => live.folderUids.has(folder.uid))) {
356
+ refreshThread();
357
+ }
358
+ }, [live]);
359
+ // The server has relayed a message this tab sent: its Sent Items copy is read for at once, without waiting for the live update that follows.
360
+ const sentKey = outgoing
361
+ .filter((reply) => reply.state === "sent" && reply.mailboxUid === mailboxUid)
362
+ .map((reply) => reply.uid)
363
+ .join(",");
364
+ useEffect(() => {
365
+ if (sentKey) {
366
+ refreshThread();
367
+ }
368
+ }, [sentKey]);
369
+ // The messages sent from here that continue this thread and whose real copy it does not hold yet, newest first (the pane's order). A message
370
+ // that replies to nothing here - a new message, or a reply to another conversation - is not in this list, and never is.
371
+ const pendingCards = outgoing
372
+ .filter((reply) => reply.mailboxUid === mailboxUid && !messages.some((message) => message.uid === reply.uid) && belongsToThread(reply, messages))
373
+ .reverse();
374
+ // A message that has just been sent is scrolled into view and takes the focus - the compose window it was sent from has just closed, and the focus
375
+ // with it - once. A failed one, or one that was already there when the thread was opened, is left where it is.
376
+ const pendingKey = pendingCards.map((reply) => reply.uid).join(",");
377
+ useEffect(() => {
378
+ const fresh = pendingCards.filter((reply) => reply.state === "sending" && !announcedRef.current.has(reply.uid));
379
+ if (fresh.length === 0)
380
+ return;
381
+ for (const reply of fresh) {
382
+ announcedRef.current.add(reply.uid);
383
+ }
384
+ setPendingFocusUid(fresh[0].uid);
385
+ }, [pendingKey]);
286
386
  /** A newer copy of one of the thread's messages, kept here and handed to the list - with the copy it
287
387
  * replaces where the caller was given one, so a conversation row can tell what actually changed. */
288
388
  function patchMessage(updated, previous) {
@@ -291,6 +391,7 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
291
391
  }
292
392
  /** A message that left the folder being listed - it leaves the thread too, as it left the list. */
293
393
  function removeMessage(updated) {
394
+ removedRef.current.add(updated.uid);
294
395
  setMessages((prev) => prev.filter((message) => message.uid !== updated.uid));
295
396
  onMessageRemoved(updated);
296
397
  }
@@ -329,26 +430,30 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
329
430
  : undefined
330
431
  : error
331
432
  ? undefined
332
- : `${messages.length} message${messages.length === 1 ? "" : "s"}`, children: truncated && !loading && !error && (_jsxs("p", { className: "text-xs text-text-muted mt-1", children: ["Only the oldest ", THREAD_MESSAGE_LIMIT, " messages of this conversation are shown here. The rest are still in the message list."] })) }), loading ? (_jsx(SkeletonCards, { messageCount: conversation.messageCount })) : error ? (_jsx("div", { className: "p-2 sm:p-3", children: _jsx(Alert, { children: error }) })) : (_jsx("ul", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: messages.map((message) => {
333
- const uid = message.uid;
334
- const expanded = expandedUids.has(uid);
335
- const bodyId = `thread-message-${uid}`;
336
- const messageUnread = isUnread(message);
337
- return (_jsx("li", { ref: (node) => {
338
- rowRefs.current[uid] = node;
339
- }, "data-unread": messageUnread ? "true" : undefined, children: expanded ? (_jsx(MessageDetailPane, { inThread: true, threadSubject: subject, threadHeader: {
340
- bodyId,
341
- unread: messageUnread,
342
- onToggle: () => toggleExpanded(uid),
343
- buttonRef: (node) => {
344
- headerRefs.current[uid] = node;
345
- },
346
- }, footer: uid === footerUid, shortcuts: !!shortcuts && uid === keyboardUid, message: message, attachments: attachmentsByUid[uid] ?? [], isSentItems: folderTypeOf(message) === "sent_items", isOutbox: folderTypeOf(message) === "outbox", draftsFolderUid: folders.find((folder) => folder.type === "drafts")?.uid, folders: folders, onMoved: removeMessage, onFolderCreated: onFolderCreated, onRecalled: patchMessage, onReceiptHandled: patchMessage, onScheduledSendCanceled: removeMessage, onArchived: removeMessage, labels: labels, onLabelsChanged: patchMessage, onLabelCreated: onLabelCreated })) : (_jsxs(_Fragment, { children: [_jsx(CollapsedCard, { from: message.from, date: new Date(message.receivedDate).toLocaleString(), preview: message.bodyPreview || (message.encrypted ? _jsx(EncryptedPreview, {}) : ""), unread: messageUnread, senderClassName: senderClass(messageUnread), dateClassName: dateClass(messageUnread), buttonRef: (node) => {
433
+ : `${messages.length + pendingCards.length} message${messages.length + pendingCards.length === 1 ? "" : "s"}`, children: truncated && !loading && !error && (_jsxs("p", { className: "text-xs text-text-muted mt-1", children: ["Only the oldest ", THREAD_MESSAGE_LIMIT, " messages of this conversation are shown here. The rest are still in the message list."] })) }), loading ? (_jsx(SkeletonCards, { messageCount: conversation.messageCount })) : error ? (_jsx("div", { className: "p-2 sm:p-3", children: _jsx(Alert, { children: error }) })) : (_jsxs("ul", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: [pendingCards.map((reply) => (_jsx("li", { ref: (node) => {
434
+ rowRefs.current[reply.uid] = node;
435
+ }, "data-outgoing": "true", children: _jsx(PendingMessageCard, { reply: reply, headerRef: (node) => {
436
+ headerRefs.current[reply.uid] = node;
437
+ } }) }, reply.uid))), messages.map((message) => {
438
+ const uid = message.uid;
439
+ const expanded = expandedUids.has(uid);
440
+ const bodyId = `thread-message-${uid}`;
441
+ const messageUnread = isUnread(message);
442
+ return (_jsx("li", { ref: (node) => {
443
+ rowRefs.current[uid] = node;
444
+ }, "data-unread": messageUnread ? "true" : undefined, children: expanded ? (_jsx(MessageDetailPane, { inThread: true, threadSubject: subject, threadHeader: {
445
+ bodyId,
446
+ unread: messageUnread,
447
+ onToggle: () => toggleExpanded(uid),
448
+ buttonRef: (node) => {
347
449
  headerRefs.current[uid] = node;
348
- }, buttonProps: {
349
- onClick: () => toggleExpanded(uid),
350
- "aria-expanded": false,
351
- "aria-controls": bodyId,
352
- } }), _jsx("div", { id: bodyId, hidden: true })] })) }, uid));
353
- }) }))] }));
450
+ },
451
+ }, footer: uid === footerUid, shortcuts: !!shortcuts && uid === keyboardUid, message: message, attachments: attachmentsByUid[uid] ?? [], isSentItems: folderTypeOf(message) === "sent_items", isOutbox: folderTypeOf(message) === "outbox", draftsFolderUid: folders.find((folder) => folder.type === "drafts")?.uid, folders: folders, onMoved: removeMessage, onFolderCreated: onFolderCreated, onRecalled: patchMessage, onReceiptHandled: patchMessage, onScheduledSendCanceled: removeMessage, onArchived: removeMessage, labels: labels, onLabelsChanged: patchMessage, onLabelCreated: onLabelCreated })) : (_jsxs(_Fragment, { children: [_jsx(CollapsedCard, { from: message.from, date: new Date(message.receivedDate).toLocaleString(), preview: message.bodyPreview || (message.encrypted ? _jsx(EncryptedPreview, {}) : ""), unread: messageUnread, senderClassName: senderClass(messageUnread), dateClassName: dateClass(messageUnread), buttonRef: (node) => {
452
+ headerRefs.current[uid] = node;
453
+ }, buttonProps: {
454
+ onClick: () => toggleExpanded(uid),
455
+ "aria-expanded": false,
456
+ "aria-controls": bodyId,
457
+ } }), _jsx("div", { id: bodyId, hidden: true })] })) }, uid));
458
+ })] }))] }));
354
459
  }
@@ -47,6 +47,11 @@ export interface MailSelectionBarProps {
47
47
  /** The keyboard acts on this selection (Delete, Ctrl+Q, Ctrl+U, Insert - registered by the page): Mark read, Mark unread, Flag and
48
48
  * Delete name their shortcut in the tooltip and `aria-keyshortcuts`. */
49
49
  shortcuts?: boolean;
50
+ /** Why Move to is unavailable, when the selection is spread over mailboxes and a folder can only belong to one (search results over several
51
+ * mailboxes). Shown as the button's tooltip. */
52
+ moveDisabledReason?: string;
53
+ /** The same for Apply label: a label belongs to one mailbox, and `labels` are one mailbox's. */
54
+ labelsDisabledReason?: string;
50
55
  }
51
56
  /**
52
57
  * The header that replaces the list's own toolbar while select mode is on: how many rows are selected,
@@ -58,4 +63,4 @@ export interface MailSelectionBarProps {
58
63
  * target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
59
64
  * of actions doesn't shift around between folders.
60
65
  */
61
- export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, shortcuts, }: MailSelectionBarProps): React.JSX.Element;
66
+ export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, shortcuts, moveDisabledReason, labelsDisabledReason, }: MailSelectionBarProps): React.JSX.Element;
@@ -22,7 +22,7 @@ function actionClassName() {
22
22
  * target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
23
23
  * of actions doesn't shift around between folders.
24
24
  */
25
- export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, shortcuts, }) {
25
+ export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, shortcuts, moveDisabledReason, labelsDisabledReason, }) {
26
26
  const env = useKeyEnvironment();
27
27
  /** `title` and `aria-keyshortcuts` for an action the keyboard also does; `reason` (why it is disabled) wins the tooltip. */
28
28
  const hint = (label, shortcut, reason) => shortcuts
@@ -49,7 +49,7 @@ export default function MailSelectionBar({ selected, listed, totals, onSelectAll
49
49
  // partially applied, and is left exactly as it is unless the reader touches that row.
50
50
  const appliedToAll = labels.filter((label) => !none && selected.every((m) => m.labelUids?.includes(label.uid)));
51
51
  const appliedToSome = labels.filter((label) => selected.some((m) => m.labelUids?.includes(label.uid)) && !appliedToAll.includes(label));
52
- return (_jsxs("div", { className: "border-b border-border", children: [_jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: mailboxUid, folders: folders, currentFolderUid: currentFolderUid, count: selected.length, onMove: onMoveTo, onFolderCreated: onFolderCreated }), _jsxs("div", { className: "flex items-center gap-2 px-3 py-1.5 bg-surface-alt", children: [_jsxs("span", { "aria-live": "polite", className: "text-sm font-semibold text-text", children: [selectedCount, totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : "", " selected"] }), _jsx("button", { type: "button", onClick: onSelectAll, disabled: allSelected || listedCount === 0, className: actionClassName(), children: "Select all" }), _jsx("button", { type: "button", onClick: onClearSelection, disabled: selectedCount === 0, className: actionClassName(), children: "Clear" }), _jsx("button", { type: "button", onClick: onCancel, className: `${actionClassName()} ml-auto`, children: "Cancel" })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-0.5 px-1.5 py-1", children: [_jsx("button", { type: "button", onClick: () => onSetRead(true), disabled: none || busy, className: actionClassName(), ...hint("Mark read", SHORTCUTS.mail.markRead), children: "Mark read" }), _jsx("button", { type: "button", onClick: () => onSetRead(false), disabled: none || busy, className: actionClassName(), ...hint("Mark unread", SHORTCUTS.mail.markUnread), children: "Mark unread" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(true), disabled: none || busy, className: actionClassName(), ...hint("Flag", SHORTCUTS.mail.flag), children: "Flag" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(false), disabled: none || busy, className: actionClassName(), children: "Unflag" }), _jsx("button", { type: "button", onClick: onArchive, disabled: none || busy || !!archiveReason, title: archiveReason, className: actionClassName(), children: "Archive" }), _jsx(LabelMenuButton, { "aria-label": "Apply label", label: "Apply label", className: "text-sm", labels: labels, mailboxUid: mailboxUid, onLabelCreated: onLabelCreated, applied: appliedToAll.map((label) => label.uid), partial: appliedToSome.map((label) => label.uid), onCommit: onApplyLabels, busy: busy, disabled: none || busy, note: selected.length === 1
52
+ return (_jsxs("div", { className: "border-b border-border", children: [_jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: mailboxUid, folders: folders, currentFolderUid: currentFolderUid, count: selected.length, onMove: onMoveTo, onFolderCreated: onFolderCreated }), _jsxs("div", { className: "flex items-center gap-2 px-3 py-1.5 bg-surface-alt", children: [_jsxs("span", { "aria-live": "polite", className: "text-sm font-semibold text-text", children: [selectedCount, totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : "", " selected"] }), _jsx("button", { type: "button", onClick: onSelectAll, disabled: allSelected || listedCount === 0, className: actionClassName(), children: "Select all" }), _jsx("button", { type: "button", onClick: onClearSelection, disabled: selectedCount === 0, className: actionClassName(), children: "Clear" }), _jsx("button", { type: "button", onClick: onCancel, className: `${actionClassName()} ml-auto`, children: "Cancel" })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-0.5 px-1.5 py-1", children: [_jsx("button", { type: "button", onClick: () => onSetRead(true), disabled: none || busy, className: actionClassName(), ...hint("Mark read", SHORTCUTS.mail.markRead), children: "Mark read" }), _jsx("button", { type: "button", onClick: () => onSetRead(false), disabled: none || busy, className: actionClassName(), ...hint("Mark unread", SHORTCUTS.mail.markUnread), children: "Mark unread" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(true), disabled: none || busy, className: actionClassName(), ...hint("Flag", SHORTCUTS.mail.flag), children: "Flag" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(false), disabled: none || busy, className: actionClassName(), children: "Unflag" }), _jsx("button", { type: "button", onClick: onArchive, disabled: none || busy || !!archiveReason, title: archiveReason, className: actionClassName(), children: "Archive" }), _jsx(LabelMenuButton, { "aria-label": "Apply label", label: "Apply label", className: "text-sm", labels: labels, mailboxUid: mailboxUid, onLabelCreated: onLabelCreated, applied: appliedToAll.map((label) => label.uid), partial: appliedToSome.map((label) => label.uid), onCommit: onApplyLabels, busy: busy, disabled: none || busy || !!labelsDisabledReason, title: labelsDisabledReason, note: selected.length === 1
53
53
  ? "Ticked labels are applied, unticked ones removed."
54
- : "Ticked labels are applied to every selected message and unticked ones removed from all of them; a dash means only some have that label, and leaving it alone keeps it that way.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }), _jsx("button", { type: "button", onClick: () => setMovePrompt(true), disabled: none || busy, className: actionClassName(), children: "Move to" }), _jsx("button", { type: "button", onClick: onReportJunk, disabled: none || busy || !!junkReason, title: junkReason, className: actionClassName(), children: "Report junk" }), _jsx("button", { type: "button", onClick: onDelete, disabled: none || busy || !!deleteReason, className: actionClassName(), ...hint("Delete", SHORTCUTS.mail.delete, deleteReason), children: "Delete" })] })] }));
54
+ : "Ticked labels are applied to every selected message and unticked ones removed from all of them; a dash means only some have that label, and leaving it alone keeps it that way.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }), _jsx("button", { type: "button", onClick: () => setMovePrompt(true), disabled: none || busy || !!moveDisabledReason, title: moveDisabledReason, className: actionClassName(), children: "Move to" }), _jsx("button", { type: "button", onClick: onReportJunk, disabled: none || busy || !!junkReason, title: junkReason, className: actionClassName(), children: "Report junk" }), _jsx("button", { type: "button", onClick: onDelete, disabled: none || busy || !!deleteReason, className: actionClassName(), ...hint("Delete", SHORTCUTS.mail.delete, deleteReason), children: "Delete" })] })] }));
55
55
  }
@@ -10,6 +10,7 @@ import { assembleDraft, attachmentContentUrl, createDraft, deleteMessage, getMai
10
10
  import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
11
11
  import { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
12
12
  import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
13
+ import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
13
14
  import { resolveRecipientEncryption } from "@rapidmx/react-shared/crypto/composeSecurity.js";
14
15
  import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
15
16
  import { findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
@@ -219,11 +220,13 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
219
220
  if (cancelled) {
220
221
  return;
221
222
  }
222
- setMailboxes(result);
223
+ // Listed with the caller's own (primary) mailbox first, and defaulted to below.
224
+ const ordered = orderMailboxes(result, userUid);
225
+ setMailboxes(ordered);
223
226
  // Never default to a mailbox already known to be view-only (see the effect below for one
224
227
  // that turns out to be).
225
- const candidates = result.filter((mb) => peekMailboxWritability(mb, userUid, trusted) !== false);
226
- setFromMailboxUid((current) => current ?? (candidates.find((mb) => mb.ownerUserUid === userUid) ?? candidates[0])?.uid);
228
+ const candidates = ordered.filter((mb) => peekMailboxWritability(mb, userUid, trusted) !== false);
229
+ setFromMailboxUid((current) => current ?? primaryMailboxUid(candidates, userUid));
227
230
  })
228
231
  .catch((err) => {
229
232
  // Only fatal when there's no mailbox to fall back on - a session that already names one just
@@ -481,7 +484,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
481
484
  if (!fromIsViewOnly || hasUploads) {
482
485
  return;
483
486
  }
484
- const ordered = [...mailboxes.filter((mb) => mb.ownerUserUid === userUid), ...mailboxes.filter((mb) => mb.ownerUserUid !== userUid)];
487
+ const ordered = orderMailboxes(mailboxes, userUid);
485
488
  const alternative = ordered.find((mb) => writability[mb.uid] === true) ?? ordered.find((mb) => writability[mb.uid] === undefined);
486
489
  if (alternative) {
487
490
  handleFromChange(alternative.uid);
@@ -685,6 +688,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
685
688
  encryptRequested: current.encryptRequested,
686
689
  forcePlaintext,
687
690
  saved: saveInFlightRef.current,
691
+ threading: session.threading,
688
692
  };
689
693
  if (request.to.length === 0) {
690
694
  setSendError("At least one recipient is required.");
@@ -22,6 +22,26 @@ export interface RecipientInputProps {
22
22
  disabled?: boolean;
23
23
  /** Focuses the input when it mounts - a new message starts in To. */
24
24
  autoFocus?: boolean;
25
+ /** Called whenever the field's entries change: every committed entry (chips - a picked suggestion, a finished or
26
+ * pasted one, or a typed one committed by Enter or leaving the field) and the text still being typed (`""` after a
27
+ * commit). Lets a field that keeps its entries as something other than text (attendees) read them without parsing
28
+ * `value` and guessing which part of it is still being typed. */
29
+ onEdit?: (chips: string[], pending: string) => void;
30
+ /** Text being typed already when the input mounts (the last recipient of `value`), so a field that is remounted
31
+ * (a popover growing into a form) keeps it in the input instead of turning it into a chip. */
32
+ initialPending?: string;
33
+ /** Chips this returns true for are not drawn (they stay in the value, and out of the suggestions): a field that
34
+ * lists some entries itself. Backspace in an empty input removes the last chip that is drawn. */
35
+ hideChip?: (chip: string) => boolean;
36
+ placeholder?: string;
37
+ /** The input's accessible name, for a field without a `<label htmlFor>`. */
38
+ ariaLabel?: string;
39
+ /** The chip list's accessible name; `${label} recipients` by default. */
40
+ listLabel?: string;
41
+ /** Classes for the field's outer element, after its own (a border, padding). */
42
+ className?: string;
43
+ /** The suggestions' `z-index`, for a field inside an overlay that stacks above the default's (`z-[60]`). */
44
+ dropdownZIndex?: number;
25
45
  }
26
46
  /**
27
47
  * A To/Cc/Bcc field: committed recipients as removable chips, and a WAI-ARIA combobox for the one being typed that
@@ -33,4 +53,4 @@ export interface RecipientInputProps {
33
53
  * least two characters, and a newer query aborts an older request. Arrow keys move through them, Enter or Tab picks
34
54
  * one (as `Name <address>`), Escape closes them, and Backspace in an empty input removes the last chip.
35
55
  */
36
- export default function RecipientInput({ id, label, value, onChange, onBlur, onCommit, mailboxUid, fetchSuggestions, debounceMs, disabled, autoFocus, }: RecipientInputProps): React.JSX.Element;
56
+ export default function RecipientInput({ id, label, value, onChange, onBlur, onCommit, mailboxUid, fetchSuggestions, debounceMs, disabled, autoFocus, onEdit, initialPending, hideChip, placeholder, ariaLabel, listLabel, className, dropdownZIndex, }: RecipientInputProps): React.JSX.Element;
@@ -41,8 +41,8 @@ function dropdownPosition(anchor) {
41
41
  * least two characters, and a newer query aborts an older request. Arrow keys move through them, Enter or Tab picks
42
42
  * one (as `Name <address>`), Escape closes them, and Backspace in an empty input removes the last chip.
43
43
  */
44
- export default function RecipientInput({ id, label, value, onChange, onBlur, onCommit, mailboxUid, fetchSuggestions = fetchRecipientSuggestions, debounceMs = RECIPIENT_SUGGESTION_DEBOUNCE_MS, disabled, autoFocus, }) {
45
- const [pending, setPending] = useState("");
44
+ export default function RecipientInput({ id, label, value, onChange, onBlur, onCommit, mailboxUid, fetchSuggestions = fetchRecipientSuggestions, debounceMs = RECIPIENT_SUGGESTION_DEBOUNCE_MS, disabled, autoFocus, onEdit, initialPending = "", hideChip, placeholder, ariaLabel, listLabel, className, dropdownZIndex, }) {
45
+ const [pending, setPending] = useState(initialPending);
46
46
  const [suggestions, setSuggestions] = useState([]);
47
47
  const [open, setOpen] = useState(false);
48
48
  const [activeIndex, setActiveIndex] = useState(0);
@@ -58,6 +58,8 @@ export default function RecipientInput({ id, label, value, onChange, onBlur, onC
58
58
  const chips = pendingActive ? tokens.slice(0, -1) : tokens;
59
59
  const inputValue = pendingActive ? pending : "";
60
60
  const query = pendingActive ? pendingText : "";
61
+ const drawn = chips.map((chip, index) => ({ chip, index })).filter(({ chip }) => !hideChip?.(chip));
62
+ const lastDrawn = drawn.length > 0 ? drawn[drawn.length - 1].index : -1;
61
63
  const listboxId = `${id}-suggestions`;
62
64
  const optionId = (index) => `${id}-suggestion-${index}`;
63
65
  useEffect(() => {
@@ -113,6 +115,7 @@ export default function RecipientInput({ id, label, value, onChange, onBlur, onC
113
115
  });
114
116
  function emit(nextChips, nextPending) {
115
117
  onChange([...nextChips, nextPending.trim()].filter((part) => part.length > 0).join(", "));
118
+ onEdit?.(nextChips, nextPending.trim());
116
119
  }
117
120
  function handleInputChange(e) {
118
121
  const { finished, rest } = splitTypedRecipients(e.target.value);
@@ -122,17 +125,22 @@ export default function RecipientInput({ id, label, value, onChange, onBlur, onC
122
125
  emit([...chips, ...finished], nextPending);
123
126
  }
124
127
  function commitPending() {
125
- const next = [...chips, pendingText].filter((part) => part.length > 0).join(", ");
128
+ const committed = [...chips, pendingText].filter((part) => part.length > 0);
126
129
  setPending("");
127
130
  setOpen(false);
128
- return next;
131
+ if (pendingText) {
132
+ onEdit?.(committed, "");
133
+ }
134
+ return committed.join(", ");
129
135
  }
130
136
  function select(suggestion) {
131
- const next = [...chips, formatRecipient({ address: suggestion.address, displayName: suggestion.displayName || undefined })].join(", ");
137
+ const committed = [...chips, formatRecipient({ address: suggestion.address, displayName: suggestion.displayName || undefined })];
138
+ const next = committed.join(", ");
132
139
  setPending("");
133
140
  setOpen(false);
134
141
  setSuggestions([]);
135
142
  onChange(next);
143
+ onEdit?.(committed, "");
136
144
  onCommit?.(next);
137
145
  }
138
146
  function removeChip(index) {
@@ -181,9 +189,9 @@ export default function RecipientInput({ id, label, value, onChange, onBlur, onC
181
189
  }
182
190
  return;
183
191
  case "Backspace":
184
- if (inputValue === "" && chips.length > 0) {
192
+ if (inputValue === "" && lastDrawn >= 0) {
185
193
  e.preventDefault();
186
- emit(chips.slice(0, -1), "");
194
+ emit(chips.filter((_, i) => i !== lastDrawn), "");
187
195
  }
188
196
  return;
189
197
  }
@@ -192,12 +200,12 @@ export default function RecipientInput({ id, label, value, onChange, onBlur, onC
192
200
  const next = commitPending();
193
201
  onBlur?.(next);
194
202
  }
195
- return (_jsxs("div", { ref: anchorRef, className: "flex-1 min-w-0 flex flex-wrap items-center gap-1", children: [chips.length > 0 && (_jsx("ul", { role: "list", "aria-label": `${label} recipients`, className: "contents", children: chips.map((chip, index) => {
203
+ return (_jsxs("div", { ref: anchorRef, className: `flex-1 min-w-0 flex flex-wrap items-center gap-1${className ? ` ${className}` : ""}`, children: [drawn.length > 0 && (_jsx("ul", { role: "list", "aria-label": listLabel ?? `${label} recipients`, className: "contents", children: drawn.map(({ chip, index }) => {
196
204
  const recipient = parseRecipient(chip);
197
205
  const valid = isValidRecipientAddress(recipient.address);
198
206
  const name = recipient.displayName || recipient.address;
199
207
  return (_jsxs("li", { title: chip, className: `flex items-center gap-1 max-w-full min-w-0 rounded-pill border pl-2 pr-0.5 py-px text-xs ${valid ? "bg-surface-alt border-border text-text" : "bg-danger-bg border-danger text-danger"}`, children: [_jsx("span", { className: "truncate", children: name }), !valid && _jsx("span", { className: "sr-only", children: " (not a valid email address)" }), _jsx("button", { type: "button", tabIndex: -1, "aria-label": `Remove ${recipient.address}`, disabled: disabled, onMouseDown: (e) => e.preventDefault(), onClick: () => removeChip(index), className: "shrink-0 w-4 h-4 flex items-center justify-center rounded-pill text-text-muted hover:bg-border hover:text-text", children: _jsx(HiOutlineXMark, { size: 10 }) })] }, `${index}-${chip}`));
200
- }) })), _jsx("input", { ref: inputRef, id: id, type: "text", role: "combobox", autoComplete: "off", "aria-autocomplete": "list", "aria-expanded": expanded, "aria-controls": listboxId, "aria-activedescendant": expanded ? optionId(active) : undefined, disabled: disabled, autoFocus: autoFocus, className: "flex-1 min-w-[6rem] text-sm bg-transparent outline-none py-0.5", value: inputValue, onChange: handleInputChange, onKeyDown: handleKeyDown, onBlur: handleBlur }), _jsx("span", { className: "sr-only", "aria-live": "polite", children: expanded ? `${visible.length} ${visible.length === 1 ? "suggestion" : "suggestions"}` : "" }), expanded &&
208
+ }) })), _jsx("input", { ref: inputRef, id: id, type: "text", role: "combobox", autoComplete: "off", "aria-autocomplete": "list", "aria-label": ariaLabel, placeholder: placeholder, "aria-expanded": expanded, "aria-controls": listboxId, "aria-activedescendant": expanded ? optionId(active) : undefined, disabled: disabled, autoFocus: autoFocus, className: "flex-1 min-w-[6rem] text-sm bg-transparent outline-none py-0.5", value: inputValue, onChange: handleInputChange, onKeyDown: handleKeyDown, onBlur: handleBlur }), _jsx("span", { className: "sr-only", "aria-live": "polite", children: expanded ? `${visible.length} ${visible.length === 1 ? "suggestion" : "suggestions"}` : "" }), expanded &&
201
209
  position &&
202
- createPortal(_jsx("ul", { id: listboxId, role: "listbox", "aria-label": `${label} suggestions`, onMouseDown: (e) => e.preventDefault(), style: { position: "fixed", left: position.left, width: position.width, top: position.top, bottom: position.bottom, maxHeight: position.maxHeight }, className: "z-[60] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1", children: visible.map((suggestion, index) => (_jsxs("li", { id: optionId(index), role: "option", "aria-selected": index === active, onMouseDown: (e) => e.preventDefault(), onMouseEnter: () => setActiveIndex(index), onClick: () => select(suggestion), className: `flex items-center gap-2 px-3 py-1.5 cursor-pointer ${index === active ? "bg-primary/10" : ""}`, children: [_jsxs("span", { className: "flex-1 min-w-0", children: [_jsx("span", { className: "block truncate text-sm text-text", children: suggestion.displayName || suggestion.address }), suggestion.displayName && _jsx("span", { className: "block truncate text-xs text-text-muted", children: suggestion.address })] }), _jsx("span", { className: "shrink-0 text-[11px] leading-4 px-1.5 rounded-pill bg-surface-alt text-text-muted", children: KIND_LABELS[suggestion.kind] ?? "Directory" })] }, `${index}-${suggestion.address}`))) }), document.body)] }));
210
+ createPortal(_jsx("ul", { id: listboxId, role: "listbox", "aria-label": `${label} suggestions`, onMouseDown: (e) => e.preventDefault(), style: { position: "fixed", left: position.left, width: position.width, top: position.top, bottom: position.bottom, maxHeight: position.maxHeight, zIndex: dropdownZIndex }, className: "z-[60] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1", children: visible.map((suggestion, index) => (_jsxs("li", { id: optionId(index), role: "option", "aria-selected": index === active, onMouseDown: (e) => e.preventDefault(), onMouseEnter: () => setActiveIndex(index), onClick: () => select(suggestion), className: `flex items-center gap-2 px-3 py-1.5 cursor-pointer ${index === active ? "bg-primary/10" : ""}`, children: [_jsxs("span", { className: "flex-1 min-w-0", children: [_jsx("span", { className: "block truncate text-sm text-text", children: suggestion.displayName || suggestion.address }), suggestion.displayName && _jsx("span", { className: "block truncate text-xs text-text-muted", children: suggestion.address })] }), _jsx("span", { className: "shrink-0 text-[11px] leading-4 px-1.5 rounded-pill bg-surface-alt text-text-muted", children: KIND_LABELS[suggestion.kind] ?? "Directory" })] }, `${index}-${suggestion.address}`))) }), document.body)] }));
203
211
  }
@@ -0,0 +1,6 @@
1
+ import React from "react";
2
+ import { FolderBadge } from "../../../mail/folderCounts.js";
3
+ /** A folder's badge: an accent pill with the unread count, or - for the folders that show how many they hold - plain muted text. */
4
+ export default function FolderBadgeChip({ badge }: {
5
+ badge: FolderBadge;
6
+ }): React.JSX.Element;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { badgeLabel } from "../../../mail/folderCounts.js";
3
+ /** A folder's badge: an accent pill with the unread count, or - for the folders that show how many they hold - plain muted text. */
4
+ export default function FolderBadgeChip({ badge }) {
5
+ return (_jsxs("span", { className: [
6
+ "text-xs rounded-pill py-0.5 px-1.5",
7
+ badge.kind === "unread" ? "font-bold bg-primary/15 text-primary-dark" : "font-medium text-text-muted",
8
+ ].join(" "), children: [_jsx("span", { "aria-hidden": "true", children: badge.value }), _jsxs("span", { className: "sr-only", children: [" ", badgeLabel(badge)] })] }));
9
+ }
@@ -83,5 +83,10 @@ export { MAILBOX_LIST_LIMIT };
83
83
  * behavior change from before this, and a multi-mailbox user sees every mailbox's folders (and the new
84
84
  * aggregate section) simultaneously, matching the shared-mailbox feature's own "display as a separate set
85
85
  * of folders" requirement.
86
+ *
87
+ * An address that names nothing opens "All Mailboxes > Inbox" when there is more than one mailbox (that is when the section exists), and
88
+ * the one mailbox's own Inbox otherwise; a `?mailboxUid=`, `?folderUid=` or `?aggregate=` always wins. Nothing is selected until the address
89
+ * has been read. What needs a single mailbox while an all-mailboxes view is open (Compose, the new-message shortcut, key unlocking) uses the
90
+ * primary mailbox.
86
91
  */
87
92
  export default function MailShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }: PropsWithChildren<MailShellProps>): React.JSX.Element;