@rapidmx/web-client 0.14.0 → 0.15.1

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 (171) hide show
  1. package/apps/admin/domains/[uid].tsx +3 -2
  2. package/apps/shared/appearance/AppearanceForm.tsx +9 -1
  3. package/apps/shared/calendar/bookingPlugin.ts +100 -0
  4. package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
  5. package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
  6. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
  7. package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
  8. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
  9. package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
  10. package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
  11. package/apps/shared/components/calendar/EventDetails.tsx +401 -0
  12. package/apps/shared/components/calendar/EventEditor.tsx +640 -0
  13. package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
  14. package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
  15. package/apps/shared/components/calendar/EventModal.tsx +126 -862
  16. package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
  17. package/apps/shared/components/calendar/EventShell.tsx +202 -0
  18. package/apps/shared/components/calendar/FindATime.tsx +379 -0
  19. package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
  20. package/apps/shared/components/calendar/MonthView.tsx +15 -3
  21. package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
  22. package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
  23. package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
  24. package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
  25. package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
  26. package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
  27. package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
  28. package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
  29. package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
  30. package/apps/shared/components/calendar/eventForm.ts +108 -0
  31. package/apps/shared/components/calendar/eventFormat.ts +183 -0
  32. package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
  33. package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
  34. package/apps/shared/components/layout/UserMenu.tsx +80 -28
  35. package/apps/shared/components/mail/ConversationList.tsx +100 -48
  36. package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
  37. package/apps/shared/components/mail/InviteCard.tsx +387 -0
  38. package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
  39. package/apps/shared/components/mail/SwipeRow.tsx +120 -0
  40. package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
  41. package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
  42. package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
  43. package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
  44. package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
  45. package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
  46. package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
  47. package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
  48. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  49. package/apps/shared/gestures/useEnterSlide.ts +54 -0
  50. package/apps/shared/gestures/useSwipe.ts +114 -0
  51. package/apps/shared/gestures/useSwipeSlide.ts +113 -0
  52. package/apps/shared/gestures/useWheelPaging.ts +107 -0
  53. package/apps/shared/mail/newMailNotifications.ts +171 -183
  54. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  55. package/apps/shared/notifications/preferences.ts +36 -0
  56. package/apps/shared/notifications/store.ts +560 -551
  57. package/apps/www/_routes.ts +1 -0
  58. package/apps/www/calendar/index.tsx +139 -37
  59. package/apps/www/index.tsx +273 -126
  60. package/apps/www/messages/[uid].tsx +31 -4
  61. package/apps/www/settings/profile/index.tsx +248 -0
  62. package/dist/apps/admin/domains/[uid].js +4 -2
  63. package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
  64. package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
  65. package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
  66. package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
  67. package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
  68. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  69. package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
  70. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
  71. package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
  72. package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
  73. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
  74. package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
  75. package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
  76. package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
  77. package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
  78. package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
  79. package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
  80. package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
  81. package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
  82. package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
  83. package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
  84. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
  85. package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
  86. package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
  87. package/dist/apps/shared/components/calendar/EventModal.js +57 -455
  88. package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
  89. package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
  90. package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
  91. package/dist/apps/shared/components/calendar/EventShell.js +134 -0
  92. package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
  93. package/dist/apps/shared/components/calendar/FindATime.js +190 -0
  94. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
  95. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
  96. package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
  97. package/dist/apps/shared/components/calendar/MonthView.js +11 -5
  98. package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
  99. package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
  100. package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
  101. package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
  102. package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
  103. package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
  104. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
  105. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
  106. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
  107. package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
  108. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
  109. package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
  110. package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
  111. package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
  112. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
  113. package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
  114. package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
  115. package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
  116. package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
  117. package/dist/apps/shared/components/calendar/eventForm.js +1 -0
  118. package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
  119. package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
  120. package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
  121. package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
  122. package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
  123. package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
  124. package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
  125. package/dist/apps/shared/components/layout/UserMenu.js +70 -23
  126. package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
  127. package/dist/apps/shared/components/mail/ConversationList.js +21 -4
  128. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  129. package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
  130. package/dist/apps/shared/components/mail/InviteCard.js +150 -0
  131. package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
  132. package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
  133. package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
  134. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
  135. package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
  136. package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
  137. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
  138. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
  139. package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
  140. package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
  141. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
  142. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
  143. package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
  144. package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
  145. package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
  146. package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
  147. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
  148. package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
  149. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  150. package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
  151. package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
  152. package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
  153. package/dist/apps/shared/gestures/useSwipe.js +90 -0
  154. package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
  155. package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
  156. package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
  157. package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
  158. package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
  159. package/dist/apps/shared/mail/newMailNotifications.js +3 -12
  160. package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
  161. package/dist/apps/shared/notifications/preferences.d.ts +13 -0
  162. package/dist/apps/shared/notifications/preferences.js +36 -0
  163. package/dist/apps/shared/notifications/store.d.ts +1 -0
  164. package/dist/apps/shared/notifications/store.js +7 -23
  165. package/dist/apps/www/_routes.js +1 -0
  166. package/dist/apps/www/calendar/index.js +76 -6
  167. package/dist/apps/www/index.js +140 -39
  168. package/dist/apps/www/messages/[uid].js +13 -5
  169. package/dist/apps/www/settings/profile/index.d.ts +4 -0
  170. package/dist/apps/www/settings/profile/index.js +124 -0
  171. package/package.json +3 -3
@@ -8,6 +8,7 @@ import { useNavigate } from "../shared/navigation/AppRouter.js";
8
8
  import { whenIdle } from "../shared/navigation/idle.js";
9
9
  import { prefetchComposeWindow } from "../shared/components/mail/compose/ComposeContext.js";
10
10
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
11
+ import { createPortal } from "react-dom";
11
12
  import { HiOutlineFlag, HiOutlineLockClosed, HiOutlinePaperClip } from "react-icons/hi2";
12
13
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
13
14
  import { archiveMessage, bulkUpdateMessages, createFolder, getMessage, getMessageRawContent, listFolders, listMessages, moveMessages, setMessagesFlagged, } from "@rapidmx/react-shared/mail/mailApi.js";
@@ -30,6 +31,9 @@ import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
30
31
  import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "../shared/components/mail/unreadStyle.js";
31
32
  import { LazyConversationThreadPane, LazyMessageDetailPane, prefetchReadingPane } from "../shared/components/mail/LazyReadingPane.js";
32
33
  import ConversationList from "../shared/components/mail/ConversationList.js";
34
+ import SwipeRow from "../shared/components/mail/SwipeRow.js";
35
+ import InviteRowChip from "../shared/components/mail/invite/InviteRowChip.js";
36
+ import MoveToFolderDialog from "../shared/components/mail/MoveToFolderDialog.js";
33
37
  import { EncryptedPreview } from "../shared/components/mail/reading/EncryptedPreview.js";
34
38
  import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
35
39
  import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
@@ -366,11 +370,26 @@ async function fetchAggregateMessages(mailboxFolders, type, filter) {
366
370
  }));
367
371
  return mergeInboxMessages(perMailbox);
368
372
  }
373
+ /** Groups search results by the conversation each message belongs to, the groups in the order of their first result. */
374
+ function groupByConversation(results) {
375
+ const groups = new Map();
376
+ for (const message of results) {
377
+ const id = message.conversationId ?? message.uid;
378
+ const group = groups.get(id);
379
+ if (group) {
380
+ group.messages.push(message);
381
+ }
382
+ else {
383
+ groups.set(id, { id, messages: [message] });
384
+ }
385
+ }
386
+ return [...groups.values()];
387
+ }
369
388
  function InboxPage(props) {
370
389
  return (_jsx(MailShell, { ...props, children: _jsx(InboxContent, { userUid: props.userUid }) }));
371
390
  }
372
391
  function InboxContent({ userUid }) {
373
- const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange } = useMailShell();
392
+ const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot } = useMailShell();
374
393
  const isMobile = useIsMobile();
375
394
  const navigate = useNavigate();
376
395
  const { requestUnlock } = useUnlockPrompt();
@@ -480,7 +499,14 @@ function InboxContent({ userUid }) {
480
499
  // Search stays real-folder-only - Tier 1/2/3 are all deeply mailbox/folder-scoped, and extending them
481
500
  // to span an arbitrary number of mailboxes is out of scope for this pass (see the aggregate-fetch
482
501
  // branch below, which the search effect never reaches while `folderUid` is unset).
483
- const isSearching = !preferences.showAsConversations && searchQuery.length > 0 && !aggregateFolderType;
502
+ // Search works whichever way the list is arranged. Its results are messages, so while a query is held the list is fetched as
503
+ // messages (`asConversations` is about the list's data and is false then), and when the reader arranges by conversation they are
504
+ // shown grouped under their conversation - only the messages that match - with the whole thread in the reading pane
505
+ // (`threadPane`, which follows the arrangement the reader chose and not the query).
506
+ const isSearching = searchQuery.length > 0 && !aggregateFolderType;
507
+ const asConversations = preferences.showAsConversations && !isSearching;
508
+ const threadPane = preferences.showAsConversations;
509
+ const groupedResults = isSearching && preferences.showAsConversations;
484
510
  // Focused/Other is an Inbox-only concept (`FocusedInboxUtils.classifyMessage()` short-circuits to
485
511
  // Focused for every other folder), and
486
512
  // search results are ranked across folders rather than listed from one, so neither tab is offered
@@ -513,7 +539,7 @@ function InboxContent({ userUid }) {
513
539
  * shown: `GET /mail/messages/conversations` takes no sort parameters at all, so those rows are ordered
514
540
  * in the browser (`sortConversations()`) and rearranging them must not refetch the identical page -
515
541
  * which would also collapse whichever conversations the reader had expanded. */
516
- const serverSortKey = preferences.showAsConversations ? "" : `${preferences.sortBy}:${preferences.sortOrder}`;
542
+ const serverSortKey = asConversations ? "" : `${preferences.sortBy}:${preferences.sortOrder}`;
517
543
  /** The conversation list's own equivalent of `listParams` - a function because the page differs. */
518
544
  function conversationParams(page) {
519
545
  return { folderUid, filter: effectiveFilter, ...labelFilter, page, limit: MESSAGE_PAGE_SIZE };
@@ -653,7 +679,7 @@ function InboxContent({ userUid }) {
653
679
  // `activeMailboxUid`, and offering that mailbox's labels would let a label from the wrong mailbox be
654
680
  // applied. The conversation view stays scoped to `activeMailboxUid`, the mailbox its threads come from.
655
681
  const selectedMessageMailboxUid = messages.find((m) => m.uid === selectedUid)?.mailboxUid;
656
- const labelsMailboxUid = preferences.showAsConversations ? activeMailboxUid : (selectedMessageMailboxUid ?? activeMailboxUid);
682
+ const labelsMailboxUid = threadPane ? activeMailboxUid : (selectedMessageMailboxUid ?? activeMailboxUid);
657
683
  // Almost always the open mailbox's own labels, already fetched below - so `labels` reuses that list
658
684
  // rather than asking for the identical one a second time (which is what this page did on every single
659
685
  // view). Only a selected message from *another* mailbox - a search hit or an aggregate-view row - needs
@@ -719,7 +745,7 @@ function InboxContent({ userUid }) {
719
745
  const listKey = listSnapshotKey({
720
746
  mailboxUid: activeMailboxUid,
721
747
  folderUid,
722
- conversations: preferences.showAsConversations,
748
+ conversations: asConversations,
723
749
  filter: effectiveFilter,
724
750
  labels: labelFilterKey,
725
751
  sort: serverSortKey,
@@ -787,7 +813,7 @@ function InboxContent({ userUid }) {
787
813
  setLoading(true);
788
814
  return;
789
815
  }
790
- if (preferences.showAsConversations) {
816
+ if (asConversations) {
791
817
  // Conversations stay single-mailbox (not aggregated across mailboxes in this pass) - in
792
818
  // aggregate mode this falls back to `activeMailboxUid`, the same mailbox unlock/labels use
793
819
  // (always set - `MailShell` only renders this component once at least one mailbox exists).
@@ -986,7 +1012,7 @@ function InboxContent({ userUid }) {
986
1012
  // `refreshKey` is a dependency for the same reason: a failed bulk action bumps it to refetch,
987
1013
  // since a bulk update applies until its first rejection rather than all-or-nothing.
988
1014
  }, [
989
- preferences.showAsConversations,
1015
+ asConversations,
990
1016
  serverSortKey,
991
1017
  effectiveFilter,
992
1018
  labelFilterKey,
@@ -1019,7 +1045,7 @@ function InboxContent({ userUid }) {
1019
1045
  if (live.tick === 0 || isSearching || loading || loadMoreInFlightRef.current || (!folderUid && !aggregateFolderType)) {
1020
1046
  return;
1021
1047
  }
1022
- if (!preferences.showAsConversations && live.folderUids) {
1048
+ if (!asConversations && live.folderUids) {
1023
1049
  const shown = new Set(aggregateFolderType
1024
1050
  ? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
1025
1051
  : [folderUid]);
@@ -1033,7 +1059,7 @@ function InboxContent({ userUid }) {
1033
1059
  const isCurrent = () => searchRunIdRef.current === listRun && liveRunRef.current === myRun;
1034
1060
  void (async () => {
1035
1061
  try {
1036
- if (preferences.showAsConversations) {
1062
+ if (asConversations) {
1037
1063
  const fresh = await listConversations(activeMailboxUid, conversationParams(0));
1038
1064
  if (!isCurrent()) {
1039
1065
  return;
@@ -1082,12 +1108,12 @@ function InboxContent({ userUid }) {
1082
1108
  // Reads the ref, not `conversations`/`messages` state, so this callback (and the observer effect
1083
1109
  // watching it) don't need to be recreated every time a page lands - the same reason every other
1084
1110
  // check below reads a ref rather than closing over state.
1085
- const atRowCap = (preferences.showAsConversations ? conversationsRef.current.length : messagesRef.current.length) >= MAX_LOADED_ROWS;
1111
+ const atRowCap = (asConversations ? conversationsRef.current.length : messagesRef.current.length) >= MAX_LOADED_ROWS;
1086
1112
  if (loadMoreInFlightRef.current ||
1087
1113
  !hasMore ||
1088
1114
  loading ||
1089
1115
  atRowCap ||
1090
- (!preferences.showAsConversations && !folderUid) ||
1116
+ (!asConversations && !folderUid) ||
1091
1117
  (isSearching && !searchCursor)) {
1092
1118
  return;
1093
1119
  }
@@ -1119,7 +1145,7 @@ function InboxContent({ userUid }) {
1119
1145
  const myRunId = searchRunIdRef.current;
1120
1146
  const isCurrentRun = () => searchRunIdRef.current === myRunId;
1121
1147
  try {
1122
- if (preferences.showAsConversations) {
1148
+ if (asConversations) {
1123
1149
  const page = Math.floor(listedOffsetRef.current / MESSAGE_PAGE_SIZE);
1124
1150
  const more = await listConversations(activeMailboxUid, conversationParams(page));
1125
1151
  if (!isCurrentRun()) {
@@ -1198,7 +1224,7 @@ function InboxContent({ userUid }) {
1198
1224
  }, [
1199
1225
  hasMore,
1200
1226
  loading,
1201
- preferences.showAsConversations,
1227
+ asConversations,
1202
1228
  serverSortKey,
1203
1229
  effectiveFilter,
1204
1230
  labelFilterKey,
@@ -1277,7 +1303,7 @@ function InboxContent({ userUid }) {
1277
1303
  }
1278
1304
  // The conversation list opens a whole thread in `ConversationThreadPane`, which loads and marks read
1279
1305
  // its own messages; only the flat list feeds the single-message pane below.
1280
- const selected = preferences.showAsConversations ? null : (messages.find((m) => m.uid === selectedUid) ?? null);
1306
+ const selected = threadPane ? null : (messages.find((m) => m.uid === selectedUid) ?? null);
1281
1307
  const attachments = useMessageAttachments(selected);
1282
1308
  useMarkMessageRead(selected, patchListedMessage);
1283
1309
  // Search results can span every folder in the mailbox, not just the one selected in the sidebar - a
@@ -1286,7 +1312,7 @@ function InboxContent({ userUid }) {
1286
1312
  // this falls back to the sidebar selection unchanged). Aggregate views span every *mailbox* too, so
1287
1313
  // the folder list consulted is the selected message's own mailbox's, not the shell's ambient one. A
1288
1314
  // conversation spans folders for the same reason (an Inbox message and the Sent Items copy of its reply).
1289
- const spansFolders = isSearching || !!aggregateFolderType || preferences.showAsConversations;
1315
+ const spansFolders = isSearching || !!aggregateFolderType || threadPane;
1290
1316
  const selectedFolderUid = spansFolders ? (selected?.folderUid ?? folderUid) : folderUid;
1291
1317
  const selectedMailboxUid = spansFolders ? (selected?.mailboxUid ?? activeMailboxUid) : mailboxUid;
1292
1318
  const folders = mailboxFolders.find((mf) => mf.mailbox.uid === selectedMailboxUid)?.folders ?? [];
@@ -1305,12 +1331,12 @@ function InboxContent({ userUid }) {
1305
1331
  * Items copy of its reply), and the list only ever showed this folder's half of it, so a bulk action
1306
1332
  * from here must not reach into the other folders' copies either. */
1307
1333
  const selectedConversationMessages = [...selectedConversationIds].flatMap((id) => (conversationMessagesById[id] ?? []).filter((m) => !folderUid || m.folderUid === folderUid));
1308
- const selectedMessages = preferences.showAsConversations
1334
+ const selectedMessages = asConversations
1309
1335
  ? selectedConversationMessages
1310
1336
  : messages.filter((m) => selectedUids.has(m.uid));
1311
1337
  /** How many rows the list is actually showing - conversations or messages, whichever it lists. What the
1312
1338
  * Select toggle is enabled by: there is nothing to select in a list with no rows. */
1313
- const listedRowCount = preferences.showAsConversations ? conversations.length : messages.length;
1339
+ const listedRowCount = asConversations ? conversations.length : messages.length;
1314
1340
  /** The conversation rows in the order the reader arranged them. The endpoint takes no sort parameters
1315
1341
  * of its own (it pages by latest activity), so the arrangement is applied here, to the rows fetched so
1316
1342
  * far - which the Sort menu says on screen. `conversations` itself stays in the order the pages
@@ -1323,7 +1349,7 @@ function InboxContent({ userUid }) {
1323
1349
  * mailbox/search whose true size lands at or near the cap can have its very last page be a partial one,
1324
1350
  * which correctly turns `hasMore` false - without checking it here, the banner would still claim rows
1325
1351
  * are being hidden even though every one of them is already on screen. */
1326
- const rowCapReached = hasMore && (preferences.showAsConversations ? conversations.length : messages.length) >= MAX_LOADED_ROWS;
1352
+ const rowCapReached = hasMore && (asConversations ? conversations.length : messages.length) >= MAX_LOADED_ROWS;
1327
1353
  function leaveSelectMode() {
1328
1354
  setSelectMode(false);
1329
1355
  setSelectedUids(new Set());
@@ -1417,7 +1443,7 @@ function InboxContent({ userUid }) {
1417
1443
  }
1418
1444
  }
1419
1445
  }
1420
- if (preferences.showAsConversations) {
1446
+ if (asConversations) {
1421
1447
  // A conversation row is a summary of its messages - its count, unread count, participants
1422
1448
  // and preview all move when a bulk action changes or empties part of it - so the list is
1423
1449
  // reloaded rather than patched row by row.
@@ -1501,12 +1527,69 @@ function InboxContent({ userUid }) {
1501
1527
  function moveSelectionToType(type, name, chosen) {
1502
1528
  return runBulkAction(async (moving) => moveMessages(moving, await resolveFolderOfType(type, name)), true, chosen);
1503
1529
  }
1530
+ // ---- Swiping a row on a phone (see `SwipeRow`): right to left archives, left to right asks for a folder. Both go through the same
1531
+ // ---- bulk path as the selection bar's Archive and Move to, so the badges, the optimistic bookkeeping and the failure pop-up are one
1532
+ // ---- implementation. Not offered where the actions aren't (an aggregate view spans mailboxes, search results are a mixed bag, the
1533
+ // ---- Outbox is the server's send queue) or while rows are being ticked.
1534
+ const listingOutbox = currentFolders.find((f) => f.uid === folderUid)?.type === "outbox";
1535
+ const swipeEnabled = isMobile && !selectMode && !aggregateFolderType && !isSearching && !listingOutbox;
1536
+ /** What a swipe's Move to is about, while its folder prompt is open: one message, or every message of a conversation in this folder. */
1537
+ const [moveDialog, setMoveDialog] = useState(null);
1538
+ /** The messages a swiped conversation stands for (the ones in the folder being listed), or `null` after saying why they couldn't be loaded. */
1539
+ async function swipedConversationMessages(conversation) {
1540
+ try {
1541
+ return await loadOpenConversation(conversation);
1542
+ }
1543
+ catch (err) {
1544
+ notifyApiError(err, "Couldn't load the messages in that conversation");
1545
+ return null;
1546
+ }
1547
+ }
1548
+ /** Archives what a swipe took away. Resolves whether it went through, so the row comes back when it didn't. */
1549
+ async function swipeArchive(target) {
1550
+ if (bulkBusy || resolvingSelection > 0) {
1551
+ return false;
1552
+ }
1553
+ const chosen = Array.isArray(target) ? target : await swipedConversationMessages(target);
1554
+ if (!chosen || chosen.length === 0) {
1555
+ return false;
1556
+ }
1557
+ return runBulkAction(bulkArchive, true, chosen);
1558
+ }
1559
+ async function swipeMove(folderUidToMoveTo) {
1560
+ // Only ever asked by the folder prompt, which is only open while `moveDialog` is set.
1561
+ const target = moveDialog;
1562
+ const chosen = "messages" in target ? target.messages : await swipedConversationMessages(target.conversation);
1563
+ if (!chosen || chosen.length === 0) {
1564
+ setMoveDialog(null);
1565
+ return;
1566
+ }
1567
+ await runBulkAction((moving) => moveMessages(moving, folderUidToMoveTo), true, chosen);
1568
+ setMoveDialog(null);
1569
+ }
1504
1570
  const loadMoreStatus = loadingMore ? ("Loading more\u2026") : loadMoreError ? (_jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { role: "alert", className: "text-danger", children: loadMoreError }), _jsx("button", { type: "button", onClick: () => void loadMoreRef.current(), className: "text-primary-dark hover:underline font-medium", children: "Retry" })] })) : loadMoreStalled ? (_jsx("button", { type: "button", onClick: () => void loadMoreRef.current(), className: "text-primary-dark hover:underline font-medium", children: "Load more" })) : null;
1571
+ /** One message's row of the flat list - and, in search results grouped by conversation, of a group. */
1572
+ function renderMessageRow(message) {
1573
+ return (_jsxs(SwipeRow, { as: "li", enabled: swipeEnabled, onArchive: () => swipeArchive([message]), onMove: () => setMoveDialog({ messages: [message] }), "data-message-uid": message.uid, "data-unread": isUnread(message) ? "true" : undefined, className: rowClass({ unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: isUnread(message) }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: selectedUids.has(message.uid), onChange: () => toggleSelected(message.uid), "aria-label": `Select ${rowSubject(message)}`, className: "w-4 h-4 accent-primary" }) })), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: () => handleSelect(message), className: ["w-full text-left px-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: isUnread(message) }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(isUnread(message)) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(isUnread(message))].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), aggregateFolderType && (_jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName })), isSearching && pendingUids.has(message.uid) ? (_jsxs("div", { className: "flex flex-col gap-1.5 py-0.5", children: [_jsx(Skeleton, { height: "h-3.5", className: "w-2/3 rounded-sm" }), _jsx(Skeleton, { height: "h-3", className: "w-full rounded-sm" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: ["text-sm truncate", subjectClass(isUnread(message))].join(" "), children: rowSubject(message) }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: snippets[message.uid] ||
1574
+ decryptedRows[message.uid]?.preview ||
1575
+ message.bodyPreview ||
1576
+ (message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] }), isOutbox && _jsx(OutboxRowStatus, { message: message })] }))] }), _jsx(InviteRowChip, { message: message, onResponded: (updated) => patchListedMessage(updated) })] })] }, message.uid));
1577
+ }
1578
+ /** Search results arranged by conversation: the messages that matched, grouped under the conversation each belongs to, in the order of the
1579
+ * first match of each - the order the results are ranked in. `null` when the list is not showing grouped results. */
1580
+ const resultGroups = groupedResults ? groupByConversation(messages) : null;
1581
+ /** The search box: in the list on a desktop (flat lists only), in the shell's header beside the folders button on a phone. */
1582
+ const searchField = (_jsx("input", { ref: searchInputRef, type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…", "aria-label": "Search all mail", disabled: !!aggregateFolderType, className: ["w-full text-sm px-3 rounded-md border border-border bg-surface disabled:opacity-55", isMobile ? "py-2" : "py-1.5"].join(" ") }));
1505
1583
  function handleSelect(message) {
1506
1584
  if (selectMode) {
1507
1585
  toggleSelected(message.uid);
1508
1586
  return;
1509
1587
  }
1588
+ if (preferences.showAsConversations) {
1589
+ // A search result of a list arranged by conversation: the reading pane is the whole thread, opened at this message.
1590
+ openThreadOf(message);
1591
+ return;
1592
+ }
1510
1593
  if (isMobile) {
1511
1594
  navigate(`/messages/${encodeURIComponent(message.uid)}`);
1512
1595
  return;
@@ -1514,6 +1597,22 @@ function InboxContent({ userUid }) {
1514
1597
  removedAnchorRef.current = null;
1515
1598
  setSelectedUid(message.uid);
1516
1599
  }
1600
+ /** A message of a conversation, as the thread pane names it: the conversation's id (a message with no thread is its own) and the
1601
+ * subject to head it with while the thread loads. */
1602
+ function threadHeadOf(message) {
1603
+ return { conversationId: message.conversationId ?? message.uid, subject: message.subject ?? "", messageCount: 1 };
1604
+ }
1605
+ /** Opens the whole thread a message belongs to, positioned at it - the phone's message page, or the reading pane. */
1606
+ function openThreadOf(message) {
1607
+ const conversation = threadHeadOf(message);
1608
+ if (isMobile) {
1609
+ navigate(`/messages/${encodeURIComponent(message.uid)}?conversation=${encodeURIComponent(conversation.conversationId)}`);
1610
+ return;
1611
+ }
1612
+ removedAnchorRef.current = null;
1613
+ setSelectedUid(message.uid);
1614
+ setOpenThread({ conversation, uid: message.uid });
1615
+ }
1517
1616
  /** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
1518
1617
  * stands for, or the latest for a parent row. The thread pane loads the thread itself. */
1519
1618
  function handleOpenConversation(conversation, uid) {
@@ -1524,9 +1623,8 @@ function InboxContent({ userUid }) {
1524
1623
  return;
1525
1624
  }
1526
1625
  if (isMobile) {
1527
- // No dedicated mobile thread route yet - the existing single-message detail route already
1528
- // handles any message uid regardless of conversation grouping.
1529
- navigate(`/messages/${encodeURIComponent(uid)}`);
1626
+ // The message page shows the whole conversation, positioned at this message, when it is given the conversation.
1627
+ navigate(`/messages/${encodeURIComponent(uid)}?conversation=${encodeURIComponent(conversation.conversationId)}`);
1530
1628
  return;
1531
1629
  }
1532
1630
  removedAnchorRef.current = null;
@@ -1537,11 +1635,11 @@ function InboxContent({ userUid }) {
1537
1635
  // ---- the selection bar call: the same bulk-action path (`runBulkAction()` - so the same optimistic badge tracking, the same rollback,
1538
1636
  // ---- the same lazily created Deleted Items folder), never a second implementation. Reply, Reply all, Forward, Archive and Move to are
1539
1637
  // ---- registered by the reading pane itself (`MessageDetailPane`'s `shortcuts`), which owns those handlers.
1540
- const inConversations = preferences.showAsConversations;
1638
+ const inConversations = asConversations;
1541
1639
  /** The selection bar isn't offered in the aggregate view (its rows belong to several mailboxes), and neither are these. */
1542
1640
  const keyboardActions = !aggregateFolderType;
1543
1641
  /** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
1544
- const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : inConversations ? openThread !== null : selected !== null;
1642
+ const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
1545
1643
  const rowCount = inConversations ? listedConversations.length : messages.length;
1546
1644
  const canMoveSelection = !selectMode && !isMobile && !loading && rowCount > 0;
1547
1645
  function folderTypeOf(folderUidOfMessage) {
@@ -1611,7 +1709,7 @@ function InboxContent({ userUid }) {
1611
1709
  }
1612
1710
  let chosen;
1613
1711
  try {
1614
- chosen = selectMode ? selectedMessages : inConversations ? await loadOpenConversation(openThread.conversation) : [selected];
1712
+ chosen = selectMode ? selectedMessages : threadPane ? await loadOpenConversation(openThread.conversation) : [selected];
1615
1713
  }
1616
1714
  catch (err) {
1617
1715
  notifyApiError(err, "Couldn't load the messages in that conversation");
@@ -1622,7 +1720,7 @@ function InboxContent({ userUid }) {
1622
1720
  return;
1623
1721
  }
1624
1722
  const succeeded = await runBulkAction(action, removesRows, chosen);
1625
- if (succeeded && removesRows && inConversations && !selectMode) {
1723
+ if (succeeded && removesRows && threadPane && !selectMode) {
1626
1724
  setOpenThread(null);
1627
1725
  setSelectedUid(null);
1628
1726
  }
@@ -1661,7 +1759,7 @@ function InboxContent({ userUid }) {
1661
1759
  useShortcut(SHORTCUTS.mail.search, () => {
1662
1760
  searchInputRef.current?.focus();
1663
1761
  searchInputRef.current?.select();
1664
- }, { enabled: !inConversations && !aggregateFolderType });
1762
+ }, { enabled: !aggregateFolderType });
1665
1763
  if (!folderUid && !aggregateFolderType) {
1666
1764
  // `MailShell` never renders this component at all until a mailbox is resolved (see its own
1667
1765
  // full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
@@ -1673,28 +1771,28 @@ function InboxContent({ userUid }) {
1673
1771
  return (_jsxs("div", { className: "flex h-full min-h-0", children: [_jsxs("div", { ref: scrollContainerRef,
1674
1772
  // Remembered as it moves (not when the list is left: by then React has already detached the element), so
1675
1773
  // the folder - or Mail, after another app - comes back where it was.
1676
- onScroll: (event) => saveListScroll(listKey, event.currentTarget.scrollTop), className: "w-full md:w-96 shrink-0 md:border-r border-border overflow-y-auto", children: [selectMode ? (_jsx(MailSelectionBar, { selected: selectedMessages, listed: messages, totals: preferences.showAsConversations
1774
+ onScroll: (event) => saveListScroll(listKey, event.currentTarget.scrollTop), className: "w-full md:w-96 shrink-0 md:border-r border-border overflow-y-auto", children: [selectMode ? (_jsx(MailSelectionBar, { selected: selectedMessages, listed: messages, totals: asConversations
1677
1775
  ? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
1678
- : undefined, onSelectAll: () => preferences.showAsConversations
1776
+ : undefined, onSelectAll: () => asConversations
1679
1777
  ? selectAllConversations()
1680
- : setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () => preferences.showAsConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set()), onCancel: leaveSelectMode, folders: currentFolders, currentFolderUid: folderUid, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated, onApplyLabels: applyLabelsToSelection, onSetRead: (read) => void runBulkAction(
1778
+ : setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () => asConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set()), onCancel: leaveSelectMode, folders: currentFolders, currentFolderUid: folderUid, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated, onApplyLabels: applyLabelsToSelection, onSetRead: (read) => void runBulkAction(
1681
1779
  // Optimistic: the rows and the folder badges change now, and change back if the server refuses.
1682
1780
  (chosen) => setReadStateMany(chosen, read, { patch: patchListedMessage, track: trackMessageChange }), false), onSetFlagged: (flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged), false), shortcuts: keyboardActions, onArchive: () => void runBulkAction(bulkArchive, true), onMoveTo: async (targetFolderUid) => {
1683
1781
  await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid), true);
1684
1782
  }, onReportJunk: () => void moveSelectionToType("junk", "Junk Email"), onDelete: () => void moveSelectionToType("deleted_items", "Deleted Items"), busy: bulkBusy || resolvingSelection > 0 })) : (_jsx(MailListToolbar, { sortBy: preferences.sortBy, sortOrder: preferences.sortOrder, filter: preferences.filter, labelUids: preferences.labelUids, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onLabelUidsChange: (nextLabelUids) => updatePreferences({ labelUids: nextLabelUids }), showAsConversations: preferences.showAsConversations, onSortChange: (sortBy, sortOrder) => updatePreferences({ sortBy, sortOrder }), onFilterChange: (filter) => updatePreferences({ filter }), onShowAsConversationsChange: (showAsConversations) => updatePreferences({ showAsConversations }), selectMode: selectMode, onSelectModeChange: setSelectMode, offerClassificationFilters: offerClassificationFilters, filterDisabled: isSearching, filterDisabledReason: "Filters don't apply to search results", sortKeysDisabled: isSearching || !!aggregateFolderType,
1685
1783
  // A conversation row is a thread summary, so only some of the keys have anything to
1686
1784
  // order by - the rest stay pickable and are applied to the rows already fetched.
1687
- unavailableSortKeys: preferences.showAsConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined, sortKeysNote: isSearching
1785
+ unavailableSortKeys: asConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined, sortKeysNote: isSearching
1688
1786
  ? "Search results are ranked by relevance rather than sorted."
1689
1787
  : aggregateFolderType
1690
1788
  ? "This view merges the newest mail from every mailbox and is always listed by date."
1691
- : preferences.showAsConversations
1789
+ : asConversations
1692
1790
  ? CONVERSATION_SORT_NOTE
1693
1791
  : undefined, selectDisabled: !!aggregateFolderType || loading || listedRowCount === 0, selectDisabledReason: aggregateFolderType
1694
1792
  ? "Open a mailbox's own folder to select messages"
1695
1793
  : loading
1696
1794
  ? "Wait for this folder to finish loading"
1697
- : "There is nothing here to select" })), !preferences.showAsConversations && (_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { ref: searchInputRef, type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…", "aria-label": "Search all mail", disabled: !!aggregateFolderType, className: "w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface disabled:opacity-55" }) })), offerClassificationFilters && (_jsx("div", { className: "flex border-b border-border text-xs", children: MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (_jsx("button", { type: "button", "aria-pressed": preferences.filter === value, onClick: () => updatePreferences({ filter: value }), className: [
1795
+ : "There is nothing here to select" })), !isMobile && _jsx("div", { className: "p-2 border-b border-border", children: searchField }), isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot), offerClassificationFilters && (_jsx("div", { className: "flex border-b border-border text-xs", children: MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (_jsx("button", { type: "button", "aria-pressed": preferences.filter === value, onClick: () => updatePreferences({ filter: value }), className: [
1698
1796
  "flex-1 py-1.5 font-semibold",
1699
1797
  preferences.filter === value
1700
1798
  ? "text-primary-dark border-b-2 border-primary-dark"
@@ -1703,14 +1801,17 @@ function InboxContent({ userUid }) {
1703
1801
  ? `${messages.length} result${messages.length === 1 ? "" : "s"}`
1704
1802
  : `${messages.length} of ??` }), tier2Done && !searchAllMail && (_jsx("button", { type: "button", onClick: handleSearchAllMail, className: "text-primary-dark hover:underline font-medium shrink-0", children: "Search all mail" }))] })), isSearching && coverage?.indexedFrom && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["Local search covers messages back to ", new Date(coverage.indexedFrom).toLocaleDateString(), coverage.building ? " (still building)" : "", searchAllMail
1705
1803
  ? " - searching everything, not just recent mail."
1706
- : " - older encrypted mail is still searched, just slower."] })), isSearching && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching && !preferences.showAsConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockList, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to show ", undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'", " subject"] }) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), loading ? (_jsxs("div", { role: "status", "aria-busy": "true", className: "p-4", children: [_jsx("span", { className: "sr-only", children: "Loading\u2026" }), _jsx(SkeletonList, { count: 8 })] })) : preferences.showAsConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: toggleConversationSelected }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", MAX_LOADED_ROWS, " conversations \u2014 refine your search or filters to see more."] }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
1804
+ : " - older encrypted mail is still searched, just slower."] })), isSearching && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching && !asConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockList, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to show ", undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'", " subject"] }) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), loading ? (_jsxs("div", { role: "status", "aria-busy": "true", className: "p-4", children: [_jsx("span", { className: "sr-only", children: "Loading\u2026" }), _jsx(SkeletonList, { count: 8 })] })) : asConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onMeetingResponded: patchListedMessage, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: toggleConversationSelected, swipe: {
1805
+ enabled: swipeEnabled,
1806
+ onArchive: swipeArchive,
1807
+ onMove: (conversation) => setMoveDialog({ conversation }),
1808
+ } }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", MAX_LOADED_ROWS, " conversations \u2014 refine your search or filters to see more."] }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
1707
1809
  ? `No messages match "${searchQuery}".`
1708
1810
  : effectiveFilter === "all"
1709
1811
  ? "No messages in this folder."
1710
- : "No messages here." }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : (_jsxs(_Fragment, { children: [_jsx("ul", { children: messages.map((message) => (_jsxs("li", { "data-message-uid": message.uid, "data-unread": isUnread(message) ? "true" : undefined, className: rowClass({ unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: isUnread(message) }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: selectedUids.has(message.uid), onChange: () => toggleSelected(message.uid), "aria-label": `Select ${rowSubject(message)}`, className: "w-4 h-4 accent-primary" }) })), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => handleSelect(message), className: ["flex-1 min-w-0 text-left px-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: isUnread(message) }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(isUnread(message)) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(isUnread(message))].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), aggregateFolderType && (_jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName })), isSearching && pendingUids.has(message.uid) ? (_jsxs("div", { className: "flex flex-col gap-1.5 py-0.5", children: [_jsx(Skeleton, { height: "h-3.5", className: "w-2/3 rounded-sm" }), _jsx(Skeleton, { height: "h-3", className: "w-full rounded-sm" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: ["text-sm truncate", subjectClass(isUnread(message))].join(" "), children: rowSubject(message) }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: snippets[message.uid] ||
1711
- decryptedRows[message.uid]?.preview ||
1712
- message.bodyPreview ||
1713
- (message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] }), isOutbox && _jsx(OutboxRowStatus, { message: message })] }))] })] }, message.uid))) }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", MAX_LOADED_ROWS, " messages \u2014 refine your search or filters to see more."] })), aggregateFolderType && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0 min-h-0", children: preferences.showAsConversations ? (_jsx(LazyConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: activeMailboxUid, folders: currentFolders, labels: mailboxLabels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(LazyMessageDetailPane, { shortcuts: !selectMode, message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
1812
+ : "No messages here." }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : (_jsxs(_Fragment, { children: [_jsx("ul", { className: swipeEnabled ? "overflow-x-clip" : undefined, children: resultGroups
1813
+ ? resultGroups.map((group) => (_jsxs("li", { "data-search-group": group.id, children: [_jsxs("div", { className: "px-4 py-1.5 bg-surface-alt border-b border-border text-xs text-text-muted flex items-center justify-between gap-2", children: [_jsx("span", { className: "truncate font-semibold", children: rowSubject(group.messages[0]) }), _jsxs("span", { className: "shrink-0", children: [group.messages.length, " matching message", group.messages.length === 1 ? "" : "s"] })] }), _jsx("ul", { children: group.messages.map(renderMessageRow) })] }, group.id)))
1814
+ : messages.map(renderMessageRow) }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", MAX_LOADED_ROWS, " messages \u2014 refine your search or filters to see more."] })), aggregateFolderType && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0 min-h-0", children: threadPane ? (_jsx(LazyConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: activeMailboxUid, folders: currentFolders, labels: mailboxLabels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(LazyMessageDetailPane, { shortcuts: !selectMode, message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
1714
1815
  // Same reasoning as onArchived below - a move takes the message out of the folder
1715
1816
  // being listed, so it leaves the list rather than being patched in place.
1716
1817
  removeListedMessage(updated.uid);
@@ -1724,6 +1825,6 @@ function InboxContent({ userUid }) {
1724
1825
  // whichever folder is currently being viewed (into Archive), so it's removed
1725
1826
  // from the list rather than patched in place.
1726
1827
  removeListedMessage(updated.uid);
1727
- }, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) })] }));
1828
+ }, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) }), _jsx(MoveToFolderDialog, { open: moveDialog !== null, onClose: () => setMoveDialog(null), mailboxUid: activeMailboxUid, folders: currentFolders, currentFolderUid: folderUid, count: moveDialog === null ? 1 : "messages" in moveDialog ? moveDialog.messages.length : moveDialog.conversation.messageCount, onMove: swipeMove, onFolderCreated: onFolderCreated })] }));
1728
1829
  }
1729
1830
  export default routedPage("/", InboxPage);
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
@@ -11,12 +11,15 @@ import { listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
11
11
  import { useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
12
12
  import { useMarkMessageRead } from "../../shared/mail/useMarkMessageRead.js";
13
13
  import MailShell, { useMailShell } from "../../shared/components/mail/layout/MailShell.js";
14
- import { LazyMessageDetailPane } from "../../shared/components/mail/LazyReadingPane.js";
14
+ import { LazyConversationThreadPane, LazyMessageDetailPane } from "../../shared/components/mail/LazyReadingPane.js";
15
+ import { useLocationSearch } from "../../shared/navigation/AppRouter.js";
15
16
  import { ReadingPaneSkeleton } from "../../shared/components/mail/reading/MessageCard.js";
16
17
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
17
18
  /**
18
19
  * Only reached on mobile (below the `md` breakpoint) — desktop's `apps/www/index.tsx` keeps its existing
19
- * inline reading pane and never navigates here; see that file's `handleSelect`.
20
+ * inline reading pane and never navigates here; see that file's `handleSelect`. A row of the conversation list
21
+ * comes with `?conversation=<id>` (`handleOpenConversation`), and then this page is the whole thread, opened at this
22
+ * message, as the desktop's reading pane is; without it (a flat list, a link to a message) it is that one message.
20
23
  */
21
24
  function MessageDetailPage(props) {
22
25
  return (_jsx(MailShell, { ...props, children: _jsx(MessageDetailContent, { uid: props.params.uid }) }));
@@ -27,6 +30,7 @@ function MessageDetailContent({ uid }) {
27
30
  const [loading, setLoading] = useState(true);
28
31
  const [error, setError] = useState(null);
29
32
  const [labels, setLabels] = useState([]);
33
+ const conversationId = new URLSearchParams(useLocationSearch()).get("conversation");
30
34
  useEffect(() => {
31
35
  setLoading(true);
32
36
  setError(null);
@@ -55,8 +59,9 @@ function MessageDetailContent({ uid }) {
55
59
  cancelled = true;
56
60
  };
57
61
  }, [messageMailboxUid]);
58
- const attachments = useMessageAttachments(message);
59
- useMarkMessageRead(message, setMessage);
62
+ // A thread loads, marks read and fetches the attachments of each message it opens itself.
63
+ const attachments = useMessageAttachments(conversationId ? null : message);
64
+ useMarkMessageRead(conversationId ? null : message, setMessage);
60
65
  if (loading) {
61
66
  // The pane's own frame - a header card and a message card - rather than a line of text: what is coming is known, only not yet its contents.
62
67
  return _jsx(ReadingPaneSkeleton, {});
@@ -72,6 +77,9 @@ function MessageDetailContent({ uid }) {
72
77
  const isSentItems = folders.find((f) => f.uid === message.folderUid)?.type === "sent_items";
73
78
  const isOutbox = folders.find((f) => f.uid === message.folderUid)?.type === "outbox";
74
79
  const draftsFolderUid = folders.find((f) => f.type === "drafts")?.uid;
80
+ if (conversationId) {
81
+ return (_jsxs("div", { className: "flex flex-col h-full min-h-0", children: [_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" }), _jsx(LazyConversationThreadPane, { conversation: { conversationId, subject: message.subject, messageCount: 1 }, selectedUid: message.uid, mailboxUid: message.mailboxUid, folders: folders, labels: labels, shortcuts: true, onMessagePatched: () => undefined, onMessageRemoved: () => undefined, onLabelCreated: (label) => setLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })] }));
82
+ }
75
83
  return (_jsx(LazyMessageDetailPane, { shortcuts: true, message: message, attachments: attachments, backHref: backHref, isSentItems: isSentItems, onRecalled: setMessage, isOutbox: isOutbox, onReceiptHandled: setMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: setMessage, onFolderCreated: onFolderCreated, onScheduledSendCanceled: setMessage, onArchived: setMessage, labels: labels, onLabelsChanged: setMessage, onLabelCreated: (label) => setLabels((prev) => [...prev, label]) }));
76
84
  }
77
85
  export default routedPage("/messages/:uid", MessageDetailPage);
@@ -0,0 +1,4 @@
1
+ import { SettingsShellProps } from "../../../shared/components/settings/layout/SettingsShell.js";
2
+ export type SettingsProfilePageProps = Omit<SettingsShellProps, "active">;
3
+ declare const _default: import("../../../shared/navigation/AppRouter.js").RoutedPageComponent<SettingsProfilePageProps>;
4
+ export default _default;