@rapidmx/web-client 0.14.1 → 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 -866
  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 -459
  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 +2 -2
@@ -7,6 +7,7 @@ import { useNavigate } from "../shared/navigation/AppRouter.js";
7
7
  import { whenIdle } from "../shared/navigation/idle.js";
8
8
  import { prefetchComposeWindow } from "../shared/components/mail/compose/ComposeContext.js";
9
9
  import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
10
+ import { createPortal } from "react-dom";
10
11
  import { HiOutlineFlag, HiOutlineLockClosed, HiOutlinePaperClip } from "react-icons/hi2";
11
12
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
12
13
  import {
@@ -55,6 +56,10 @@ import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
55
56
  import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "../shared/components/mail/unreadStyle.js";
56
57
  import { LazyConversationThreadPane, LazyMessageDetailPane, prefetchReadingPane } from "../shared/components/mail/LazyReadingPane.js";
57
58
  import ConversationList from "../shared/components/mail/ConversationList.js";
59
+ import type { ConversationThreadHead } from "../shared/components/mail/ConversationThreadPane.js";
60
+ import SwipeRow from "../shared/components/mail/SwipeRow.js";
61
+ import InviteRowChip from "../shared/components/mail/invite/InviteRowChip.js";
62
+ import MoveToFolderDialog from "../shared/components/mail/MoveToFolderDialog.js";
58
63
  import { EncryptedPreview } from "../shared/components/mail/reading/EncryptedPreview.js";
59
64
  import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
60
65
  import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
@@ -473,6 +478,29 @@ async function fetchAggregateMessages(
473
478
  return mergeInboxMessages(perMailbox);
474
479
  }
475
480
 
481
+ /** One conversation's part of a set of search results. */
482
+ interface ResultGroup {
483
+ /** The conversation's id - a message that belongs to no thread is its own conversation. */
484
+ id: string;
485
+ /** Only the messages of the conversation that are among the results, in the order they were ranked. */
486
+ messages: Message[];
487
+ }
488
+
489
+ /** Groups search results by the conversation each message belongs to, the groups in the order of their first result. */
490
+ function groupByConversation(results: Message[]): ResultGroup[] {
491
+ const groups = new Map<string, ResultGroup>();
492
+ for (const message of results) {
493
+ const id = message.conversationId ?? message.uid;
494
+ const group = groups.get(id);
495
+ if (group) {
496
+ group.messages.push(message);
497
+ } else {
498
+ groups.set(id, { id, messages: [message] });
499
+ }
500
+ }
501
+ return [...groups.values()];
502
+ }
503
+
476
504
  function InboxPage(props: MailShellProps) {
477
505
  return (
478
506
  <MailShell {...props}>
@@ -482,7 +510,7 @@ function InboxPage(props: MailShellProps) {
482
510
  }
483
511
 
484
512
  function InboxContent({ userUid }: { userUid?: string }) {
485
- const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange } = useMailShell();
513
+ const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot } = useMailShell();
486
514
  const isMobile = useIsMobile();
487
515
  const navigate = useNavigate();
488
516
  const { requestUnlock } = useUnlockPrompt();
@@ -506,7 +534,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
506
534
  const [selectedUid, setSelectedUid] = useState<string | null>(null);
507
535
  // The thread the conversation list opened, and which of its messages was picked - the reading pane
508
536
  // shows the whole conversation, positioned at that message (see `ConversationThreadPane`).
509
- const [openThread, setOpenThread] = useState<{ conversation: ConversationSummary; uid: string } | null>(null);
537
+ const [openThread, setOpenThread] = useState<{ conversation: ConversationThreadHead; uid: string } | null>(null);
510
538
  // Messages the page has a newer copy of than `ConversationList` fetched (so far only the one the reading
511
539
  // pane just marked read), applied over its own child rows so they don't stay bold after being read.
512
540
  const [conversationPatches, setConversationPatches] = useState<Record<string, Message>>({});
@@ -592,7 +620,14 @@ function InboxContent({ userUid }: { userUid?: string }) {
592
620
  // Search stays real-folder-only - Tier 1/2/3 are all deeply mailbox/folder-scoped, and extending them
593
621
  // to span an arbitrary number of mailboxes is out of scope for this pass (see the aggregate-fetch
594
622
  // branch below, which the search effect never reaches while `folderUid` is unset).
595
- const isSearching = !preferences.showAsConversations && searchQuery.length > 0 && !aggregateFolderType;
623
+ // Search works whichever way the list is arranged. Its results are messages, so while a query is held the list is fetched as
624
+ // messages (`asConversations` is about the list's data and is false then), and when the reader arranges by conversation they are
625
+ // shown grouped under their conversation - only the messages that match - with the whole thread in the reading pane
626
+ // (`threadPane`, which follows the arrangement the reader chose and not the query).
627
+ const isSearching = searchQuery.length > 0 && !aggregateFolderType;
628
+ const asConversations = preferences.showAsConversations && !isSearching;
629
+ const threadPane = preferences.showAsConversations;
630
+ const groupedResults = isSearching && preferences.showAsConversations;
596
631
  // Focused/Other is an Inbox-only concept (`FocusedInboxUtils.classifyMessage()` short-circuits to
597
632
  // Focused for every other folder), and
598
633
  // search results are ranked across folders rather than listed from one, so neither tab is offered
@@ -626,7 +661,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
626
661
  * shown: `GET /mail/messages/conversations` takes no sort parameters at all, so those rows are ordered
627
662
  * in the browser (`sortConversations()`) and rearranging them must not refetch the identical page -
628
663
  * which would also collapse whichever conversations the reader had expanded. */
629
- const serverSortKey = preferences.showAsConversations ? "" : `${preferences.sortBy}:${preferences.sortOrder}`;
664
+ const serverSortKey = asConversations ? "" : `${preferences.sortBy}:${preferences.sortOrder}`;
630
665
  /** The conversation list's own equivalent of `listParams` - a function because the page differs. */
631
666
  function conversationParams(page: number): ConversationListParams {
632
667
  return { folderUid, filter: effectiveFilter, ...labelFilter, page, limit: MESSAGE_PAGE_SIZE };
@@ -779,7 +814,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
779
814
  // `activeMailboxUid`, and offering that mailbox's labels would let a label from the wrong mailbox be
780
815
  // applied. The conversation view stays scoped to `activeMailboxUid`, the mailbox its threads come from.
781
816
  const selectedMessageMailboxUid = messages.find((m) => m.uid === selectedUid)?.mailboxUid;
782
- const labelsMailboxUid = preferences.showAsConversations ? activeMailboxUid : (selectedMessageMailboxUid ?? activeMailboxUid);
817
+ const labelsMailboxUid = threadPane ? activeMailboxUid : (selectedMessageMailboxUid ?? activeMailboxUid);
783
818
  // Almost always the open mailbox's own labels, already fetched below - so `labels` reuses that list
784
819
  // rather than asking for the identical one a second time (which is what this page did on every single
785
820
  // view). Only a selected message from *another* mailbox - a search hit or an aggregate-view row - needs
@@ -849,7 +884,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
849
884
  const listKey = listSnapshotKey({
850
885
  mailboxUid: activeMailboxUid,
851
886
  folderUid,
852
- conversations: preferences.showAsConversations,
887
+ conversations: asConversations,
853
888
  filter: effectiveFilter,
854
889
  labels: labelFilterKey,
855
890
  sort: serverSortKey,
@@ -920,7 +955,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
920
955
  return;
921
956
  }
922
957
 
923
- if (preferences.showAsConversations) {
958
+ if (asConversations) {
924
959
  // Conversations stay single-mailbox (not aggregated across mailboxes in this pass) - in
925
960
  // aggregate mode this falls back to `activeMailboxUid`, the same mailbox unlock/labels use
926
961
  // (always set - `MailShell` only renders this component once at least one mailbox exists).
@@ -1128,7 +1163,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1128
1163
  // `refreshKey` is a dependency for the same reason: a failed bulk action bumps it to refetch,
1129
1164
  // since a bulk update applies until its first rejection rather than all-or-nothing.
1130
1165
  }, [
1131
- preferences.showAsConversations,
1166
+ asConversations,
1132
1167
  serverSortKey,
1133
1168
  effectiveFilter,
1134
1169
  labelFilterKey,
@@ -1163,7 +1198,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1163
1198
  if (live.tick === 0 || isSearching || loading || loadMoreInFlightRef.current || (!folderUid && !aggregateFolderType)) {
1164
1199
  return;
1165
1200
  }
1166
- if (!preferences.showAsConversations && live.folderUids) {
1201
+ if (!asConversations && live.folderUids) {
1167
1202
  const shown = new Set(
1168
1203
  aggregateFolderType
1169
1204
  ? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
@@ -1179,7 +1214,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1179
1214
  const isCurrent = () => searchRunIdRef.current === listRun && liveRunRef.current === myRun;
1180
1215
  void (async () => {
1181
1216
  try {
1182
- if (preferences.showAsConversations) {
1217
+ if (asConversations) {
1183
1218
  const fresh = await listConversations(activeMailboxUid, conversationParams(0));
1184
1219
  if (!isCurrent()) {
1185
1220
  return;
@@ -1229,13 +1264,13 @@ function InboxContent({ userUid }: { userUid?: string }) {
1229
1264
  // Reads the ref, not `conversations`/`messages` state, so this callback (and the observer effect
1230
1265
  // watching it) don't need to be recreated every time a page lands - the same reason every other
1231
1266
  // check below reads a ref rather than closing over state.
1232
- const atRowCap = (preferences.showAsConversations ? conversationsRef.current.length : messagesRef.current.length) >= MAX_LOADED_ROWS;
1267
+ const atRowCap = (asConversations ? conversationsRef.current.length : messagesRef.current.length) >= MAX_LOADED_ROWS;
1233
1268
  if (
1234
1269
  loadMoreInFlightRef.current ||
1235
1270
  !hasMore ||
1236
1271
  loading ||
1237
1272
  atRowCap ||
1238
- (!preferences.showAsConversations && !folderUid) ||
1273
+ (!asConversations && !folderUid) ||
1239
1274
  (isSearching && !searchCursor)
1240
1275
  ) {
1241
1276
  return;
@@ -1266,7 +1301,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1266
1301
  const myRunId = searchRunIdRef.current;
1267
1302
  const isCurrentRun = () => searchRunIdRef.current === myRunId;
1268
1303
  try {
1269
- if (preferences.showAsConversations) {
1304
+ if (asConversations) {
1270
1305
  const page = Math.floor(listedOffsetRef.current / MESSAGE_PAGE_SIZE);
1271
1306
  const more = await listConversations(activeMailboxUid, conversationParams(page));
1272
1307
  if (!isCurrentRun()) {
@@ -1344,7 +1379,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1344
1379
  }, [
1345
1380
  hasMore,
1346
1381
  loading,
1347
- preferences.showAsConversations,
1382
+ asConversations,
1348
1383
  serverSortKey,
1349
1384
  effectiveFilter,
1350
1385
  labelFilterKey,
@@ -1437,7 +1472,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1437
1472
 
1438
1473
  // The conversation list opens a whole thread in `ConversationThreadPane`, which loads and marks read
1439
1474
  // its own messages; only the flat list feeds the single-message pane below.
1440
- const selected = preferences.showAsConversations ? null : (messages.find((m) => m.uid === selectedUid) ?? null);
1475
+ const selected = threadPane ? null : (messages.find((m) => m.uid === selectedUid) ?? null);
1441
1476
  const attachments = useMessageAttachments(selected);
1442
1477
  useMarkMessageRead(selected, patchListedMessage);
1443
1478
  // Search results can span every folder in the mailbox, not just the one selected in the sidebar - a
@@ -1446,7 +1481,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1446
1481
  // this falls back to the sidebar selection unchanged). Aggregate views span every *mailbox* too, so
1447
1482
  // the folder list consulted is the selected message's own mailbox's, not the shell's ambient one. A
1448
1483
  // conversation spans folders for the same reason (an Inbox message and the Sent Items copy of its reply).
1449
- const spansFolders = isSearching || !!aggregateFolderType || preferences.showAsConversations;
1484
+ const spansFolders = isSearching || !!aggregateFolderType || threadPane;
1450
1485
  const selectedFolderUid = spansFolders ? (selected?.folderUid ?? folderUid) : folderUid;
1451
1486
  const selectedMailboxUid = spansFolders ? (selected?.mailboxUid ?? activeMailboxUid) : mailboxUid;
1452
1487
  const folders = mailboxFolders.find((mf) => mf.mailbox.uid === selectedMailboxUid)?.folders ?? [];
@@ -1471,12 +1506,12 @@ function InboxContent({ userUid }: { userUid?: string }) {
1471
1506
  const selectedConversationMessages = [...selectedConversationIds].flatMap((id) =>
1472
1507
  (conversationMessagesById[id] ?? []).filter((m) => !folderUid || m.folderUid === folderUid),
1473
1508
  );
1474
- const selectedMessages = preferences.showAsConversations
1509
+ const selectedMessages = asConversations
1475
1510
  ? selectedConversationMessages
1476
1511
  : messages.filter((m) => selectedUids.has(m.uid));
1477
1512
  /** How many rows the list is actually showing - conversations or messages, whichever it lists. What the
1478
1513
  * Select toggle is enabled by: there is nothing to select in a list with no rows. */
1479
- const listedRowCount = preferences.showAsConversations ? conversations.length : messages.length;
1514
+ const listedRowCount = asConversations ? conversations.length : messages.length;
1480
1515
  /** The conversation rows in the order the reader arranged them. The endpoint takes no sort parameters
1481
1516
  * of its own (it pages by latest activity), so the arrangement is applied here, to the rows fetched so
1482
1517
  * far - which the Sort menu says on screen. `conversations` itself stays in the order the pages
@@ -1490,7 +1525,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1490
1525
  * mailbox/search whose true size lands at or near the cap can have its very last page be a partial one,
1491
1526
  * which correctly turns `hasMore` false - without checking it here, the banner would still claim rows
1492
1527
  * are being hidden even though every one of them is already on screen. */
1493
- const rowCapReached = hasMore && (preferences.showAsConversations ? conversations.length : messages.length) >= MAX_LOADED_ROWS;
1528
+ const rowCapReached = hasMore && (asConversations ? conversations.length : messages.length) >= MAX_LOADED_ROWS;
1494
1529
 
1495
1530
  function leaveSelectMode() {
1496
1531
  setSelectMode(false);
@@ -1592,7 +1627,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1592
1627
  }
1593
1628
  }
1594
1629
  }
1595
- if (preferences.showAsConversations) {
1630
+ if (asConversations) {
1596
1631
  // A conversation row is a summary of its messages - its count, unread count, participants
1597
1632
  // and preview all move when a bulk action changes or empties part of it - so the list is
1598
1633
  // reloaded rather than patched row by row.
@@ -1685,6 +1720,49 @@ function InboxContent({ userUid }: { userUid?: string }) {
1685
1720
  return runBulkAction(async (moving) => moveMessages(moving, await resolveFolderOfType(type, name)), true, chosen);
1686
1721
  }
1687
1722
 
1723
+ // ---- Swiping a row on a phone (see `SwipeRow`): right to left archives, left to right asks for a folder. Both go through the same
1724
+ // ---- bulk path as the selection bar's Archive and Move to, so the badges, the optimistic bookkeeping and the failure pop-up are one
1725
+ // ---- implementation. Not offered where the actions aren't (an aggregate view spans mailboxes, search results are a mixed bag, the
1726
+ // ---- Outbox is the server's send queue) or while rows are being ticked.
1727
+ const listingOutbox = currentFolders.find((f) => f.uid === folderUid)?.type === "outbox";
1728
+ const swipeEnabled = isMobile && !selectMode && !aggregateFolderType && !isSearching && !listingOutbox;
1729
+ /** 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. */
1730
+ const [moveDialog, setMoveDialog] = useState<{ messages: Message[] } | { conversation: ConversationSummary } | null>(null);
1731
+
1732
+ /** The messages a swiped conversation stands for (the ones in the folder being listed), or `null` after saying why they couldn't be loaded. */
1733
+ async function swipedConversationMessages(conversation: ConversationThreadHead): Promise<Message[] | null> {
1734
+ try {
1735
+ return await loadOpenConversation(conversation);
1736
+ } catch (err) {
1737
+ notifyApiError(err, "Couldn't load the messages in that conversation");
1738
+ return null;
1739
+ }
1740
+ }
1741
+
1742
+ /** Archives what a swipe took away. Resolves whether it went through, so the row comes back when it didn't. */
1743
+ async function swipeArchive(target: Message[] | ConversationSummary): Promise<boolean> {
1744
+ if (bulkBusy || resolvingSelection > 0) {
1745
+ return false;
1746
+ }
1747
+ const chosen = Array.isArray(target) ? target : await swipedConversationMessages(target);
1748
+ if (!chosen || chosen.length === 0) {
1749
+ return false;
1750
+ }
1751
+ return runBulkAction(bulkArchive, true, chosen);
1752
+ }
1753
+
1754
+ async function swipeMove(folderUidToMoveTo: string): Promise<void> {
1755
+ // Only ever asked by the folder prompt, which is only open while `moveDialog` is set.
1756
+ const target = moveDialog!;
1757
+ const chosen = "messages" in target ? target.messages : await swipedConversationMessages(target.conversation);
1758
+ if (!chosen || chosen.length === 0) {
1759
+ setMoveDialog(null);
1760
+ return;
1761
+ }
1762
+ await runBulkAction((moving) => moveMessages(moving, folderUidToMoveTo), true, chosen);
1763
+ setMoveDialog(null);
1764
+ }
1765
+
1688
1766
  const loadMoreStatus = loadingMore ? (
1689
1767
  "Loading more\u2026"
1690
1768
  ) : loadMoreError ? (
@@ -1702,11 +1780,120 @@ function InboxContent({ userUid }: { userUid?: string }) {
1702
1780
  </button>
1703
1781
  ) : null;
1704
1782
 
1783
+ /** One message's row of the flat list - and, in search results grouped by conversation, of a group. */
1784
+ function renderMessageRow(message: Message) {
1785
+ return (
1786
+ <SwipeRow
1787
+ as="li"
1788
+ enabled={swipeEnabled}
1789
+ onArchive={() => swipeArchive([message])}
1790
+ onMove={() => setMoveDialog({ messages: [message] })}
1791
+ key={message.uid}
1792
+ data-message-uid={message.uid}
1793
+ data-unread={isUnread(message) ? "true" : undefined}
1794
+ className={rowClass(
1795
+ { unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) },
1796
+ "flex items-stretch",
1797
+ )}
1798
+ >
1799
+ <UnreadBar unread={isUnread(message)} />
1800
+ {selectMode && (
1801
+ <span className="shrink-0 flex items-center pl-3">
1802
+ <input
1803
+ type="checkbox"
1804
+ checked={selectedUids.has(message.uid)}
1805
+ onChange={() => toggleSelected(message.uid)}
1806
+ aria-label={`Select ${rowSubject(message)}`}
1807
+ className="w-4 h-4 accent-primary"
1808
+ />
1809
+ </span>
1810
+ )}
1811
+ {/* The open button and, for a meeting request, its RSVP chip: a button can't hold a button, so the chip is the button's sibling. */}
1812
+ <div className="flex-1 min-w-0 flex flex-col">
1813
+ <button
1814
+ type="button"
1815
+ data-row-open
1816
+ onClick={() => handleSelect(message)}
1817
+ className={["w-full text-left px-4 py-3", ROW_FOCUS_CLASS].join(" ")}
1818
+ >
1819
+ <UnreadLabel unread={isUnread(message)} />
1820
+ <div className="flex items-center justify-between gap-2 text-sm">
1821
+ <MailAddress recipient={message.from} className={senderClass(isUnread(message))} />
1822
+ <span className={["text-xs shrink-0", dateClass(isUnread(message))].join(" ")}>
1823
+ {new Date(message.receivedDate).toLocaleDateString()}
1824
+ </span>
1825
+ </div>
1826
+ {aggregateFolderType && (
1827
+ // The one view where a row needs to say which mailbox it came from.
1828
+ <div className="text-xs text-text-muted truncate font-normal">
1829
+ {mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName}
1830
+ </div>
1831
+ )}
1832
+ {isSearching && pendingUids.has(message.uid) ? (
1833
+ // §_Progressive Results_: "Unresolved encrypted results MUST
1834
+ // be rendered as skeleton entries in place, not appended on
1835
+ // arrival." This uid is a Tier 1 metadataOnly guess Tier 2/3
1836
+ // haven't confirmed (or ruled out) yet.
1837
+ <div className="flex flex-col gap-1.5 py-0.5">
1838
+ <Skeleton height="h-3.5" className="w-2/3 rounded-sm" />
1839
+ <Skeleton height="h-3" className="w-full rounded-sm" />
1840
+ </div>
1841
+ ) : (
1842
+ <>
1843
+ <div className={["text-sm truncate", subjectClass(isUnread(message))].join(" ")}>
1844
+ {rowSubject(message)}
1845
+ </div>
1846
+ <div className="flex items-center gap-2 text-xs text-text-muted font-normal">
1847
+ <span className="truncate">
1848
+ {snippets[message.uid] ||
1849
+ decryptedRows[message.uid]?.preview ||
1850
+ message.bodyPreview ||
1851
+ (message.encrypted ? <EncryptedPreview /> : null)}
1852
+ </span>
1853
+ {message.hasAttachments && <HiOutlinePaperClip size={12} aria-label="Has attachments" />}
1854
+ {message.flags.flagged && (
1855
+ <HiOutlineFlag size={12} aria-label="Flagged" className="text-danger" />
1856
+ )}
1857
+ </div>
1858
+ {/* What the server is doing with a message in Outbox: sending, retrying, or why it wasn't sent. */}
1859
+ {isOutbox && <OutboxRowStatus message={message} />}
1860
+ </>
1861
+ )}
1862
+ </button>
1863
+ <InviteRowChip message={message} onResponded={(updated) => patchListedMessage(updated)} />
1864
+ </div>
1865
+ </SwipeRow>
1866
+ );
1867
+ }
1868
+
1869
+ /** Search results arranged by conversation: the messages that matched, grouped under the conversation each belongs to, in the order of the
1870
+ * first match of each - the order the results are ranked in. `null` when the list is not showing grouped results. */
1871
+ const resultGroups = groupedResults ? groupByConversation(messages) : null;
1872
+
1873
+ /** The search box: in the list on a desktop (flat lists only), in the shell's header beside the folders button on a phone. */
1874
+ const searchField = (
1875
+ <input
1876
+ ref={searchInputRef}
1877
+ type="search"
1878
+ value={searchInput}
1879
+ onChange={(e) => setSearchInput(e.target.value)}
1880
+ placeholder={aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…"}
1881
+ aria-label="Search all mail"
1882
+ disabled={!!aggregateFolderType}
1883
+ className={["w-full text-sm px-3 rounded-md border border-border bg-surface disabled:opacity-55", isMobile ? "py-2" : "py-1.5"].join(" ")}
1884
+ />
1885
+ );
1886
+
1705
1887
  function handleSelect(message: Message) {
1706
1888
  if (selectMode) {
1707
1889
  toggleSelected(message.uid);
1708
1890
  return;
1709
1891
  }
1892
+ if (preferences.showAsConversations) {
1893
+ // A search result of a list arranged by conversation: the reading pane is the whole thread, opened at this message.
1894
+ openThreadOf(message);
1895
+ return;
1896
+ }
1710
1897
  if (isMobile) {
1711
1898
  navigate(`/messages/${encodeURIComponent(message.uid)}`);
1712
1899
  return;
@@ -1715,6 +1902,24 @@ function InboxContent({ userUid }: { userUid?: string }) {
1715
1902
  setSelectedUid(message.uid);
1716
1903
  }
1717
1904
 
1905
+ /** 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
1906
+ * subject to head it with while the thread loads. */
1907
+ function threadHeadOf(message: Message): ConversationThreadHead {
1908
+ return { conversationId: message.conversationId ?? message.uid, subject: message.subject ?? "", messageCount: 1 };
1909
+ }
1910
+
1911
+ /** Opens the whole thread a message belongs to, positioned at it - the phone's message page, or the reading pane. */
1912
+ function openThreadOf(message: Message) {
1913
+ const conversation = threadHeadOf(message);
1914
+ if (isMobile) {
1915
+ navigate(`/messages/${encodeURIComponent(message.uid)}?conversation=${encodeURIComponent(conversation.conversationId)}`);
1916
+ return;
1917
+ }
1918
+ removedAnchorRef.current = null;
1919
+ setSelectedUid(message.uid);
1920
+ setOpenThread({ conversation, uid: message.uid });
1921
+ }
1922
+
1718
1923
  /** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
1719
1924
  * stands for, or the latest for a parent row. The thread pane loads the thread itself. */
1720
1925
  function handleOpenConversation(conversation: ConversationSummary, uid: string) {
@@ -1725,9 +1930,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
1725
1930
  return;
1726
1931
  }
1727
1932
  if (isMobile) {
1728
- // No dedicated mobile thread route yet - the existing single-message detail route already
1729
- // handles any message uid regardless of conversation grouping.
1730
- navigate(`/messages/${encodeURIComponent(uid)}`);
1933
+ // The message page shows the whole conversation, positioned at this message, when it is given the conversation.
1934
+ navigate(`/messages/${encodeURIComponent(uid)}?conversation=${encodeURIComponent(conversation.conversationId)}`);
1731
1935
  return;
1732
1936
  }
1733
1937
  removedAnchorRef.current = null;
@@ -1739,11 +1943,11 @@ function InboxContent({ userUid }: { userUid?: string }) {
1739
1943
  // ---- the selection bar call: the same bulk-action path (`runBulkAction()` - so the same optimistic badge tracking, the same rollback,
1740
1944
  // ---- the same lazily created Deleted Items folder), never a second implementation. Reply, Reply all, Forward, Archive and Move to are
1741
1945
  // ---- registered by the reading pane itself (`MessageDetailPane`'s `shortcuts`), which owns those handlers.
1742
- const inConversations = preferences.showAsConversations;
1946
+ const inConversations = asConversations;
1743
1947
  /** The selection bar isn't offered in the aggregate view (its rows belong to several mailboxes), and neither are these. */
1744
1948
  const keyboardActions = !aggregateFolderType;
1745
1949
  /** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
1746
- const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : inConversations ? openThread !== null : selected !== null;
1950
+ const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
1747
1951
  const rowCount = inConversations ? listedConversations.length : messages.length;
1748
1952
  const canMoveSelection = !selectMode && !isMobile && !loading && rowCount > 0;
1749
1953
 
@@ -1800,7 +2004,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1800
2004
  }
1801
2005
 
1802
2006
  /** The messages of the open conversation that are in the folder being listed - fetched (once) like a ticked conversation's. */
1803
- async function loadOpenConversation(conversation: ConversationSummary): Promise<Message[]> {
2007
+ async function loadOpenConversation(conversation: ConversationThreadHead): Promise<Message[]> {
1804
2008
  const id = conversation.conversationId;
1805
2009
  let all = conversationMessagesById[id];
1806
2010
  if (!all) {
@@ -1826,7 +2030,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1826
2030
  }
1827
2031
  let chosen: Message[];
1828
2032
  try {
1829
- chosen = selectMode ? selectedMessages : inConversations ? await loadOpenConversation(openThread!.conversation) : [selected!];
2033
+ chosen = selectMode ? selectedMessages : threadPane ? await loadOpenConversation(openThread!.conversation) : [selected!];
1830
2034
  } catch (err) {
1831
2035
  notifyApiError(err, "Couldn't load the messages in that conversation");
1832
2036
  return;
@@ -1836,7 +2040,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1836
2040
  return;
1837
2041
  }
1838
2042
  const succeeded = await runBulkAction(action, removesRows, chosen);
1839
- if (succeeded && removesRows && inConversations && !selectMode) {
2043
+ if (succeeded && removesRows && threadPane && !selectMode) {
1840
2044
  setOpenThread(null);
1841
2045
  setSelectedUid(null);
1842
2046
  }
@@ -1916,7 +2120,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1916
2120
  searchInputRef.current?.focus();
1917
2121
  searchInputRef.current?.select();
1918
2122
  },
1919
- { enabled: !inConversations && !aggregateFolderType },
2123
+ { enabled: !aggregateFolderType },
1920
2124
  );
1921
2125
 
1922
2126
  if (!folderUid && !aggregateFolderType) {
@@ -1942,17 +2146,17 @@ function InboxContent({ userUid }: { userUid?: string }) {
1942
2146
  selected={selectedMessages}
1943
2147
  listed={messages}
1944
2148
  totals={
1945
- preferences.showAsConversations
2149
+ asConversations
1946
2150
  ? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
1947
2151
  : undefined
1948
2152
  }
1949
2153
  onSelectAll={() =>
1950
- preferences.showAsConversations
2154
+ asConversations
1951
2155
  ? selectAllConversations()
1952
2156
  : setSelectedUids(new Set(messages.map((m) => m.uid)))
1953
2157
  }
1954
2158
  onClearSelection={() =>
1955
- preferences.showAsConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set())
2159
+ asConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set())
1956
2160
  }
1957
2161
  onCancel={leaveSelectMode}
1958
2162
  folders={currentFolders}
@@ -2001,13 +2205,13 @@ function InboxContent({ userUid }: { userUid?: string }) {
2001
2205
  sortKeysDisabled={isSearching || !!aggregateFolderType}
2002
2206
  // A conversation row is a thread summary, so only some of the keys have anything to
2003
2207
  // order by - the rest stay pickable and are applied to the rows already fetched.
2004
- unavailableSortKeys={preferences.showAsConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined}
2208
+ unavailableSortKeys={asConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined}
2005
2209
  sortKeysNote={
2006
2210
  isSearching
2007
2211
  ? "Search results are ranked by relevance rather than sorted."
2008
2212
  : aggregateFolderType
2009
2213
  ? "This view merges the newest mail from every mailbox and is always listed by date."
2010
- : preferences.showAsConversations
2214
+ : asConversations
2011
2215
  ? CONVERSATION_SORT_NOTE
2012
2216
  : undefined
2013
2217
  }
@@ -2021,20 +2225,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
2021
2225
  }
2022
2226
  />
2023
2227
  )}
2024
- {!preferences.showAsConversations && (
2025
- <div className="p-2 border-b border-border">
2026
- <input
2027
- ref={searchInputRef}
2028
- type="search"
2029
- value={searchInput}
2030
- onChange={(e) => setSearchInput(e.target.value)}
2031
- placeholder={aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…"}
2032
- aria-label="Search all mail"
2033
- disabled={!!aggregateFolderType}
2034
- className="w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface disabled:opacity-55"
2035
- />
2036
- </div>
2037
- )}
2228
+ {!isMobile && <div className="p-2 border-b border-border">{searchField}</div>}
2229
+ {isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot)}
2038
2230
  {/* Two tabs, as Outlook has: Focused and Other. There is no "All" tab - the whole Inbox is
2039
2231
  still one pick away, in the Filter menu, which is where every other named filter lives
2040
2232
  and the only place that can show which of them is really in force. A stored `all` (or
@@ -2104,7 +2296,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
2104
2296
  </button>
2105
2297
  </div>
2106
2298
  )}
2107
- {!isSearching && !preferences.showAsConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (
2299
+ {!isSearching && !asConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (
2108
2300
  <div className="px-4 py-2 border-b border-border bg-surface-alt">
2109
2301
  <button
2110
2302
  type="button"
@@ -2130,7 +2322,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
2130
2322
  <span className="sr-only">Loading&hellip;</span>
2131
2323
  <SkeletonList count={8} />
2132
2324
  </div>
2133
- ) : preferences.showAsConversations ? (
2325
+ ) : asConversations ? (
2134
2326
  <>
2135
2327
  <ConversationList
2136
2328
  conversations={listedConversations}
@@ -2138,10 +2330,16 @@ function InboxContent({ userUid }: { userUid?: string }) {
2138
2330
  mailboxUid={activeMailboxUid}
2139
2331
  selectedUid={selectedUid}
2140
2332
  messageOverrides={conversationPatches}
2333
+ onMeetingResponded={patchListedMessage}
2141
2334
  onOpenMessage={handleOpenConversation}
2142
2335
  selectMode={selectMode}
2143
2336
  selectedConversationIds={selectedConversationIds}
2144
2337
  onToggleSelected={toggleConversationSelected}
2338
+ swipe={{
2339
+ enabled: swipeEnabled,
2340
+ onArchive: swipeArchive,
2341
+ onMove: (conversation) => setMoveDialog({ conversation }),
2342
+ }}
2145
2343
  />
2146
2344
  {hasMore && !rowCapReached && (
2147
2345
  <div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
@@ -2173,81 +2371,20 @@ function InboxContent({ userUid }: { userUid?: string }) {
2173
2371
  </>
2174
2372
  ) : (
2175
2373
  <>
2176
- <ul>
2177
- {messages.map((message) => (
2178
- <li
2179
- key={message.uid}
2180
- data-message-uid={message.uid}
2181
- data-unread={isUnread(message) ? "true" : undefined}
2182
- className={rowClass(
2183
- { unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) },
2184
- "flex items-stretch",
2185
- )}
2186
- >
2187
- <UnreadBar unread={isUnread(message)} />
2188
- {selectMode && (
2189
- <span className="shrink-0 flex items-center pl-3">
2190
- <input
2191
- type="checkbox"
2192
- checked={selectedUids.has(message.uid)}
2193
- onChange={() => toggleSelected(message.uid)}
2194
- aria-label={`Select ${rowSubject(message)}`}
2195
- className="w-4 h-4 accent-primary"
2196
- />
2197
- </span>
2198
- )}
2199
- <button
2200
- type="button"
2201
- data-row-open
2202
- onClick={() => handleSelect(message)}
2203
- className={["flex-1 min-w-0 text-left px-4 py-3", ROW_FOCUS_CLASS].join(" ")}
2204
- >
2205
- <UnreadLabel unread={isUnread(message)} />
2206
- <div className="flex items-center justify-between gap-2 text-sm">
2207
- <MailAddress recipient={message.from} className={senderClass(isUnread(message))} />
2208
- <span className={["text-xs shrink-0", dateClass(isUnread(message))].join(" ")}>
2209
- {new Date(message.receivedDate).toLocaleDateString()}
2210
- </span>
2211
- </div>
2212
- {aggregateFolderType && (
2213
- // The one view where a row needs to say which mailbox it came from.
2214
- <div className="text-xs text-text-muted truncate font-normal">
2215
- {mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName}
2216
- </div>
2217
- )}
2218
- {isSearching && pendingUids.has(message.uid) ? (
2219
- // §_Progressive Results_: "Unresolved encrypted results MUST
2220
- // be rendered as skeleton entries in place, not appended on
2221
- // arrival." This uid is a Tier 1 metadataOnly guess Tier 2/3
2222
- // haven't confirmed (or ruled out) yet.
2223
- <div className="flex flex-col gap-1.5 py-0.5">
2224
- <Skeleton height="h-3.5" className="w-2/3 rounded-sm" />
2225
- <Skeleton height="h-3" className="w-full rounded-sm" />
2226
- </div>
2227
- ) : (
2228
- <>
2229
- <div className={["text-sm truncate", subjectClass(isUnread(message))].join(" ")}>
2230
- {rowSubject(message)}
2231
- </div>
2232
- <div className="flex items-center gap-2 text-xs text-text-muted font-normal">
2233
- <span className="truncate">
2234
- {snippets[message.uid] ||
2235
- decryptedRows[message.uid]?.preview ||
2236
- message.bodyPreview ||
2237
- (message.encrypted ? <EncryptedPreview /> : null)}
2238
- </span>
2239
- {message.hasAttachments && <HiOutlinePaperClip size={12} aria-label="Has attachments" />}
2240
- {message.flags.flagged && (
2241
- <HiOutlineFlag size={12} aria-label="Flagged" className="text-danger" />
2242
- )}
2243
- </div>
2244
- {/* What the server is doing with a message in Outbox: sending, retrying, or why it wasn't sent. */}
2245
- {isOutbox && <OutboxRowStatus message={message} />}
2246
- </>
2247
- )}
2248
- </button>
2249
- </li>
2250
- ))}
2374
+ <ul className={swipeEnabled ? "overflow-x-clip" : undefined}>
2375
+ {resultGroups
2376
+ ? resultGroups.map((group) => (
2377
+ <li key={group.id} data-search-group={group.id}>
2378
+ <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">
2379
+ <span className="truncate font-semibold">{rowSubject(group.messages[0])}</span>
2380
+ <span className="shrink-0">
2381
+ {group.messages.length} matching message{group.messages.length === 1 ? "" : "s"}
2382
+ </span>
2383
+ </div>
2384
+ <ul>{group.messages.map(renderMessageRow)}</ul>
2385
+ </li>
2386
+ ))
2387
+ : messages.map(renderMessageRow)}
2251
2388
  </ul>
2252
2389
  {hasMore && !rowCapReached && (
2253
2390
  <div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
@@ -2275,7 +2412,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
2275
2412
  body iframe that cannot measure itself - takes its height from here, never from a `vh`
2276
2413
  number of its own. */}
2277
2414
  <div className="hidden md:flex flex-1 min-w-0 min-h-0">
2278
- {preferences.showAsConversations ? (
2415
+ {threadPane ? (
2279
2416
  <LazyConversationThreadPane
2280
2417
  conversation={openThread?.conversation ?? null}
2281
2418
  selectedUid={openThread?.uid ?? null}
@@ -2325,6 +2462,16 @@ function InboxContent({ userUid }: { userUid?: string }) {
2325
2462
  />
2326
2463
  )}
2327
2464
  </div>
2465
+ <MoveToFolderDialog
2466
+ open={moveDialog !== null}
2467
+ onClose={() => setMoveDialog(null)}
2468
+ mailboxUid={activeMailboxUid}
2469
+ folders={currentFolders}
2470
+ currentFolderUid={folderUid}
2471
+ count={moveDialog === null ? 1 : "messages" in moveDialog ? moveDialog.messages.length : moveDialog.conversation.messageCount}
2472
+ onMove={swipeMove}
2473
+ onFolderCreated={onFolderCreated}
2474
+ />
2328
2475
  </div>
2329
2476
  );
2330
2477
  }