@rapidmx/web-client 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +3 -0
  2. package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +22 -10
  3. package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +60 -0
  4. package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +108 -0
  5. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +25 -1
  6. package/apps/shared/components/calendar/CalendarSearchBox.tsx +83 -0
  7. package/apps/shared/components/calendar/EventMatchNav.tsx +72 -0
  8. package/apps/shared/components/calendar/EventModal.tsx +33 -12
  9. package/apps/shared/components/calendar/ListView.tsx +207 -0
  10. package/apps/shared/components/calendar/MonthView.tsx +14 -2
  11. package/apps/shared/components/calendar/SlideIn.tsx +47 -0
  12. package/apps/shared/components/calendar/SplitDayView.tsx +5 -0
  13. package/apps/shared/components/calendar/TimeGridView.tsx +23 -12
  14. package/apps/shared/components/calendar/activeOccurrence.ts +28 -0
  15. package/apps/shared/components/calendar/allDay.ts +16 -1
  16. package/apps/shared/components/calendar/calendarView.ts +41 -0
  17. package/apps/shared/components/layout/AppShell.tsx +13 -6
  18. package/apps/shared/components/mail/ConversationList.tsx +32 -6
  19. package/apps/shared/components/mail/ConversationThreadPane.tsx +15 -0
  20. package/apps/shared/components/mail/MailSelectionBar.tsx +117 -55
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +3 -0
  22. package/apps/shared/components/mail/compose/ComposeContext.tsx +193 -35
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +62 -11
  24. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +21 -10
  25. package/apps/shared/components/mail/compose/RichTextEditor.tsx +2 -1
  26. package/apps/shared/components/mail/compose/quotedBody.ts +45 -2
  27. package/apps/shared/components/mail/labelMenu.tsx +4 -0
  28. package/apps/shared/components/mail/layout/MailShell.tsx +9 -2
  29. package/apps/shared/components/mail/reading/MessageBody.tsx +1 -1
  30. package/apps/shared/components/mail/reading/bodyContent.ts +15 -13
  31. package/apps/shared/components/mail/reading/bodyHtml.ts +12 -7
  32. package/apps/shared/gestures/useEnterSlide.ts +1 -1
  33. package/apps/shared/keyboard/keymap.ts +2 -0
  34. package/apps/shared/mail/rangeSelection.ts +45 -0
  35. package/apps/shared/mail/rowExit.ts +39 -0
  36. package/apps/shared/mail/usePermanentDelete.tsx +14 -7
  37. package/apps/www/calendar/index.tsx +251 -53
  38. package/apps/www/index.tsx +563 -376
  39. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +3 -3
  40. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +15 -9
  41. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +15 -0
  42. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +16 -0
  43. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +20 -0
  44. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +31 -0
  45. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +20 -1
  46. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +7 -0
  47. package/dist/apps/shared/components/calendar/CalendarSearchBox.d.ts +22 -0
  48. package/dist/apps/shared/components/calendar/CalendarSearchBox.js +24 -0
  49. package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +17 -0
  50. package/dist/apps/shared/components/calendar/EventMatchNav.js +37 -0
  51. package/dist/apps/shared/components/calendar/EventModal.d.ts +17 -1
  52. package/dist/apps/shared/components/calendar/EventModal.js +13 -11
  53. package/dist/apps/shared/components/calendar/ListView.d.ts +30 -0
  54. package/dist/apps/shared/components/calendar/ListView.js +111 -0
  55. package/dist/apps/shared/components/calendar/MonthView.js +12 -3
  56. package/dist/apps/shared/components/calendar/SlideIn.d.ts +16 -0
  57. package/dist/apps/shared/components/calendar/SlideIn.js +32 -0
  58. package/dist/apps/shared/components/calendar/SplitDayView.js +5 -1
  59. package/dist/apps/shared/components/calendar/TimeGridView.js +11 -3
  60. package/dist/apps/shared/components/calendar/activeOccurrence.d.ts +15 -0
  61. package/dist/apps/shared/components/calendar/activeOccurrence.js +21 -0
  62. package/dist/apps/shared/components/calendar/allDay.d.ts +4 -0
  63. package/dist/apps/shared/components/calendar/allDay.js +14 -1
  64. package/dist/apps/shared/components/calendar/calendarView.d.ts +16 -0
  65. package/dist/apps/shared/components/calendar/calendarView.js +36 -0
  66. package/dist/apps/shared/components/layout/AppShell.js +8 -4
  67. package/dist/apps/shared/components/mail/ConversationList.d.ts +11 -3
  68. package/dist/apps/shared/components/mail/ConversationList.js +11 -3
  69. package/dist/apps/shared/components/mail/ConversationThreadPane.js +9 -1
  70. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
  71. package/dist/apps/shared/components/mail/MessageDetailPane.js +3 -0
  72. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +31 -0
  73. package/dist/apps/shared/components/mail/compose/ComposeContext.js +123 -11
  74. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +6 -1
  75. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +37 -11
  76. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +5 -1
  77. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +10 -6
  78. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +2 -1
  79. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -0
  80. package/dist/apps/shared/components/mail/compose/quotedBody.js +43 -1
  81. package/dist/apps/shared/components/mail/labelMenu.d.ts +3 -1
  82. package/dist/apps/shared/components/mail/labelMenu.js +2 -2
  83. package/dist/apps/shared/components/mail/layout/MailShell.js +7 -1
  84. package/dist/apps/shared/components/mail/reading/MessageBody.js +1 -1
  85. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +5 -1
  86. package/dist/apps/shared/components/mail/reading/bodyContent.js +11 -11
  87. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +3 -2
  88. package/dist/apps/shared/components/mail/reading/bodyHtml.js +8 -4
  89. package/dist/apps/shared/gestures/useEnterSlide.d.ts +2 -0
  90. package/dist/apps/shared/gestures/useEnterSlide.js +1 -1
  91. package/dist/apps/shared/keyboard/keymap.d.ts +12 -0
  92. package/dist/apps/shared/keyboard/keymap.js +2 -0
  93. package/dist/apps/shared/mail/rangeSelection.d.ts +20 -0
  94. package/dist/apps/shared/mail/rangeSelection.js +34 -0
  95. package/dist/apps/shared/mail/rowExit.d.ts +12 -0
  96. package/dist/apps/shared/mail/rowExit.js +33 -0
  97. package/dist/apps/shared/mail/usePermanentDelete.d.ts +2 -0
  98. package/dist/apps/shared/mail/usePermanentDelete.js +13 -8
  99. package/dist/apps/www/calendar/index.js +177 -18
  100. package/dist/apps/www/index.js +257 -93
  101. package/dist/lib/calendar/eventSearch.d.ts +42 -0
  102. package/dist/lib/calendar/eventSearch.js +152 -0
  103. package/dist/lib/components/navigation/BottomTabBar.d.ts +15 -3
  104. package/dist/lib/components/navigation/BottomTabBar.js +52 -15
  105. package/dist/lib/components/navigation/NavBadge.d.ts +13 -0
  106. package/dist/lib/components/navigation/NavBadge.js +18 -0
  107. package/dist/lib/mail/compose/quotedImages.d.ts +21 -0
  108. package/dist/lib/mail/compose/quotedImages.js +41 -0
  109. package/dist/lib/mail/inlineImages.d.ts +38 -0
  110. package/dist/lib/mail/inlineImages.js +90 -0
  111. package/dist/lib/mail/mailApi.d.ts +8 -1
  112. package/dist/lib/mail/mailApi.js +57 -5
  113. package/lib/calendar/eventSearch.ts +172 -0
  114. package/lib/components/navigation/BottomTabBar.tsx +107 -22
  115. package/lib/components/navigation/NavBadge.tsx +33 -0
  116. package/lib/mail/compose/quotedImages.ts +49 -0
  117. package/lib/mail/inlineImages.ts +104 -0
  118. package/lib/mail/mailApi.ts +64 -5
  119. package/package.json +1 -1
  120. package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +0 -36
  121. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +0 -10
  122. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +0 -11
@@ -26,6 +26,8 @@ import MailShell, { useMailShell, } from "../shared/components/mail/layout/MailS
26
26
  import MailAddress from "../shared/components/mail/MailAddress.js";
27
27
  import OutboxRowStatus from "../shared/components/mail/OutboxRowStatus.js";
28
28
  import { mergeFirstPage } from "../shared/mail/mergeFirstPage.js";
29
+ import { isToggleClick, rangeBetween, resolveAnchor } from "../shared/mail/rangeSelection.js";
30
+ import { ROW_EXIT_MS, canAnimateRowExit, collapseRow } from "../shared/mail/rowExit.js";
29
31
  import { isOwnMailbox, orderMailboxes, primaryMailboxUid } from "../shared/mail/primaryMailbox.js";
30
32
  import { listSnapshotKey, readListSnapshot, saveListScroll, writeListSnapshot } from "../shared/mail/listSnapshots.js";
31
33
  import { setReadStateMany } from "../shared/mail/messageReadState.js";
@@ -586,6 +588,18 @@ function InboxContent({ userUid }) {
586
588
  // Where the message the keyboard just removed from the list (deleted, archived, moved) was: the next Down/Up continues from that spot, so
587
589
  // clearing an inbox from the keyboard walks down it instead of jumping back to the top. Cleared by any selection.
588
590
  const removedAnchorRef = useRef(null);
591
+ // The row a Shift+click range starts from: the row last clicked or opened (a `message.uid`, or a `conversationRowKey()` in the conversation list).
592
+ // A Shift+click selects from here to the clicked row but leaves it where it is, so the next Shift+click re-spans from the same row, as in Explorer and
593
+ // Outlook; a plain click or a Ctrl+click moves it to the row clicked. Forgotten when the listing is replaced. See `resolveAnchor()`.
594
+ const selectionAnchorRef = useRef(null);
595
+ // Rows on their way out of the list (moved, deleted, archived): `message.uid`s, or `conversationRowKey()`s in the conversation list. They stay in the
596
+ // data while they collapse (`collapseRow()`, `ROW_EXIT_MS`), so the rows below slide up into the gap, and are dropped by `animateRowsOut()` after.
597
+ const [exitingKeys, setExitingKeys] = useState(new Set());
598
+ const exitTimersRef = useRef(new Set());
599
+ useEffect(() => {
600
+ const timers = exitTimersRef.current;
601
+ return () => timers.forEach(clearTimeout);
602
+ }, []);
589
603
  // State (via a callback ref), not a plain ref: the sentinel mounts and unmounts as the list loads,
590
604
  // filters, and empties, and the observer effect below must re-attach to whichever node is current.
591
605
  const [sentinel, setSentinel] = useState(null);
@@ -804,12 +818,6 @@ function InboxContent({ userUid }) {
804
818
  setSearchAllMailboxes(false);
805
819
  }
806
820
  }, [searchQuery]);
807
- // Rows can be ticked in the results of a search over every mailbox, but not in the listing they came out of: clearing the search leaves select mode.
808
- useEffect(() => {
809
- if (aggregateFolderType && !isSearching) {
810
- setSelectMode(false);
811
- }
812
- }, [aggregateFolderType, isSearching]);
813
821
  // Which listing this is, for the short-lived snapshot of each folder that makes going back to one instant (see
814
822
  // `listSnapshots.ts`). A search, an aggregate view and a folder that hasn't resolved have no snapshot.
815
823
  const listKey = listSnapshotKey({
@@ -846,6 +854,7 @@ function InboxContent({ userUid }) {
846
854
  setSelectedUids(new Set());
847
855
  setSelectedConversationIds(new Set());
848
856
  setConversationMessagesById({});
857
+ selectionAnchorRef.current = null;
849
858
  listedOffsetRef.current = 0;
850
859
  compositeCursorRef.current = undefined;
851
860
  setHasMore(false);
@@ -1231,20 +1240,16 @@ function InboxContent({ userUid }) {
1231
1240
  // a listing still loading and a "load more" in flight, and for events about folders the list isn't showing - a
1232
1241
  // conversation list, which can span folders, refreshes for any. Any failure is silent: the next tick tries again.
1233
1242
  const liveRunRef = useRef(0);
1234
- useEffect(() => {
1235
- if (live.tick === 0 || isSearching || loading || loadMoreInFlightRef.current || (!folderUid && !aggregateFolderType)) {
1243
+ /**
1244
+ * Quietly refetches the first page of whatever is listed and folds it in (see above) - the same refresh a push event makes, which a bulk action or
1245
+ * a move also makes once its rows have left, so the list is brought up to date (counts, previews) without the skeleton or losing the reader's place.
1246
+ * `listRun` is the listing the caller means: a refresh that finds another listing on screen by the time it lands is thrown away.
1247
+ */
1248
+ function refreshListQuietly(listRun = searchRunIdRef.current) {
1249
+ if (isSearching || loading || loadMoreInFlightRef.current || (!folderUid && !aggregateFolderType)) {
1236
1250
  return;
1237
1251
  }
1238
- if (!asConversations && live.folderUids) {
1239
- const shown = new Set(aggregateFolderType
1240
- ? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
1241
- : [folderUid]);
1242
- if (![...live.folderUids].some((uid) => shown.has(uid))) {
1243
- return;
1244
- }
1245
- }
1246
1252
  // Superseded by a newer refresh, or by any reload of the list itself (which bumps the search run id).
1247
- const listRun = searchRunIdRef.current;
1248
1253
  const myRun = ++liveRunRef.current;
1249
1254
  const isCurrent = () => searchRunIdRef.current === listRun && liveRunRef.current === myRun;
1250
1255
  void (async () => {
@@ -1291,6 +1296,20 @@ function InboxContent({ userUid }) {
1291
1296
  // Quiet by design - see above.
1292
1297
  }
1293
1298
  })();
1299
+ }
1300
+ useEffect(() => {
1301
+ if (live.tick === 0) {
1302
+ return;
1303
+ }
1304
+ if (!asConversations && live.folderUids) {
1305
+ const shown = new Set(aggregateFolderType
1306
+ ? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
1307
+ : [folderUid]);
1308
+ if (![...live.folderUids].some((uid) => shown.has(uid))) {
1309
+ return;
1310
+ }
1311
+ }
1312
+ refreshListQuietly();
1294
1313
  }, [live.tick]);
1295
1314
  function handleSearchAllMail() {
1296
1315
  setSearchAllMailKey(searchAllMailScope);
@@ -1541,14 +1560,61 @@ function InboxContent({ userUid }) {
1541
1560
  : conversation));
1542
1561
  }
1543
1562
  }
1563
+ /**
1564
+ * Takes rows out of the list without a jolt: they collapse over `ROW_EXIT_MS` (the rows below slide up into the gap - see `collapseRow()`), then
1565
+ * `commit()` drops them from the data. `keys` are the rows' `exitingKeys`. With nothing to animate - none of them is listed, the browser can't
1566
+ * animate, or the reader prefers reduced motion - `commit()` runs at once.
1567
+ */
1568
+ function animateRowsOut(keys, commit) {
1569
+ if (keys.length === 0 || !canAnimateRowExit()) {
1570
+ commit();
1571
+ return;
1572
+ }
1573
+ setExitingKeys((prev) => new Set([...prev, ...keys]));
1574
+ const timer = setTimeout(() => {
1575
+ exitTimersRef.current.delete(timer);
1576
+ commit();
1577
+ setExitingKeys((prev) => {
1578
+ const next = new Set(prev);
1579
+ keys.forEach((key) => next.delete(key));
1580
+ return next;
1581
+ });
1582
+ }, ROW_EXIT_MS);
1583
+ exitTimersRef.current.add(timer);
1584
+ }
1585
+ /**
1586
+ * Takes the conversations whose every message is in `uids` out of the conversation list (animated), then quietly refreshes the list: a conversation
1587
+ * that keeps messages elsewhere stays, with its count and preview brought up to date by the refresh rather than by reloading the whole list.
1588
+ * `listRun` is the listing the caller acted on; if another is on screen by now there is nothing left to do.
1589
+ */
1590
+ function removeListedConversations(uids, listRun = searchRunIdRef.current) {
1591
+ if (searchRunIdRef.current !== listRun) {
1592
+ return;
1593
+ }
1594
+ const leaving = new Set(conversationsRef.current.filter((c) => c.messageUids.every((uid) => uids.has(uid))).map(conversationKey));
1595
+ animateRowsOut([...leaving], () => {
1596
+ if (searchRunIdRef.current !== listRun) {
1597
+ return;
1598
+ }
1599
+ setConversations((prev) => prev.filter((c) => !leaving.has(conversationKey(c))));
1600
+ // The refresh below merges into the ref, which only catches up with the state on the next render.
1601
+ conversationsRef.current = conversationsRef.current.filter((c) => !leaving.has(conversationKey(c)));
1602
+ listedOffsetRef.current = Math.max(0, listedOffsetRef.current - leaving.size);
1603
+ refreshListQuietly(listRun);
1604
+ });
1605
+ }
1544
1606
  function removeListedMessages(uids) {
1545
1607
  const selectedIndex = messagesRef.current.findIndex((m) => m.uid === selectedUid);
1546
1608
  if (selectedUid && uids.has(selectedUid) && selectedIndex !== -1) {
1547
1609
  removedAnchorRef.current = selectedIndex;
1548
1610
  }
1549
- setMessages((prev) => prev.filter((m) => !uids.has(m.uid)));
1550
- listedOffsetRef.current = Math.max(0, listedOffsetRef.current - uids.size);
1551
1611
  setSelectedUid((prev) => (prev && uids.has(prev) ? null : prev));
1612
+ if (asConversations) {
1613
+ removeListedConversations(uids);
1614
+ return;
1615
+ }
1616
+ listedOffsetRef.current = Math.max(0, listedOffsetRef.current - uids.size);
1617
+ animateRowsOut(messagesRef.current.filter((m) => uids.has(m.uid)).map((m) => m.uid), () => setMessages((prev) => prev.filter((m) => !uids.has(m.uid))));
1552
1618
  }
1553
1619
  function removeListedMessage(uid) {
1554
1620
  removeListedMessages(new Set([uid]));
@@ -1582,7 +1648,7 @@ function InboxContent({ userUid }) {
1582
1648
  * that is in the folder being listed. A conversation spans folders (an Inbox message and the Sent
1583
1649
  * Items copy of its reply), and the list only ever showed this folder's half of it, so a bulk action
1584
1650
  * from here must not reach into the other folders' copies either. */
1585
- const selectedConversationMessages = [...selectedConversationIds].flatMap((id) => (conversationMessagesById[id] ?? []).filter((m) => !folderUid || m.folderUid === folderUid));
1651
+ const selectedConversationMessages = [...selectedConversationIds].flatMap((id) => (conversationMessagesById[id] ?? []).filter((m) => (aggregateFolderType ? folderTypeOf(m.folderUid) === aggregateFolderType : !folderUid || m.folderUid === folderUid)));
1586
1652
  const selectedMessages = asConversations
1587
1653
  ? selectedConversationMessages
1588
1654
  : messages.filter((m) => selectedUids.has(m.uid));
@@ -1622,22 +1688,24 @@ function InboxContent({ userUid }) {
1622
1688
  /** Loads (once) the messages behind each of `ids`, so a ticked conversation resolves to the messages
1623
1689
  * every bulk action below acts on. If any of them fails to load, none of that batch stays ticked and
1624
1690
  * a pop-up says why - better than acting on the part of a selection that happened to arrive. */
1625
- async function resolveConversations(ids) {
1626
- const missing = ids.filter((id) => !conversationMessagesById[id]);
1691
+ async function resolveConversations(rows) {
1692
+ const missing = rows.filter((row) => !conversationMessagesById[conversationKey(row)]);
1627
1693
  if (missing.length === 0) {
1628
1694
  return;
1629
1695
  }
1630
1696
  setResolvingSelection((n) => n + 1);
1631
1697
  try {
1632
- const loaded = await Promise.all(missing.map(async (id) => [id, await listConversationMessages(activeMailboxUid, id, {}, client)]));
1698
+ const loaded = await Promise.all(
1699
+ // A row of the "All mailboxes" view names its own mailbox; any other list is the open mailbox's.
1700
+ missing.map(async (row) => [conversationKey(row), await listConversationMessages(row.mailboxUid ?? activeMailboxUid, row.conversationId, {}, client)]));
1633
1701
  setConversationMessagesById((prev) => ({ ...prev, ...Object.fromEntries(loaded) }));
1634
1702
  }
1635
1703
  catch (err) {
1636
1704
  notifyApiError(err, "Couldn't load the messages in one of those conversations");
1637
1705
  setSelectedConversationIds((prev) => {
1638
1706
  const next = new Set(prev);
1639
- for (const id of missing) {
1640
- next.delete(id);
1707
+ for (const row of missing) {
1708
+ next.delete(conversationKey(row));
1641
1709
  }
1642
1710
  return next;
1643
1711
  });
@@ -1647,7 +1715,7 @@ function InboxContent({ userUid }) {
1647
1715
  }
1648
1716
  }
1649
1717
  function toggleConversationSelected(conversation) {
1650
- const id = conversation.conversationId;
1718
+ const id = conversationKey(conversation);
1651
1719
  const ticking = !selectedConversationIds.has(id);
1652
1720
  setSelectedConversationIds((prev) => {
1653
1721
  const next = new Set(prev);
@@ -1660,13 +1728,52 @@ function InboxContent({ userUid }) {
1660
1728
  return next;
1661
1729
  });
1662
1730
  if (ticking) {
1663
- void resolveConversations([id]);
1731
+ void resolveConversations([conversation]);
1664
1732
  }
1665
1733
  }
1666
1734
  function selectAllConversations() {
1667
- const ids = conversations.map(conversationKey);
1668
- setSelectedConversationIds(new Set(ids));
1669
- void resolveConversations(ids);
1735
+ setSelectedConversationIds(new Set(conversations.map(conversationKey)));
1736
+ void resolveConversations(conversations);
1737
+ }
1738
+ /**
1739
+ * Shift+click: the keys from the anchor to `target` in `keys` (the list in the order it is displayed), entering select mode. The anchor is the
1740
+ * row last clicked or opened (`selectionAnchorRef`), else the row open in the reading pane (`openKey`), else the clicked row itself - and it is
1741
+ * pinned where it was, not moved to `target`, so a second Shift+click elsewhere re-spans from the same row.
1742
+ */
1743
+ function selectRange(keys, target, openKey) {
1744
+ const anchor = resolveAnchor(keys, selectionAnchorRef.current, openKey);
1745
+ selectionAnchorRef.current = anchor ?? target;
1746
+ setSelectMode(true);
1747
+ return rangeBetween(keys, anchor, target);
1748
+ }
1749
+ /** Shift+click on a message row. Plain Shift makes the range the whole selection; Ctrl+Shift (`extend`) adds it to what is ticked already. */
1750
+ function selectMessageRange(message, extend) {
1751
+ const range = selectRange(displayedMessages.map((m) => m.uid), message.uid, selectedUid);
1752
+ setSelectedUids((prev) => new Set(extend ? [...prev, ...range] : range));
1753
+ }
1754
+ /** Shift+click on a conversation row: as `selectMessageRange()`, over the rows in the order they are displayed, each resolved to its messages like a ticked one. */
1755
+ function selectConversationRange(conversation, extend) {
1756
+ const keys = listedConversations.map(conversationKey);
1757
+ const openIndex = selectedRowIndex();
1758
+ const range = selectRange(keys, conversationKey(conversation), openIndex === -1 ? null : keys[openIndex]);
1759
+ setSelectedConversationIds((prev) => new Set(extend ? [...prev, ...range] : range));
1760
+ const inRange = new Set(range);
1761
+ void resolveConversations(listedConversations.filter((c) => inRange.has(conversationKey(c))));
1762
+ }
1763
+ /** A plain click on a conversation's checkbox: ticks (or unticks) it and makes it the anchor of the next Shift+click. */
1764
+ function tickConversation(conversation) {
1765
+ selectionAnchorRef.current = conversationKey(conversation);
1766
+ toggleConversationSelected(conversation);
1767
+ }
1768
+ /** A Shift, Ctrl or Cmd click on a conversation row or its checkbox (the list hands those over rather than opening or ticking the row). */
1769
+ function handleConversationModifierClick(conversation, modifiers) {
1770
+ if (modifiers.shiftKey) {
1771
+ selectConversationRange(conversation, isToggleClick(modifiers));
1772
+ return;
1773
+ }
1774
+ // Ctrl (Cmd): just this row, and select mode if the reader was not in it.
1775
+ setSelectMode(true);
1776
+ tickConversation(conversation);
1670
1777
  }
1671
1778
  /**
1672
1779
  * Runs one bulk action over the current selection, then either patches the affected rows in place or
@@ -1681,6 +1788,8 @@ function InboxContent({ userUid }) {
1681
1788
  * names its own. Resolves whether it succeeded.
1682
1789
  */
1683
1790
  async function runBulkAction(action, removesRows, chosen = selectedMessages) {
1791
+ // The listing this acts on: the action takes a while, and the reader may have opened another folder by the time it is done.
1792
+ const listRun = searchRunIdRef.current;
1684
1793
  setBulkBusy(true);
1685
1794
  try {
1686
1795
  const updated = await action(chosen);
@@ -1697,11 +1806,17 @@ function InboxContent({ userUid }) {
1697
1806
  }
1698
1807
  if (asConversations) {
1699
1808
  // A conversation row is a summary of its messages - its count, unread count, participants
1700
- // and preview all move when a bulk action changes or empties part of it - so the list is
1701
- // reloaded rather than patched row by row.
1809
+ // and preview all move when a bulk action changes or empties part of it - so the rows that
1810
+ // went collapse away and the list is then refetched quietly (no skeleton, nothing forgotten)
1811
+ // rather than patched row by row.
1702
1812
  setSelectedConversationIds(new Set());
1703
1813
  setConversationMessagesById({});
1704
- setRefreshKey((n) => n + 1);
1814
+ if (removesRows) {
1815
+ removeListedConversations(new Set(chosen.map((m) => m.uid)), listRun);
1816
+ }
1817
+ else {
1818
+ refreshListQuietly(listRun);
1819
+ }
1705
1820
  }
1706
1821
  else if (removesRows) {
1707
1822
  removeListedMessages(new Set(chosen.map((m) => m.uid)));
@@ -1804,6 +1919,7 @@ function InboxContent({ userUid }) {
1804
1919
  * (those stay listed, and the reload shows what is really there). Resolves whether anything was deleted.
1805
1920
  */
1806
1921
  async function purgeChosen(chosen) {
1922
+ const listRun = searchRunIdRef.current;
1807
1923
  const outcome = await permanent.requestPermanentDelete(chosen);
1808
1924
  if (!outcome) {
1809
1925
  return false;
@@ -1811,7 +1927,7 @@ function InboxContent({ userUid }) {
1811
1927
  if (asConversations) {
1812
1928
  setSelectedConversationIds(new Set());
1813
1929
  setConversationMessagesById({});
1814
- setRefreshKey((n) => n + 1);
1930
+ removeListedConversations(new Set(outcome.deleted.map((m) => m.uid)), listRun);
1815
1931
  }
1816
1932
  else {
1817
1933
  removeListedMessages(new Set(outcome.deleted.map((m) => m.uid)));
@@ -1883,7 +1999,10 @@ function InboxContent({ userUid }) {
1883
1999
  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;
1884
2000
  /** One message's row of the flat list - and, in search results grouped by conversation, of a group. */
1885
2001
  function renderMessageRow(message) {
1886
- 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() })] }), crossMailbox && (_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] ||
2002
+ const exiting = exitingKeys.has(message.uid);
2003
+ 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, "data-exiting": exiting ? "true" : undefined, "aria-hidden": exiting ? "true" : undefined, inert: exiting, className: rowClass({ unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) }, exiting ? "flex items-stretch pointer-events-none" : "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),
2004
+ // The change of a checkbox is caused by a click (or the Space key, which makes one): its modifiers are that event's.
2005
+ onChange: (event) => handleSelect(message, event.nativeEvent), "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: (event) => handleSelect(message, event), 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() })] }), crossMailbox && (_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] ||
1887
2006
  decryptedRows[message.uid]?.preview ||
1888
2007
  message.bodyPreview ||
1889
2008
  (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));
@@ -1891,9 +2010,26 @@ function InboxContent({ userUid }) {
1891
2010
  /** Search results arranged by conversation: the messages that matched, grouped under the conversation each belongs to, in the order of the
1892
2011
  * first match of each - the order the results are ranked in. `null` when the list is not showing grouped results. */
1893
2012
  const resultGroups = groupedResults ? groupByConversation(messages) : null;
2013
+ /** The messages in the order the list shows them - grouped results are listed group by group - which is the order a Shift+click range runs in. */
2014
+ const displayedMessages = resultGroups ? resultGroups.flatMap((group) => group.messages) : messages;
1894
2015
  /** The search box: in the list on a desktop (flat lists only), in the shell's header beside the folders button on a phone. */
1895
2016
  const searchField = (_jsx("input", { ref: searchInputRef, type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: "Search all mail\u2026", "aria-label": "Search all mail", className: ["w-full text-sm px-3 rounded-md border border-border bg-surface", isMobile ? "py-2" : "py-1.5"].join(" ") }));
1896
- function handleSelect(message) {
2017
+ /**
2018
+ * A click on a message row (or its checkbox), or the keyboard moving the selection to it (no `modifiers`). Shift+click selects the range from the
2019
+ * anchor; Ctrl/Cmd+click toggles just this row, entering select mode if needed (Ctrl+Shift adds the range to the selection instead of replacing it);
2020
+ * a plain click ticks the row in select mode and opens it otherwise.
2021
+ */
2022
+ function handleSelect(message, modifiers) {
2023
+ if (modifiers?.shiftKey) {
2024
+ selectMessageRange(message, isToggleClick(modifiers));
2025
+ return;
2026
+ }
2027
+ selectionAnchorRef.current = message.uid;
2028
+ if (isToggleClick(modifiers)) {
2029
+ setSelectMode(true);
2030
+ toggleSelected(message.uid);
2031
+ return;
2032
+ }
1897
2033
  if (selectMode) {
1898
2034
  toggleSelected(message.uid);
1899
2035
  return;
@@ -1929,6 +2065,7 @@ function InboxContent({ userUid }) {
1929
2065
  /** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
1930
2066
  * stands for, or the latest for a parent row. The thread pane loads the thread itself. */
1931
2067
  function handleOpenConversation(conversation, uid) {
2068
+ selectionAnchorRef.current = conversationKey(conversation);
1932
2069
  if (selectMode) {
1933
2070
  // Same rule as `handleSelect()` for a message row: while selecting, a row's own button ticks
1934
2071
  // the row rather than opening it.
@@ -1951,11 +2088,18 @@ function InboxContent({ userUid }) {
1951
2088
  const inConversations = asConversations;
1952
2089
  /** The selection bar isn't offered in the aggregate listing (its rows belong to several mailboxes), and neither are these - but the results of a
1953
2090
  * search over them are, each hit acted on in its own mailbox (see `inEachMailbox()`). */
1954
- const keyboardActions = !aggregateFolderType || isSearching;
2091
+ const keyboardActions = true;
1955
2092
  /** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
1956
2093
  const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
1957
2094
  const rowCount = inConversations ? listedConversations.length : messages.length;
1958
- const canMoveSelection = !selectMode && !isMobile && !loading && rowCount > 0;
2095
+ // Not while rows are collapsing out of the list: their indexes are about to shift.
2096
+ const canMoveSelection = !selectMode && !isMobile && !loading && rowCount > 0 && exitingKeys.size === 0;
2097
+ // Starts the collapse of the rows that have just been marked as leaving (see `animateRowsOut()`).
2098
+ useLayoutEffect(() => {
2099
+ if (exitingKeys.size > 0) {
2100
+ scrollContainerRef.current.querySelectorAll("[data-exiting]").forEach((row) => collapseRow(row));
2101
+ }
2102
+ }, [exitingKeys]);
1959
2103
  /** The type of a message's folder - looked for in every mailbox's, as a search over several has messages from each. */
1960
2104
  function folderTypeOf(folderUidOfMessage) {
1961
2105
  return mailboxFolders.flatMap((mf) => mf.folders).find((f) => f.uid === folderUidOfMessage)?.type;
@@ -2132,13 +2276,31 @@ function InboxContent({ userUid }) {
2132
2276
  : undefined;
2133
2277
  // Delete is the permanent one for what is in Deleted Items: for the selection when there is one (search results can come from any folder),
2134
2278
  // else for the folder being viewed, so the button says so before anything is ticked.
2135
- const deletesPermanently = selectedMessages.length > 0 ? selectedMessages.every(inDeletedItems) : currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
2136
- // Empty folder is offered at the top of the list of a mailbox's own Deleted Items or Junk Email - not for search results, nor for the "All
2137
- // mailboxes" views, which have no one folder to empty.
2138
- const emptiableFolder = !isSearching && !aggregateFolderType ? currentFolders.find((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type)) : undefined;
2279
+ const deletesPermanently = selectedMessages.length > 0
2280
+ ? selectedMessages.every(inDeletedItems)
2281
+ : aggregateFolderType
2282
+ ? aggregateFolderType === "deleted_items"
2283
+ : currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
2284
+ // Empty folder is offered at the top of the list of a mailbox's own Deleted Items or Junk Email, and of the "All mailboxes" view of either, which
2285
+ // empties each mailbox's own (a share the reader may not delete in refuses, and says so) - not for search results.
2286
+ const emptiableFolders = isSearching
2287
+ ? []
2288
+ : aggregateFolderType
2289
+ ? EMPTIABLE_FOLDER_TYPES.includes(aggregateFolderType)
2290
+ ? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType))
2291
+ : []
2292
+ : currentFolders.filter((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type));
2293
+ const emptiableFolder = emptiableFolders.length === 0
2294
+ ? undefined
2295
+ : {
2296
+ uid: emptiableFolders[0].uid,
2297
+ name: aggregateFolderType ? (emptiableFolders[0].type === "junk" ? "Junk Email" : "Deleted Items") : emptiableFolders[0].name,
2298
+ uids: aggregateFolderType ? emptiableFolders.map((f) => f.uid) : undefined,
2299
+ };
2139
2300
  // How many it holds as the sidebar's counts have it (kept right as messages leave), unless that says none while the list shows some.
2140
- const emptiableCount = emptiableFolder && folderCountOf(emptiableFolder).total > 0 ? folderCountOf(emptiableFolder).total : undefined;
2141
- const emptyDisabledReason = !mailboxWritable
2301
+ const emptiableTotal = emptiableFolders.reduce((sum, f) => sum + folderCountOf(f).total, 0);
2302
+ const emptiableCount = emptiableFolder && emptiableTotal > 0 ? emptiableTotal : undefined;
2303
+ const emptyDisabledReason = !(aggregateFolderType ? emptiableFolders.length > 0 : mailboxWritable)
2142
2304
  ? "This mailbox is shared with you view-only"
2143
2305
  : listedRowCount === 0 && emptiableCount === undefined
2144
2306
  ? "This folder is already empty"
@@ -2151,55 +2313,57 @@ function InboxContent({ userUid }) {
2151
2313
  // indistinguishable to anything (a test, a user re-reading the screen) that catches this one.
2152
2314
  return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Loading your mailbox\u2026" });
2153
2315
  }
2154
- return (_jsxs("div", { className: "flex h-full min-h-0", children: [_jsxs("div", { ref: scrollContainerRef,
2155
- // Remembered as it moves (not when the list is left: by then React has already detached the element), so
2156
- // the folder - or Mail, after another app - comes back where it was.
2157
- 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
2158
- ? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
2159
- : undefined, onSelectAll: () => asConversations
2160
- ? selectAllConversations()
2161
- : setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () => asConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set()), onCancel: leaveSelectMode, folders: foldersOf(selectionMailboxUid), currentFolderUid: folderUid, labels: mailboxLabels, mailboxUid: selectionMailboxUid, moveDisabledReason: moveDisabledReason, labelsDisabledReason: labelsDisabledReason, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated, onApplyLabels: applyLabelsToSelection, onSetRead: (read) => void runBulkAction(
2162
- // Optimistic: the rows and the folder badges change now, and change back if the server refuses.
2163
- (chosen) => setReadStateMany(chosen, read, { patch: patchListedMessage, track: trackMessageChange }), false), onSetFlagged: (flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged, client), false), shortcuts: keyboardActions, onArchive: () => void runBulkAction(bulkArchive, true), onMoveTo: async (targetFolderUid) => {
2164
- await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid, client), true);
2165
- }, onReportJunk: () => void moveSelectionToType("junk", "Junk Email"), onDelete: () => void (deletesPermanently
2166
- ? purgeChosen(selectedMessages)
2167
- : // A mix (search results from several folders) moves only what is not in Deleted Items yet.
2168
- moveSelectionToType("deleted_items", "Deleted Items", selectedMessages.filter((m) => !inDeletedItems(m)))), deletesPermanently: deletesPermanently, busy: bulkBusy || resolvingSelection > 0 || permanent.busy })) : (_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,
2169
- // A conversation row is a thread summary, so only some of the keys have anything to
2170
- // order by - the rest stay pickable and are applied to the rows already fetched.
2171
- unavailableSortKeys: asConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined, sortKeysNote: isSearching
2172
- ? "Search results are ranked by relevance rather than sorted."
2173
- : aggregateFolderType
2174
- ? "This view merges the newest mail from every mailbox and is always listed by date."
2175
- : asConversations
2176
- ? CONVERSATION_SORT_NOTE
2177
- : undefined, selectDisabled: (!!aggregateFolderType && !isSearching) || loading || listedRowCount === 0, selectDisabledReason: aggregateFolderType && !isSearching
2178
- ? "Open a mailbox's own folder to select messages"
2179
- : loading
2180
- ? "Wait for this folder to finish loading"
2181
- : "There is nothing here to select" })), !isMobile && _jsx("div", { className: "p-2 border-b border-border", children: searchField }), isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot), emptiableFolder && !selectMode && (_jsx(EmptyFolderBar, { folderName: emptiableFolder.name, count: emptiableCount, disabled: !!emptyDisabledReason || permanent.busy || bulkBusy, disabledReason: emptyDisabledReason, onEmpty: () => void emptyListedFolder(emptiableFolder, emptiableCount) })), permanent.dialog, 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: [
2182
- "flex-1 py-1.5 font-semibold",
2183
- preferences.filter === value
2184
- ? "text-primary-dark border-b-2 border-primary-dark"
2185
- : "text-text-muted",
2186
- ].join(" "), children: label }, value))) })), isSearching && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2", children: [_jsxs("span", { className: "flex flex-col", children: [_jsx("span", { children: tier1Done && tier2Done && tier3Done
2187
- ? `${messages.length} result${messages.length === 1 ? "" : "s"}`
2188
- : `${messages.length} of ??` }), crossMailbox && _jsx("span", { "data-testid": "search-scope", children: "All mailboxes" })] }), _jsxs("span", { className: "flex flex-col items-end gap-0.5", children: [tier2Done && !searchAllMail && (_jsx("button", { type: "button", onClick: handleSearchAllMail, title: crossMailbox
2189
- ? "Also search encrypted mail from before each mailbox's local index begins"
2190
- : undefined, className: "text-primary-dark hover:underline font-medium shrink-0", children: crossMailbox ? "Search older encrypted mail" : "Search all mail" })), widenable && (_jsx("button", { type: "button", onClick: () => setSearchAllMailboxes(!searchAllMailboxes), className: "text-primary-dark hover:underline font-medium shrink-0", children: searchAllMailboxes ? "Search this mailbox only" : "Search all mailboxes" }))] })] })), crossMailbox && isSearching && searchableMailboxes.length > searchedMailboxes.length && (_jsxs("div", { role: "status", className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["Searched ", searchedMailboxes.length, " of ", searchableMailboxes.length, " mailboxes - the most that are searched at once."] })), crossMailbox && isSearching && searchFailures.length > 0 && (_jsxs("div", { role: "status", className: "px-4 py-1.5 text-xs text-text-muted border-b border-border bg-surface-alt", children: ["Some results may be missing: ", describeFailures(searchFailures)] })), isSearching && !crossMailbox && 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
2191
- ? " - searching everything, not just recent mail."
2192
- : " - older encrypted mail is still searched, just slower."] })), isSearching && crossMailbox && tier2Done && serverOnlyMailboxes.length > 0 && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["There is no local index on this device for ", serverOnlyMailboxes.map((mb) => mb.displayName).join(", "), ": its encrypted mail is searched through the server instead, which is slower and looks at a limited number of messages."] })), isSearching && crossMailbox && lockedSearchMailboxes.length > 0 && (_jsxs("div", { className: "px-4 py-2 border-b border-border bg-surface-alt flex flex-col gap-1", children: [unlockableSearchMailboxes.length > 0 && (_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 from ", unlockableSearchMailboxes.map((mb) => mb.displayName).join(", ")] })), sharedLockedSearchMailboxes.length > 0 && (_jsxs("p", { className: "text-xs text-text-muted", children: ["Encrypted messages in ", sharedLockedSearchMailboxes.map((mb) => mb.displayName).join(", "), " are not included: only the mailbox\u2019s owner can unlock them."] }))] })), isSearching && !crossMailbox && !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: {
2193
- enabled: swipeEnabled,
2194
- onArchive: swipeArchive,
2195
- onMove: (conversation) => setMoveDialog({ conversation }),
2196
- } }), 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 ", rowCap, " 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
2197
- ? `No messages match "${searchQuery}".`
2198
- : effectiveFilter === "all"
2199
- ? "No messages in this folder."
2200
- : "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
2201
- ? 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.mailboxUid}\u0000${group.id}`)))
2202
- : 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 ", rowCap, " messages \u2014 refine your search or filters to see more."] })), aggregateFolderType && !isSearching && (_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: labelsMailboxUid, folders: foldersOf(labelsMailboxUid), labels: labels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : 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) => {
2316
+ return (_jsxs("div", { className: "flex h-full min-h-0", children: [_jsxs("div", { "data-testid": "mail-list-column", className: "w-full md:w-96 shrink-0 md:border-r border-border flex flex-col min-h-0", children: [_jsxs("div", { "data-testid": "mail-list-header", className: "shrink-0", children: [selectMode ? (_jsx(MailSelectionBar, { selected: selectedMessages, listed: messages, totals: asConversations
2317
+ ? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
2318
+ : undefined, onSelectAll: () => asConversations
2319
+ ? selectAllConversations()
2320
+ : setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () => asConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set()), onCancel: leaveSelectMode, folders: foldersOf(selectionMailboxUid), currentFolderUid: folderUid, labels: mailboxLabels, mailboxUid: selectionMailboxUid, moveDisabledReason: moveDisabledReason, labelsDisabledReason: labelsDisabledReason, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated, onApplyLabels: applyLabelsToSelection, onSetRead: (read) => void runBulkAction(
2321
+ // Optimistic: the rows and the folder badges change now, and change back if the server refuses.
2322
+ (chosen) => setReadStateMany(chosen, read, { patch: patchListedMessage, track: trackMessageChange }), false), onSetFlagged: (flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged, client), false), shortcuts: keyboardActions, onArchive: () => void runBulkAction(bulkArchive, true), onMoveTo: async (targetFolderUid) => {
2323
+ await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid, client), true);
2324
+ }, onReportJunk: () => void moveSelectionToType("junk", "Junk Email"), onDelete: () => void (deletesPermanently
2325
+ ? purgeChosen(selectedMessages)
2326
+ : // A mix (search results from several folders) moves only what is not in Deleted Items yet.
2327
+ moveSelectionToType("deleted_items", "Deleted Items", selectedMessages.filter((m) => !inDeletedItems(m)))), deletesPermanently: deletesPermanently, busy: bulkBusy || resolvingSelection > 0 || permanent.busy })) : (_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,
2328
+ // A conversation row is a thread summary, so only some of the keys have anything to
2329
+ // order by - the rest stay pickable and are applied to the rows already fetched.
2330
+ unavailableSortKeys: asConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined, sortKeysNote: isSearching
2331
+ ? "Search results are ranked by relevance rather than sorted."
2332
+ : aggregateFolderType
2333
+ ? "This view merges the newest mail from every mailbox and is always listed by date."
2334
+ : asConversations
2335
+ ? CONVERSATION_SORT_NOTE
2336
+ : undefined, selectDisabled: loading || listedRowCount === 0, selectDisabledReason: loading ? "Wait for this folder to finish loading" : "There is nothing here to select" })), !isMobile && _jsx("div", { className: "p-2 border-b border-border", children: searchField }), isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot), emptiableFolder && !selectMode && (_jsx(EmptyFolderBar, { folderName: emptiableFolder.name, count: emptiableCount, disabled: !!emptyDisabledReason || permanent.busy || bulkBusy, disabledReason: emptyDisabledReason, onEmpty: () => void emptyListedFolder(emptiableFolder, emptiableCount) })), permanent.dialog, 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: [
2337
+ "flex-1 py-1.5 font-semibold",
2338
+ preferences.filter === value
2339
+ ? "text-primary-dark border-b-2 border-primary-dark"
2340
+ : "text-text-muted",
2341
+ ].join(" "), children: label }, value))) }))] }), _jsxs("div", { ref: scrollContainerRef, "data-testid": "mail-list-scroll",
2342
+ // Remembered as it moves (not when the list is left: by then React has already detached the element), so
2343
+ // the folder - or Mail, after another app - comes back where it was.
2344
+ onScroll: (event) => saveListScroll(listKey, event.currentTarget.scrollTop),
2345
+ // A Shift+click selects a range: without this the browser would also select the text between the two rows.
2346
+ onMouseDown: (event) => {
2347
+ if (event.shiftKey) {
2348
+ event.preventDefault();
2349
+ }
2350
+ }, className: "flex-1 min-h-0 overflow-y-auto", children: [isSearching && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2", children: [_jsxs("span", { className: "flex flex-col", children: [_jsx("span", { children: tier1Done && tier2Done && tier3Done
2351
+ ? `${messages.length} result${messages.length === 1 ? "" : "s"}`
2352
+ : `${messages.length} of ??` }), crossMailbox && _jsx("span", { "data-testid": "search-scope", children: "All mailboxes" })] }), _jsxs("span", { className: "flex flex-col items-end gap-0.5", children: [tier2Done && !searchAllMail && (_jsx("button", { type: "button", onClick: handleSearchAllMail, title: crossMailbox
2353
+ ? "Also search encrypted mail from before each mailbox's local index begins"
2354
+ : undefined, className: "text-primary-dark hover:underline font-medium shrink-0", children: crossMailbox ? "Search older encrypted mail" : "Search all mail" })), widenable && (_jsx("button", { type: "button", onClick: () => setSearchAllMailboxes(!searchAllMailboxes), className: "text-primary-dark hover:underline font-medium shrink-0", children: searchAllMailboxes ? "Search this mailbox only" : "Search all mailboxes" }))] })] })), crossMailbox && isSearching && searchableMailboxes.length > searchedMailboxes.length && (_jsxs("div", { role: "status", className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["Searched ", searchedMailboxes.length, " of ", searchableMailboxes.length, " mailboxes - the most that are searched at once."] })), crossMailbox && isSearching && searchFailures.length > 0 && (_jsxs("div", { role: "status", className: "px-4 py-1.5 text-xs text-text-muted border-b border-border bg-surface-alt", children: ["Some results may be missing: ", describeFailures(searchFailures)] })), isSearching && !crossMailbox && 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
2355
+ ? " - searching everything, not just recent mail."
2356
+ : " - older encrypted mail is still searched, just slower."] })), isSearching && crossMailbox && tier2Done && serverOnlyMailboxes.length > 0 && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["There is no local index on this device for ", serverOnlyMailboxes.map((mb) => mb.displayName).join(", "), ": its encrypted mail is searched through the server instead, which is slower and looks at a limited number of messages."] })), isSearching && crossMailbox && lockedSearchMailboxes.length > 0 && (_jsxs("div", { className: "px-4 py-2 border-b border-border bg-surface-alt flex flex-col gap-1", children: [unlockableSearchMailboxes.length > 0 && (_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 from ", unlockableSearchMailboxes.map((mb) => mb.displayName).join(", ")] })), sharedLockedSearchMailboxes.length > 0 && (_jsxs("p", { className: "text-xs text-text-muted", children: ["Encrypted messages in ", sharedLockedSearchMailboxes.map((mb) => mb.displayName).join(", "), " are not included: only the mailbox\u2019s owner can unlock them."] }))] })), isSearching && !crossMailbox && !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: tickConversation, onSelectWithModifier: handleConversationModifierClick, exitingKeys: exitingKeys, swipe: {
2357
+ enabled: swipeEnabled,
2358
+ onArchive: swipeArchive,
2359
+ onMove: (conversation) => setMoveDialog({ conversation }),
2360
+ } }), 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 ", rowCap, " 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
2361
+ ? `No messages match "${searchQuery}".`
2362
+ : effectiveFilter === "all"
2363
+ ? "No messages in this folder."
2364
+ : "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
2365
+ ? 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.mailboxUid}\u0000${group.id}`)))
2366
+ : 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 ", rowCap, " messages \u2014 refine your search or filters to see more."] })), aggregateFolderType && !isSearching && (_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: labelsMailboxUid, folders: foldersOf(labelsMailboxUid), labels: labels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : 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) => {
2203
2367
  // Same reasoning as onArchived below - a move takes the message out of the folder
2204
2368
  // being listed, so it leaves the list rather than being patched in place.
2205
2369
  removeListedMessage(updated.uid);