@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.
- package/README.md +3 -0
- package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +22 -10
- package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +60 -0
- package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +108 -0
- package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +25 -1
- package/apps/shared/components/calendar/CalendarSearchBox.tsx +83 -0
- package/apps/shared/components/calendar/EventMatchNav.tsx +72 -0
- package/apps/shared/components/calendar/EventModal.tsx +33 -12
- package/apps/shared/components/calendar/ListView.tsx +207 -0
- package/apps/shared/components/calendar/MonthView.tsx +14 -2
- package/apps/shared/components/calendar/SlideIn.tsx +47 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +5 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +23 -12
- package/apps/shared/components/calendar/activeOccurrence.ts +28 -0
- package/apps/shared/components/calendar/allDay.ts +16 -1
- package/apps/shared/components/calendar/calendarView.ts +41 -0
- package/apps/shared/components/layout/AppShell.tsx +13 -6
- package/apps/shared/components/mail/ConversationList.tsx +32 -6
- package/apps/shared/components/mail/ConversationThreadPane.tsx +15 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +117 -55
- package/apps/shared/components/mail/MessageDetailPane.tsx +3 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +193 -35
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +62 -11
- package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +21 -10
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +2 -1
- package/apps/shared/components/mail/compose/quotedBody.ts +45 -2
- package/apps/shared/components/mail/labelMenu.tsx +4 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +9 -2
- package/apps/shared/components/mail/reading/MessageBody.tsx +1 -1
- package/apps/shared/components/mail/reading/bodyContent.ts +15 -13
- package/apps/shared/components/mail/reading/bodyHtml.ts +12 -7
- package/apps/shared/gestures/useEnterSlide.ts +1 -1
- package/apps/shared/keyboard/keymap.ts +2 -0
- package/apps/shared/mail/rangeSelection.ts +45 -0
- package/apps/shared/mail/rowExit.ts +39 -0
- package/apps/shared/mail/usePermanentDelete.tsx +14 -7
- package/apps/www/calendar/index.tsx +251 -53
- package/apps/www/index.tsx +563 -376
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +3 -3
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +15 -9
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +15 -0
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +16 -0
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +20 -0
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +31 -0
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +20 -1
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +7 -0
- package/dist/apps/shared/components/calendar/CalendarSearchBox.d.ts +22 -0
- package/dist/apps/shared/components/calendar/CalendarSearchBox.js +24 -0
- package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +17 -0
- package/dist/apps/shared/components/calendar/EventMatchNav.js +37 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +17 -1
- package/dist/apps/shared/components/calendar/EventModal.js +13 -11
- package/dist/apps/shared/components/calendar/ListView.d.ts +30 -0
- package/dist/apps/shared/components/calendar/ListView.js +111 -0
- package/dist/apps/shared/components/calendar/MonthView.js +12 -3
- package/dist/apps/shared/components/calendar/SlideIn.d.ts +16 -0
- package/dist/apps/shared/components/calendar/SlideIn.js +32 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +5 -1
- package/dist/apps/shared/components/calendar/TimeGridView.js +11 -3
- package/dist/apps/shared/components/calendar/activeOccurrence.d.ts +15 -0
- package/dist/apps/shared/components/calendar/activeOccurrence.js +21 -0
- package/dist/apps/shared/components/calendar/allDay.d.ts +4 -0
- package/dist/apps/shared/components/calendar/allDay.js +14 -1
- package/dist/apps/shared/components/calendar/calendarView.d.ts +16 -0
- package/dist/apps/shared/components/calendar/calendarView.js +36 -0
- package/dist/apps/shared/components/layout/AppShell.js +8 -4
- package/dist/apps/shared/components/mail/ConversationList.d.ts +11 -3
- package/dist/apps/shared/components/mail/ConversationList.js +11 -3
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +9 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
- package/dist/apps/shared/components/mail/MessageDetailPane.js +3 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +31 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +123 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +6 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +37 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +5 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +10 -6
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +2 -1
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +43 -1
- package/dist/apps/shared/components/mail/labelMenu.d.ts +3 -1
- package/dist/apps/shared/components/mail/labelMenu.js +2 -2
- package/dist/apps/shared/components/mail/layout/MailShell.js +7 -1
- package/dist/apps/shared/components/mail/reading/MessageBody.js +1 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +5 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.js +11 -11
- package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +3 -2
- package/dist/apps/shared/components/mail/reading/bodyHtml.js +8 -4
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +2 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +1 -1
- package/dist/apps/shared/keyboard/keymap.d.ts +12 -0
- package/dist/apps/shared/keyboard/keymap.js +2 -0
- package/dist/apps/shared/mail/rangeSelection.d.ts +20 -0
- package/dist/apps/shared/mail/rangeSelection.js +34 -0
- package/dist/apps/shared/mail/rowExit.d.ts +12 -0
- package/dist/apps/shared/mail/rowExit.js +33 -0
- package/dist/apps/shared/mail/usePermanentDelete.d.ts +2 -0
- package/dist/apps/shared/mail/usePermanentDelete.js +13 -8
- package/dist/apps/www/calendar/index.js +177 -18
- package/dist/apps/www/index.js +257 -93
- package/dist/lib/calendar/eventSearch.d.ts +42 -0
- package/dist/lib/calendar/eventSearch.js +152 -0
- package/dist/lib/components/navigation/BottomTabBar.d.ts +15 -3
- package/dist/lib/components/navigation/BottomTabBar.js +52 -15
- package/dist/lib/components/navigation/NavBadge.d.ts +13 -0
- package/dist/lib/components/navigation/NavBadge.js +18 -0
- package/dist/lib/mail/compose/quotedImages.d.ts +21 -0
- package/dist/lib/mail/compose/quotedImages.js +41 -0
- package/dist/lib/mail/inlineImages.d.ts +38 -0
- package/dist/lib/mail/inlineImages.js +90 -0
- package/dist/lib/mail/mailApi.d.ts +8 -1
- package/dist/lib/mail/mailApi.js +57 -5
- package/lib/calendar/eventSearch.ts +172 -0
- package/lib/components/navigation/BottomTabBar.tsx +107 -22
- package/lib/components/navigation/NavBadge.tsx +33 -0
- package/lib/mail/compose/quotedImages.ts +49 -0
- package/lib/mail/inlineImages.ts +104 -0
- package/lib/mail/mailApi.ts +64 -5
- package/package.json +1 -1
- package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +0 -36
- package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +0 -10
- package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +0 -11
package/apps/www/index.tsx
CHANGED
|
@@ -63,6 +63,8 @@ import MailShell, {
|
|
|
63
63
|
import MailAddress from "../shared/components/mail/MailAddress.js";
|
|
64
64
|
import OutboxRowStatus from "../shared/components/mail/OutboxRowStatus.js";
|
|
65
65
|
import { mergeFirstPage } from "../shared/mail/mergeFirstPage.js";
|
|
66
|
+
import { ClickModifiers, isToggleClick, rangeBetween, resolveAnchor } from "../shared/mail/rangeSelection.js";
|
|
67
|
+
import { ROW_EXIT_MS, canAnimateRowExit, collapseRow } from "../shared/mail/rowExit.js";
|
|
66
68
|
import { isOwnMailbox, orderMailboxes, primaryMailboxUid } from "../shared/mail/primaryMailbox.js";
|
|
67
69
|
import { listSnapshotKey, readListSnapshot, saveListScroll, writeListSnapshot } from "../shared/mail/listSnapshots.js";
|
|
68
70
|
import { setReadStateMany } from "../shared/mail/messageReadState.js";
|
|
@@ -80,7 +82,7 @@ import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
|
|
|
80
82
|
import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
|
|
81
83
|
import EmptyFolderBar from "../shared/components/mail/EmptyFolderBar.js";
|
|
82
84
|
import { EMPTIABLE_FOLDER_TYPES } from "../shared/mail/permanentDelete.js";
|
|
83
|
-
import { usePermanentDelete } from "../shared/mail/usePermanentDelete.js";
|
|
85
|
+
import { type EmptiableFolder, usePermanentDelete } from "../shared/mail/usePermanentDelete.js";
|
|
84
86
|
import { useMailboxUpdateAccess } from "../shared/mail/useMailboxUpdateAccess.js";
|
|
85
87
|
import {
|
|
86
88
|
CONVERSATION_SORT_NOTE,
|
|
@@ -770,6 +772,18 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
770
772
|
// Where the message the keyboard just removed from the list (deleted, archived, moved) was: the next Down/Up continues from that spot, so
|
|
771
773
|
// clearing an inbox from the keyboard walks down it instead of jumping back to the top. Cleared by any selection.
|
|
772
774
|
const removedAnchorRef = useRef<number | null>(null);
|
|
775
|
+
// The row a Shift+click range starts from: the row last clicked or opened (a `message.uid`, or a `conversationRowKey()` in the conversation list).
|
|
776
|
+
// 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
|
|
777
|
+
// Outlook; a plain click or a Ctrl+click moves it to the row clicked. Forgotten when the listing is replaced. See `resolveAnchor()`.
|
|
778
|
+
const selectionAnchorRef = useRef<string | null>(null);
|
|
779
|
+
// 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
|
|
780
|
+
// data while they collapse (`collapseRow()`, `ROW_EXIT_MS`), so the rows below slide up into the gap, and are dropped by `animateRowsOut()` after.
|
|
781
|
+
const [exitingKeys, setExitingKeys] = useState<Set<string>>(new Set());
|
|
782
|
+
const exitTimersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
|
783
|
+
useEffect(() => {
|
|
784
|
+
const timers = exitTimersRef.current;
|
|
785
|
+
return () => timers.forEach(clearTimeout);
|
|
786
|
+
}, []);
|
|
773
787
|
// State (via a callback ref), not a plain ref: the sentinel mounts and unmounts as the list loads,
|
|
774
788
|
// filters, and empties, and the observer effect below must re-attach to whichever node is current.
|
|
775
789
|
const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);
|
|
@@ -1002,12 +1016,6 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1002
1016
|
setSearchAllMailboxes(false);
|
|
1003
1017
|
}
|
|
1004
1018
|
}, [searchQuery]);
|
|
1005
|
-
// 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.
|
|
1006
|
-
useEffect(() => {
|
|
1007
|
-
if (aggregateFolderType && !isSearching) {
|
|
1008
|
-
setSelectMode(false);
|
|
1009
|
-
}
|
|
1010
|
-
}, [aggregateFolderType, isSearching]);
|
|
1011
1019
|
|
|
1012
1020
|
// Which listing this is, for the short-lived snapshot of each folder that makes going back to one instant (see
|
|
1013
1021
|
// `listSnapshots.ts`). A search, an aggregate view and a folder that hasn't resolved have no snapshot.
|
|
@@ -1046,6 +1054,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1046
1054
|
setSelectedUids(new Set());
|
|
1047
1055
|
setSelectedConversationIds(new Set());
|
|
1048
1056
|
setConversationMessagesById({});
|
|
1057
|
+
selectionAnchorRef.current = null;
|
|
1049
1058
|
listedOffsetRef.current = 0;
|
|
1050
1059
|
compositeCursorRef.current = undefined;
|
|
1051
1060
|
setHasMore(false);
|
|
@@ -1460,22 +1469,16 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1460
1469
|
// a listing still loading and a "load more" in flight, and for events about folders the list isn't showing - a
|
|
1461
1470
|
// conversation list, which can span folders, refreshes for any. Any failure is silent: the next tick tries again.
|
|
1462
1471
|
const liveRunRef = useRef(0);
|
|
1463
|
-
|
|
1464
|
-
|
|
1472
|
+
/**
|
|
1473
|
+
* 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
|
|
1474
|
+
* 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.
|
|
1475
|
+
* `listRun` is the listing the caller means: a refresh that finds another listing on screen by the time it lands is thrown away.
|
|
1476
|
+
*/
|
|
1477
|
+
function refreshListQuietly(listRun = searchRunIdRef.current) {
|
|
1478
|
+
if (isSearching || loading || loadMoreInFlightRef.current || (!folderUid && !aggregateFolderType)) {
|
|
1465
1479
|
return;
|
|
1466
1480
|
}
|
|
1467
|
-
if (!asConversations && live.folderUids) {
|
|
1468
|
-
const shown = new Set(
|
|
1469
|
-
aggregateFolderType
|
|
1470
|
-
? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
|
|
1471
|
-
: [folderUid!],
|
|
1472
|
-
);
|
|
1473
|
-
if (![...live.folderUids].some((uid) => shown.has(uid))) {
|
|
1474
|
-
return;
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
1481
|
// Superseded by a newer refresh, or by any reload of the list itself (which bumps the search run id).
|
|
1478
|
-
const listRun = searchRunIdRef.current;
|
|
1479
1482
|
const myRun = ++liveRunRef.current;
|
|
1480
1483
|
const isCurrent = () => searchRunIdRef.current === listRun && liveRunRef.current === myRun;
|
|
1481
1484
|
void (async () => {
|
|
@@ -1520,6 +1523,22 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1520
1523
|
// Quiet by design - see above.
|
|
1521
1524
|
}
|
|
1522
1525
|
})();
|
|
1526
|
+
}
|
|
1527
|
+
useEffect(() => {
|
|
1528
|
+
if (live.tick === 0) {
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
if (!asConversations && live.folderUids) {
|
|
1532
|
+
const shown = new Set(
|
|
1533
|
+
aggregateFolderType
|
|
1534
|
+
? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
|
|
1535
|
+
: [folderUid!],
|
|
1536
|
+
);
|
|
1537
|
+
if (![...live.folderUids].some((uid) => shown.has(uid))) {
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
refreshListQuietly();
|
|
1523
1542
|
}, [live.tick]);
|
|
1524
1543
|
|
|
1525
1544
|
function handleSearchAllMail() {
|
|
@@ -1794,14 +1813,66 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1794
1813
|
}
|
|
1795
1814
|
}
|
|
1796
1815
|
|
|
1816
|
+
/**
|
|
1817
|
+
* 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
|
|
1818
|
+
* `commit()` drops them from the data. `keys` are the rows' `exitingKeys`. With nothing to animate - none of them is listed, the browser can't
|
|
1819
|
+
* animate, or the reader prefers reduced motion - `commit()` runs at once.
|
|
1820
|
+
*/
|
|
1821
|
+
function animateRowsOut(keys: string[], commit: () => void) {
|
|
1822
|
+
if (keys.length === 0 || !canAnimateRowExit()) {
|
|
1823
|
+
commit();
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
setExitingKeys((prev) => new Set([...prev, ...keys]));
|
|
1827
|
+
const timer = setTimeout(() => {
|
|
1828
|
+
exitTimersRef.current.delete(timer);
|
|
1829
|
+
commit();
|
|
1830
|
+
setExitingKeys((prev) => {
|
|
1831
|
+
const next = new Set(prev);
|
|
1832
|
+
keys.forEach((key) => next.delete(key));
|
|
1833
|
+
return next;
|
|
1834
|
+
});
|
|
1835
|
+
}, ROW_EXIT_MS);
|
|
1836
|
+
exitTimersRef.current.add(timer);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/**
|
|
1840
|
+
* Takes the conversations whose every message is in `uids` out of the conversation list (animated), then quietly refreshes the list: a conversation
|
|
1841
|
+
* that keeps messages elsewhere stays, with its count and preview brought up to date by the refresh rather than by reloading the whole list.
|
|
1842
|
+
* `listRun` is the listing the caller acted on; if another is on screen by now there is nothing left to do.
|
|
1843
|
+
*/
|
|
1844
|
+
function removeListedConversations(uids: Set<string>, listRun = searchRunIdRef.current) {
|
|
1845
|
+
if (searchRunIdRef.current !== listRun) {
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
const leaving = new Set(conversationsRef.current.filter((c) => c.messageUids.every((uid) => uids.has(uid))).map(conversationKey));
|
|
1849
|
+
animateRowsOut([...leaving], () => {
|
|
1850
|
+
if (searchRunIdRef.current !== listRun) {
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
setConversations((prev) => prev.filter((c) => !leaving.has(conversationKey(c))));
|
|
1854
|
+
// The refresh below merges into the ref, which only catches up with the state on the next render.
|
|
1855
|
+
conversationsRef.current = conversationsRef.current.filter((c) => !leaving.has(conversationKey(c)));
|
|
1856
|
+
listedOffsetRef.current = Math.max(0, listedOffsetRef.current - leaving.size);
|
|
1857
|
+
refreshListQuietly(listRun);
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1797
1861
|
function removeListedMessages(uids: Set<string>) {
|
|
1798
1862
|
const selectedIndex = messagesRef.current.findIndex((m) => m.uid === selectedUid);
|
|
1799
1863
|
if (selectedUid && uids.has(selectedUid) && selectedIndex !== -1) {
|
|
1800
1864
|
removedAnchorRef.current = selectedIndex;
|
|
1801
1865
|
}
|
|
1802
|
-
setMessages((prev) => prev.filter((m) => !uids.has(m.uid)));
|
|
1803
|
-
listedOffsetRef.current = Math.max(0, listedOffsetRef.current - uids.size);
|
|
1804
1866
|
setSelectedUid((prev) => (prev && uids.has(prev) ? null : prev));
|
|
1867
|
+
if (asConversations) {
|
|
1868
|
+
removeListedConversations(uids);
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
listedOffsetRef.current = Math.max(0, listedOffsetRef.current - uids.size);
|
|
1872
|
+
animateRowsOut(
|
|
1873
|
+
messagesRef.current.filter((m) => uids.has(m.uid)).map((m) => m.uid),
|
|
1874
|
+
() => setMessages((prev) => prev.filter((m) => !uids.has(m.uid))),
|
|
1875
|
+
);
|
|
1805
1876
|
}
|
|
1806
1877
|
|
|
1807
1878
|
function removeListedMessage(uid: string) {
|
|
@@ -1842,7 +1913,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1842
1913
|
* Items copy of its reply), and the list only ever showed this folder's half of it, so a bulk action
|
|
1843
1914
|
* from here must not reach into the other folders' copies either. */
|
|
1844
1915
|
const selectedConversationMessages = [...selectedConversationIds].flatMap((id) =>
|
|
1845
|
-
(conversationMessagesById[id] ?? []).filter((m) => !folderUid || m.folderUid === folderUid),
|
|
1916
|
+
(conversationMessagesById[id] ?? []).filter((m) => (aggregateFolderType ? folderTypeOf(m.folderUid) === aggregateFolderType : !folderUid || m.folderUid === folderUid)),
|
|
1846
1917
|
);
|
|
1847
1918
|
const selectedMessages = asConversations
|
|
1848
1919
|
? selectedConversationMessages
|
|
@@ -1886,23 +1957,24 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1886
1957
|
/** Loads (once) the messages behind each of `ids`, so a ticked conversation resolves to the messages
|
|
1887
1958
|
* every bulk action below acts on. If any of them fails to load, none of that batch stays ticked and
|
|
1888
1959
|
* a pop-up says why - better than acting on the part of a selection that happened to arrive. */
|
|
1889
|
-
async function resolveConversations(
|
|
1890
|
-
const missing =
|
|
1960
|
+
async function resolveConversations(rows: ListedConversation[]) {
|
|
1961
|
+
const missing = rows.filter((row) => !conversationMessagesById[conversationKey(row)]);
|
|
1891
1962
|
if (missing.length === 0) {
|
|
1892
1963
|
return;
|
|
1893
1964
|
}
|
|
1894
1965
|
setResolvingSelection((n) => n + 1);
|
|
1895
1966
|
try {
|
|
1896
1967
|
const loaded = await Promise.all(
|
|
1897
|
-
|
|
1968
|
+
// A row of the "All mailboxes" view names its own mailbox; any other list is the open mailbox's.
|
|
1969
|
+
missing.map(async (row) => [conversationKey(row), await listConversationMessages(row.mailboxUid ?? activeMailboxUid, row.conversationId, {}, client)] as const),
|
|
1898
1970
|
);
|
|
1899
1971
|
setConversationMessagesById((prev) => ({ ...prev, ...Object.fromEntries(loaded) }));
|
|
1900
1972
|
} catch (err) {
|
|
1901
1973
|
notifyApiError(err, "Couldn't load the messages in one of those conversations");
|
|
1902
1974
|
setSelectedConversationIds((prev) => {
|
|
1903
1975
|
const next = new Set(prev);
|
|
1904
|
-
for (const
|
|
1905
|
-
next.delete(
|
|
1976
|
+
for (const row of missing) {
|
|
1977
|
+
next.delete(conversationKey(row));
|
|
1906
1978
|
}
|
|
1907
1979
|
return next;
|
|
1908
1980
|
});
|
|
@@ -1911,8 +1983,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1911
1983
|
}
|
|
1912
1984
|
}
|
|
1913
1985
|
|
|
1914
|
-
function toggleConversationSelected(conversation:
|
|
1915
|
-
const id = conversation
|
|
1986
|
+
function toggleConversationSelected(conversation: ListedConversation) {
|
|
1987
|
+
const id = conversationKey(conversation);
|
|
1916
1988
|
const ticking = !selectedConversationIds.has(id);
|
|
1917
1989
|
setSelectedConversationIds((prev) => {
|
|
1918
1990
|
const next = new Set(prev);
|
|
@@ -1924,14 +1996,62 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1924
1996
|
return next;
|
|
1925
1997
|
});
|
|
1926
1998
|
if (ticking) {
|
|
1927
|
-
void resolveConversations([
|
|
1999
|
+
void resolveConversations([conversation]);
|
|
1928
2000
|
}
|
|
1929
2001
|
}
|
|
1930
2002
|
|
|
1931
2003
|
function selectAllConversations() {
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
2004
|
+
setSelectedConversationIds(new Set(conversations.map(conversationKey)));
|
|
2005
|
+
void resolveConversations(conversations);
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* 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
|
|
2010
|
+
* row last clicked or opened (`selectionAnchorRef`), else the row open in the reading pane (`openKey`), else the clicked row itself - and it is
|
|
2011
|
+
* pinned where it was, not moved to `target`, so a second Shift+click elsewhere re-spans from the same row.
|
|
2012
|
+
*/
|
|
2013
|
+
function selectRange(keys: string[], target: string, openKey: string | null): string[] {
|
|
2014
|
+
const anchor = resolveAnchor(keys, selectionAnchorRef.current, openKey);
|
|
2015
|
+
selectionAnchorRef.current = anchor ?? target;
|
|
2016
|
+
setSelectMode(true);
|
|
2017
|
+
return rangeBetween(keys, anchor, target);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/** Shift+click on a message row. Plain Shift makes the range the whole selection; Ctrl+Shift (`extend`) adds it to what is ticked already. */
|
|
2021
|
+
function selectMessageRange(message: Message, extend: boolean) {
|
|
2022
|
+
const range = selectRange(
|
|
2023
|
+
displayedMessages.map((m) => m.uid),
|
|
2024
|
+
message.uid,
|
|
2025
|
+
selectedUid,
|
|
2026
|
+
);
|
|
2027
|
+
setSelectedUids((prev) => new Set(extend ? [...prev, ...range] : range));
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
/** 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. */
|
|
2031
|
+
function selectConversationRange(conversation: ListedConversation, extend: boolean) {
|
|
2032
|
+
const keys = listedConversations.map(conversationKey);
|
|
2033
|
+
const openIndex = selectedRowIndex();
|
|
2034
|
+
const range = selectRange(keys, conversationKey(conversation), openIndex === -1 ? null : keys[openIndex]);
|
|
2035
|
+
setSelectedConversationIds((prev) => new Set(extend ? [...prev, ...range] : range));
|
|
2036
|
+
const inRange = new Set(range);
|
|
2037
|
+
void resolveConversations(listedConversations.filter((c) => inRange.has(conversationKey(c))));
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/** A plain click on a conversation's checkbox: ticks (or unticks) it and makes it the anchor of the next Shift+click. */
|
|
2041
|
+
function tickConversation(conversation: ListedConversation) {
|
|
2042
|
+
selectionAnchorRef.current = conversationKey(conversation);
|
|
2043
|
+
toggleConversationSelected(conversation);
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/** 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). */
|
|
2047
|
+
function handleConversationModifierClick(conversation: ListedConversation, modifiers: ClickModifiers) {
|
|
2048
|
+
if (modifiers.shiftKey) {
|
|
2049
|
+
selectConversationRange(conversation, isToggleClick(modifiers));
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
// Ctrl (Cmd): just this row, and select mode if the reader was not in it.
|
|
2053
|
+
setSelectMode(true);
|
|
2054
|
+
tickConversation(conversation);
|
|
1935
2055
|
}
|
|
1936
2056
|
|
|
1937
2057
|
/**
|
|
@@ -1951,6 +2071,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1951
2071
|
removesRows: boolean,
|
|
1952
2072
|
chosen: Message[] = selectedMessages,
|
|
1953
2073
|
): Promise<boolean> {
|
|
2074
|
+
// The listing this acts on: the action takes a while, and the reader may have opened another folder by the time it is done.
|
|
2075
|
+
const listRun = searchRunIdRef.current;
|
|
1954
2076
|
setBulkBusy(true);
|
|
1955
2077
|
try {
|
|
1956
2078
|
const updated = await action(chosen);
|
|
@@ -1967,11 +2089,16 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1967
2089
|
}
|
|
1968
2090
|
if (asConversations) {
|
|
1969
2091
|
// A conversation row is a summary of its messages - its count, unread count, participants
|
|
1970
|
-
// and preview all move when a bulk action changes or empties part of it - so the
|
|
1971
|
-
//
|
|
2092
|
+
// and preview all move when a bulk action changes or empties part of it - so the rows that
|
|
2093
|
+
// went collapse away and the list is then refetched quietly (no skeleton, nothing forgotten)
|
|
2094
|
+
// rather than patched row by row.
|
|
1972
2095
|
setSelectedConversationIds(new Set());
|
|
1973
2096
|
setConversationMessagesById({});
|
|
1974
|
-
|
|
2097
|
+
if (removesRows) {
|
|
2098
|
+
removeListedConversations(new Set(chosen.map((m) => m.uid)), listRun);
|
|
2099
|
+
} else {
|
|
2100
|
+
refreshListQuietly(listRun);
|
|
2101
|
+
}
|
|
1975
2102
|
} else if (removesRows) {
|
|
1976
2103
|
removeListedMessages(new Set(chosen.map((m) => m.uid)));
|
|
1977
2104
|
} else {
|
|
@@ -2086,6 +2213,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2086
2213
|
* (those stay listed, and the reload shows what is really there). Resolves whether anything was deleted.
|
|
2087
2214
|
*/
|
|
2088
2215
|
async function purgeChosen(chosen: Message[]): Promise<boolean> {
|
|
2216
|
+
const listRun = searchRunIdRef.current;
|
|
2089
2217
|
const outcome = await permanent.requestPermanentDelete(chosen);
|
|
2090
2218
|
if (!outcome) {
|
|
2091
2219
|
return false;
|
|
@@ -2093,7 +2221,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2093
2221
|
if (asConversations) {
|
|
2094
2222
|
setSelectedConversationIds(new Set());
|
|
2095
2223
|
setConversationMessagesById({});
|
|
2096
|
-
|
|
2224
|
+
removeListedConversations(new Set(outcome.deleted.map((m) => m.uid)), listRun);
|
|
2097
2225
|
} else {
|
|
2098
2226
|
removeListedMessages(new Set(outcome.deleted.map((m) => m.uid)));
|
|
2099
2227
|
if (outcome.failed.length > 0) {
|
|
@@ -2105,7 +2233,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2105
2233
|
}
|
|
2106
2234
|
|
|
2107
2235
|
/** Empty folder: permanently deletes everything in the folder being listed (Deleted Items or Junk Email) after the reader has confirmed. */
|
|
2108
|
-
async function emptyListedFolder(folder:
|
|
2236
|
+
async function emptyListedFolder(folder: EmptiableFolder, count: number | undefined) {
|
|
2109
2237
|
const outcome = await permanent.requestEmptyFolder({ uid: folder.uid, name: folder.name }, count);
|
|
2110
2238
|
if (!outcome) {
|
|
2111
2239
|
return;
|
|
@@ -2185,6 +2313,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2185
2313
|
|
|
2186
2314
|
/** One message's row of the flat list - and, in search results grouped by conversation, of a group. */
|
|
2187
2315
|
function renderMessageRow(message: Message) {
|
|
2316
|
+
const exiting = exitingKeys.has(message.uid);
|
|
2188
2317
|
return (
|
|
2189
2318
|
<SwipeRow
|
|
2190
2319
|
as="li"
|
|
@@ -2194,9 +2323,12 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2194
2323
|
key={message.uid}
|
|
2195
2324
|
data-message-uid={message.uid}
|
|
2196
2325
|
data-unread={isUnread(message) ? "true" : undefined}
|
|
2326
|
+
data-exiting={exiting ? "true" : undefined}
|
|
2327
|
+
aria-hidden={exiting ? "true" : undefined}
|
|
2328
|
+
inert={exiting}
|
|
2197
2329
|
className={rowClass(
|
|
2198
2330
|
{ unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) },
|
|
2199
|
-
"flex items-stretch",
|
|
2331
|
+
exiting ? "flex items-stretch pointer-events-none" : "flex items-stretch",
|
|
2200
2332
|
)}
|
|
2201
2333
|
>
|
|
2202
2334
|
<UnreadBar unread={isUnread(message)} />
|
|
@@ -2205,7 +2337,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2205
2337
|
<input
|
|
2206
2338
|
type="checkbox"
|
|
2207
2339
|
checked={selectedUids.has(message.uid)}
|
|
2208
|
-
|
|
2340
|
+
// The change of a checkbox is caused by a click (or the Space key, which makes one): its modifiers are that event's.
|
|
2341
|
+
onChange={(event) => handleSelect(message, event.nativeEvent as MouseEvent)}
|
|
2209
2342
|
aria-label={`Select ${rowSubject(message)}`}
|
|
2210
2343
|
className="w-4 h-4 accent-primary"
|
|
2211
2344
|
/>
|
|
@@ -2216,7 +2349,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2216
2349
|
<button
|
|
2217
2350
|
type="button"
|
|
2218
2351
|
data-row-open
|
|
2219
|
-
onClick={() => handleSelect(message)}
|
|
2352
|
+
onClick={(event) => handleSelect(message, event)}
|
|
2220
2353
|
className={["w-full text-left px-4 py-3", ROW_FOCUS_CLASS].join(" ")}
|
|
2221
2354
|
>
|
|
2222
2355
|
<UnreadLabel unread={isUnread(message)} />
|
|
@@ -2272,6 +2405,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2272
2405
|
/** Search results arranged by conversation: the messages that matched, grouped under the conversation each belongs to, in the order of the
|
|
2273
2406
|
* first match of each - the order the results are ranked in. `null` when the list is not showing grouped results. */
|
|
2274
2407
|
const resultGroups = groupedResults ? groupByConversation(messages) : null;
|
|
2408
|
+
/** 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. */
|
|
2409
|
+
const displayedMessages = resultGroups ? resultGroups.flatMap((group) => group.messages) : messages;
|
|
2275
2410
|
|
|
2276
2411
|
/** The search box: in the list on a desktop (flat lists only), in the shell's header beside the folders button on a phone. */
|
|
2277
2412
|
const searchField = (
|
|
@@ -2286,7 +2421,22 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2286
2421
|
/>
|
|
2287
2422
|
);
|
|
2288
2423
|
|
|
2289
|
-
|
|
2424
|
+
/**
|
|
2425
|
+
* 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
|
|
2426
|
+
* 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);
|
|
2427
|
+
* a plain click ticks the row in select mode and opens it otherwise.
|
|
2428
|
+
*/
|
|
2429
|
+
function handleSelect(message: Message, modifiers?: ClickModifiers) {
|
|
2430
|
+
if (modifiers?.shiftKey) {
|
|
2431
|
+
selectMessageRange(message, isToggleClick(modifiers));
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
selectionAnchorRef.current = message.uid;
|
|
2435
|
+
if (isToggleClick(modifiers)) {
|
|
2436
|
+
setSelectMode(true);
|
|
2437
|
+
toggleSelected(message.uid);
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2290
2440
|
if (selectMode) {
|
|
2291
2441
|
toggleSelected(message.uid);
|
|
2292
2442
|
return;
|
|
@@ -2325,6 +2475,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2325
2475
|
/** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
|
|
2326
2476
|
* stands for, or the latest for a parent row. The thread pane loads the thread itself. */
|
|
2327
2477
|
function handleOpenConversation(conversation: ListedConversation, uid: string) {
|
|
2478
|
+
selectionAnchorRef.current = conversationKey(conversation);
|
|
2328
2479
|
if (selectMode) {
|
|
2329
2480
|
// Same rule as `handleSelect()` for a message row: while selecting, a row's own button ticks
|
|
2330
2481
|
// the row rather than opening it.
|
|
@@ -2348,11 +2499,19 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2348
2499
|
const inConversations = asConversations;
|
|
2349
2500
|
/** 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
|
|
2350
2501
|
* search over them are, each hit acted on in its own mailbox (see `inEachMailbox()`). */
|
|
2351
|
-
const keyboardActions =
|
|
2502
|
+
const keyboardActions = true;
|
|
2352
2503
|
/** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
|
|
2353
2504
|
const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
|
|
2354
2505
|
const rowCount = inConversations ? listedConversations.length : messages.length;
|
|
2355
|
-
|
|
2506
|
+
// Not while rows are collapsing out of the list: their indexes are about to shift.
|
|
2507
|
+
const canMoveSelection = !selectMode && !isMobile && !loading && rowCount > 0 && exitingKeys.size === 0;
|
|
2508
|
+
|
|
2509
|
+
// Starts the collapse of the rows that have just been marked as leaving (see `animateRowsOut()`).
|
|
2510
|
+
useLayoutEffect(() => {
|
|
2511
|
+
if (exitingKeys.size > 0) {
|
|
2512
|
+
scrollContainerRef.current!.querySelectorAll<HTMLElement>("[data-exiting]").forEach((row) => collapseRow(row));
|
|
2513
|
+
}
|
|
2514
|
+
}, [exitingKeys]);
|
|
2356
2515
|
|
|
2357
2516
|
/** The type of a message's folder - looked for in every mailbox's, as a search over several has messages from each. */
|
|
2358
2517
|
function folderTypeOf(folderUidOfMessage: string): Folder["type"] | undefined {
|
|
@@ -2586,13 +2745,33 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2586
2745
|
// 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),
|
|
2587
2746
|
// else for the folder being viewed, so the button says so before anything is ticked.
|
|
2588
2747
|
const deletesPermanently =
|
|
2589
|
-
selectedMessages.length > 0
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2748
|
+
selectedMessages.length > 0
|
|
2749
|
+
? selectedMessages.every(inDeletedItems)
|
|
2750
|
+
: aggregateFolderType
|
|
2751
|
+
? aggregateFolderType === "deleted_items"
|
|
2752
|
+
: currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
|
|
2753
|
+
// 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
|
|
2754
|
+
// empties each mailbox's own (a share the reader may not delete in refuses, and says so) - not for search results.
|
|
2755
|
+
const emptiableFolders: Folder[] =
|
|
2756
|
+
isSearching
|
|
2757
|
+
? []
|
|
2758
|
+
: aggregateFolderType
|
|
2759
|
+
? EMPTIABLE_FOLDER_TYPES.includes(aggregateFolderType)
|
|
2760
|
+
? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType))
|
|
2761
|
+
: []
|
|
2762
|
+
: currentFolders.filter((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type));
|
|
2763
|
+
const emptiableFolder: EmptiableFolder | undefined =
|
|
2764
|
+
emptiableFolders.length === 0
|
|
2765
|
+
? undefined
|
|
2766
|
+
: {
|
|
2767
|
+
uid: emptiableFolders[0].uid,
|
|
2768
|
+
name: aggregateFolderType ? (emptiableFolders[0].type === "junk" ? "Junk Email" : "Deleted Items") : emptiableFolders[0].name,
|
|
2769
|
+
uids: aggregateFolderType ? emptiableFolders.map((f) => f.uid) : undefined,
|
|
2770
|
+
};
|
|
2593
2771
|
// 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.
|
|
2594
|
-
const
|
|
2595
|
-
const
|
|
2772
|
+
const emptiableTotal = emptiableFolders.reduce((sum, f) => sum + folderCountOf(f).total, 0);
|
|
2773
|
+
const emptiableCount = emptiableFolder && emptiableTotal > 0 ? emptiableTotal : undefined;
|
|
2774
|
+
const emptyDisabledReason = !(aggregateFolderType ? emptiableFolders.length > 0 : mailboxWritable)
|
|
2596
2775
|
? "This mailbox is shared with you view-only"
|
|
2597
2776
|
: listedRowCount === 0 && emptiableCount === undefined
|
|
2598
2777
|
? "This folder is already empty"
|
|
@@ -2609,350 +2788,358 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2609
2788
|
|
|
2610
2789
|
return (
|
|
2611
2790
|
<div className="flex h-full min-h-0">
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
totals={
|
|
2624
|
-
asConversations
|
|
2625
|
-
? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
|
|
2626
|
-
: undefined
|
|
2627
|
-
}
|
|
2628
|
-
onSelectAll={() =>
|
|
2629
|
-
asConversations
|
|
2630
|
-
? selectAllConversations()
|
|
2631
|
-
: setSelectedUids(new Set(messages.map((m) => m.uid)))
|
|
2632
|
-
}
|
|
2633
|
-
onClearSelection={() =>
|
|
2634
|
-
asConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set())
|
|
2635
|
-
}
|
|
2636
|
-
onCancel={leaveSelectMode}
|
|
2637
|
-
folders={foldersOf(selectionMailboxUid)}
|
|
2638
|
-
currentFolderUid={folderUid}
|
|
2639
|
-
labels={mailboxLabels}
|
|
2640
|
-
mailboxUid={selectionMailboxUid}
|
|
2641
|
-
moveDisabledReason={moveDisabledReason}
|
|
2642
|
-
labelsDisabledReason={labelsDisabledReason}
|
|
2643
|
-
onLabelCreated={(label) => setMailboxLabels((prev) => [...prev, label])}
|
|
2644
|
-
onFolderCreated={onFolderCreated}
|
|
2645
|
-
onApplyLabels={applyLabelsToSelection}
|
|
2646
|
-
onSetRead={(read) =>
|
|
2647
|
-
void runBulkAction(
|
|
2648
|
-
// Optimistic: the rows and the folder badges change now, and change back if the server refuses.
|
|
2649
|
-
(chosen) => setReadStateMany(chosen, read, { patch: patchListedMessage, track: trackMessageChange }),
|
|
2650
|
-
false,
|
|
2651
|
-
)
|
|
2652
|
-
}
|
|
2653
|
-
onSetFlagged={(flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged, client), false)}
|
|
2654
|
-
shortcuts={keyboardActions}
|
|
2655
|
-
onArchive={() => void runBulkAction(bulkArchive, true)}
|
|
2656
|
-
onMoveTo={async (targetFolderUid) => {
|
|
2657
|
-
await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid, client), true);
|
|
2658
|
-
}}
|
|
2659
|
-
onReportJunk={() => void moveSelectionToType("junk", "Junk Email")}
|
|
2660
|
-
onDelete={() =>
|
|
2661
|
-
void (deletesPermanently
|
|
2662
|
-
? purgeChosen(selectedMessages)
|
|
2663
|
-
: // A mix (search results from several folders) moves only what is not in Deleted Items yet.
|
|
2664
|
-
moveSelectionToType("deleted_items", "Deleted Items", selectedMessages.filter((m) => !inDeletedItems(m))))
|
|
2665
|
-
}
|
|
2666
|
-
deletesPermanently={deletesPermanently}
|
|
2667
|
-
busy={bulkBusy || resolvingSelection > 0 || permanent.busy}
|
|
2668
|
-
/>
|
|
2669
|
-
) : (
|
|
2670
|
-
<MailListToolbar
|
|
2671
|
-
sortBy={preferences.sortBy}
|
|
2672
|
-
sortOrder={preferences.sortOrder}
|
|
2673
|
-
filter={preferences.filter}
|
|
2674
|
-
labelUids={preferences.labelUids}
|
|
2675
|
-
labels={mailboxLabels}
|
|
2676
|
-
mailboxUid={activeMailboxUid}
|
|
2677
|
-
onLabelCreated={(label) => setMailboxLabels((prev) => [...prev, label])}
|
|
2678
|
-
onLabelUidsChange={(nextLabelUids) => updatePreferences({ labelUids: nextLabelUids })}
|
|
2679
|
-
showAsConversations={preferences.showAsConversations}
|
|
2680
|
-
onSortChange={(sortBy, sortOrder) => updatePreferences({ sortBy, sortOrder })}
|
|
2681
|
-
onFilterChange={(filter) => updatePreferences({ filter })}
|
|
2682
|
-
onShowAsConversationsChange={(showAsConversations) => updatePreferences({ showAsConversations })}
|
|
2683
|
-
selectMode={selectMode}
|
|
2684
|
-
onSelectModeChange={setSelectMode}
|
|
2685
|
-
offerClassificationFilters={offerClassificationFilters}
|
|
2686
|
-
filterDisabled={isSearching}
|
|
2687
|
-
filterDisabledReason="Filters don't apply to search results"
|
|
2688
|
-
sortKeysDisabled={isSearching || !!aggregateFolderType}
|
|
2689
|
-
// A conversation row is a thread summary, so only some of the keys have anything to
|
|
2690
|
-
// order by - the rest stay pickable and are applied to the rows already fetched.
|
|
2691
|
-
unavailableSortKeys={asConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined}
|
|
2692
|
-
sortKeysNote={
|
|
2693
|
-
isSearching
|
|
2694
|
-
? "Search results are ranked by relevance rather than sorted."
|
|
2695
|
-
: aggregateFolderType
|
|
2696
|
-
? "This view merges the newest mail from every mailbox and is always listed by date."
|
|
2697
|
-
: asConversations
|
|
2698
|
-
? CONVERSATION_SORT_NOTE
|
|
2791
|
+
{/* The list column: the toolbar (or the selection bar), the search box and the Focused/Other tabs are a fixed block at the top and only
|
|
2792
|
+
the rows below scroll, each of the three columns (folders, list, reading pane) being its own scroller of the window's height. */}
|
|
2793
|
+
<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">
|
|
2794
|
+
<div data-testid="mail-list-header" className="shrink-0">
|
|
2795
|
+
{selectMode ? (
|
|
2796
|
+
<MailSelectionBar
|
|
2797
|
+
selected={selectedMessages}
|
|
2798
|
+
listed={messages}
|
|
2799
|
+
totals={
|
|
2800
|
+
asConversations
|
|
2801
|
+
? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
|
|
2699
2802
|
: undefined
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
{
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
{
|
|
2803
|
+
}
|
|
2804
|
+
onSelectAll={() =>
|
|
2805
|
+
asConversations
|
|
2806
|
+
? selectAllConversations()
|
|
2807
|
+
: setSelectedUids(new Set(messages.map((m) => m.uid)))
|
|
2808
|
+
}
|
|
2809
|
+
onClearSelection={() =>
|
|
2810
|
+
asConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set())
|
|
2811
|
+
}
|
|
2812
|
+
onCancel={leaveSelectMode}
|
|
2813
|
+
folders={foldersOf(selectionMailboxUid)}
|
|
2814
|
+
currentFolderUid={folderUid}
|
|
2815
|
+
labels={mailboxLabels}
|
|
2816
|
+
mailboxUid={selectionMailboxUid}
|
|
2817
|
+
moveDisabledReason={moveDisabledReason}
|
|
2818
|
+
labelsDisabledReason={labelsDisabledReason}
|
|
2819
|
+
onLabelCreated={(label) => setMailboxLabels((prev) => [...prev, label])}
|
|
2820
|
+
onFolderCreated={onFolderCreated}
|
|
2821
|
+
onApplyLabels={applyLabelsToSelection}
|
|
2822
|
+
onSetRead={(read) =>
|
|
2823
|
+
void runBulkAction(
|
|
2824
|
+
// Optimistic: the rows and the folder badges change now, and change back if the server refuses.
|
|
2825
|
+
(chosen) => setReadStateMany(chosen, read, { patch: patchListedMessage, track: trackMessageChange }),
|
|
2826
|
+
false,
|
|
2827
|
+
)
|
|
2828
|
+
}
|
|
2829
|
+
onSetFlagged={(flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged, client), false)}
|
|
2830
|
+
shortcuts={keyboardActions}
|
|
2831
|
+
onArchive={() => void runBulkAction(bulkArchive, true)}
|
|
2832
|
+
onMoveTo={async (targetFolderUid) => {
|
|
2833
|
+
await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid, client), true);
|
|
2834
|
+
}}
|
|
2835
|
+
onReportJunk={() => void moveSelectionToType("junk", "Junk Email")}
|
|
2836
|
+
onDelete={() =>
|
|
2837
|
+
void (deletesPermanently
|
|
2838
|
+
? purgeChosen(selectedMessages)
|
|
2839
|
+
: // A mix (search results from several folders) moves only what is not in Deleted Items yet.
|
|
2840
|
+
moveSelectionToType("deleted_items", "Deleted Items", selectedMessages.filter((m) => !inDeletedItems(m))))
|
|
2841
|
+
}
|
|
2842
|
+
deletesPermanently={deletesPermanently}
|
|
2843
|
+
busy={bulkBusy || resolvingSelection > 0 || permanent.busy}
|
|
2844
|
+
/>
|
|
2845
|
+
) : (
|
|
2846
|
+
<MailListToolbar
|
|
2847
|
+
sortBy={preferences.sortBy}
|
|
2848
|
+
sortOrder={preferences.sortOrder}
|
|
2849
|
+
filter={preferences.filter}
|
|
2850
|
+
labelUids={preferences.labelUids}
|
|
2851
|
+
labels={mailboxLabels}
|
|
2852
|
+
mailboxUid={activeMailboxUid}
|
|
2853
|
+
onLabelCreated={(label) => setMailboxLabels((prev) => [...prev, label])}
|
|
2854
|
+
onLabelUidsChange={(nextLabelUids) => updatePreferences({ labelUids: nextLabelUids })}
|
|
2855
|
+
showAsConversations={preferences.showAsConversations}
|
|
2856
|
+
onSortChange={(sortBy, sortOrder) => updatePreferences({ sortBy, sortOrder })}
|
|
2857
|
+
onFilterChange={(filter) => updatePreferences({ filter })}
|
|
2858
|
+
onShowAsConversationsChange={(showAsConversations) => updatePreferences({ showAsConversations })}
|
|
2859
|
+
selectMode={selectMode}
|
|
2860
|
+
onSelectModeChange={setSelectMode}
|
|
2861
|
+
offerClassificationFilters={offerClassificationFilters}
|
|
2862
|
+
filterDisabled={isSearching}
|
|
2863
|
+
filterDisabledReason="Filters don't apply to search results"
|
|
2864
|
+
sortKeysDisabled={isSearching || !!aggregateFolderType}
|
|
2865
|
+
// A conversation row is a thread summary, so only some of the keys have anything to
|
|
2866
|
+
// order by - the rest stay pickable and are applied to the rows already fetched.
|
|
2867
|
+
unavailableSortKeys={asConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined}
|
|
2868
|
+
sortKeysNote={
|
|
2869
|
+
isSearching
|
|
2870
|
+
? "Search results are ranked by relevance rather than sorted."
|
|
2871
|
+
: aggregateFolderType
|
|
2872
|
+
? "This view merges the newest mail from every mailbox and is always listed by date."
|
|
2873
|
+
: asConversations
|
|
2874
|
+
? CONVERSATION_SORT_NOTE
|
|
2875
|
+
: undefined
|
|
2876
|
+
}
|
|
2877
|
+
selectDisabled={loading || listedRowCount === 0}
|
|
2878
|
+
selectDisabledReason={loading ? "Wait for this folder to finish loading" : "There is nothing here to select"}
|
|
2879
|
+
/>
|
|
2880
|
+
)}
|
|
2881
|
+
{!isMobile && <div className="p-2 border-b border-border">{searchField}</div>}
|
|
2882
|
+
{isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot)}
|
|
2883
|
+
{emptiableFolder && !selectMode && (
|
|
2884
|
+
<EmptyFolderBar
|
|
2885
|
+
folderName={emptiableFolder.name}
|
|
2886
|
+
count={emptiableCount}
|
|
2887
|
+
disabled={!!emptyDisabledReason || permanent.busy || bulkBusy}
|
|
2888
|
+
disabledReason={emptyDisabledReason}
|
|
2889
|
+
onEmpty={() => void emptyListedFolder(emptiableFolder, emptiableCount)}
|
|
2890
|
+
/>
|
|
2891
|
+
)}
|
|
2892
|
+
{permanent.dialog}
|
|
2893
|
+
{/* Two tabs, as Outlook has: Focused and Other. There is no "All" tab - the whole Inbox is
|
|
2894
|
+
still one pick away, in the Filter menu, which is where every other named filter lives
|
|
2895
|
+
and the only place that can show which of them is really in force. A stored `all` (or
|
|
2896
|
+
Unread, Flagged, ...) therefore still lists what it always did, with neither tab
|
|
2897
|
+
pressed, rather than being migrated into one of these two halves behind the reader's
|
|
2898
|
+
back - see `MAIL_LIST_FILTERS`, which still offers it. */}
|
|
2899
|
+
{offerClassificationFilters && (
|
|
2900
|
+
<div className="flex border-b border-border text-xs">
|
|
2901
|
+
{MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (
|
|
2765
2902
|
<button
|
|
2903
|
+
key={value}
|
|
2766
2904
|
type="button"
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2905
|
+
aria-pressed={preferences.filter === value}
|
|
2906
|
+
onClick={() => updatePreferences({ filter: value })}
|
|
2907
|
+
className={[
|
|
2908
|
+
"flex-1 py-1.5 font-semibold",
|
|
2909
|
+
preferences.filter === value
|
|
2910
|
+
? "text-primary-dark border-b-2 border-primary-dark"
|
|
2911
|
+
: "text-text-muted",
|
|
2912
|
+
].join(" ")}
|
|
2774
2913
|
>
|
|
2775
|
-
{
|
|
2776
|
-
{crossMailbox ? "Search older encrypted mail" : "Search all mail"}
|
|
2914
|
+
{label}
|
|
2777
2915
|
</button>
|
|
2778
|
-
)}
|
|
2779
|
-
|
|
2916
|
+
))}
|
|
2917
|
+
</div>
|
|
2918
|
+
)}
|
|
2919
|
+
</div>
|
|
2920
|
+
<div
|
|
2921
|
+
ref={scrollContainerRef}
|
|
2922
|
+
data-testid="mail-list-scroll"
|
|
2923
|
+
// Remembered as it moves (not when the list is left: by then React has already detached the element), so
|
|
2924
|
+
// the folder - or Mail, after another app - comes back where it was.
|
|
2925
|
+
onScroll={(event) => saveListScroll(listKey, event.currentTarget.scrollTop)}
|
|
2926
|
+
// A Shift+click selects a range: without this the browser would also select the text between the two rows.
|
|
2927
|
+
onMouseDown={(event) => {
|
|
2928
|
+
if (event.shiftKey) {
|
|
2929
|
+
event.preventDefault();
|
|
2930
|
+
}
|
|
2931
|
+
}}
|
|
2932
|
+
className="flex-1 min-h-0 overflow-y-auto"
|
|
2933
|
+
>
|
|
2934
|
+
{isSearching && (
|
|
2935
|
+
<div className="px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2">
|
|
2936
|
+
{/* §_Progressive Results_: "Never show a hard count until every tier has reported.
|
|
2937
|
+
Display n of ??, or omit the count. A settled count is the signal that ordering
|
|
2938
|
+
is final." */}
|
|
2939
|
+
<span className="flex flex-col">
|
|
2940
|
+
<span>
|
|
2941
|
+
{tier1Done && tier2Done && tier3Done
|
|
2942
|
+
? `${messages.length} result${messages.length === 1 ? "" : "s"}`
|
|
2943
|
+
: `${messages.length} of ??`}
|
|
2944
|
+
</span>
|
|
2945
|
+
{crossMailbox && <span data-testid="search-scope">All mailboxes</span>}
|
|
2946
|
+
</span>
|
|
2947
|
+
<span className="flex flex-col items-end gap-0.5">
|
|
2948
|
+
{tier2Done && !searchAllMail && (
|
|
2949
|
+
<button
|
|
2950
|
+
type="button"
|
|
2951
|
+
onClick={handleSearchAllMail}
|
|
2952
|
+
title={
|
|
2953
|
+
crossMailbox
|
|
2954
|
+
? "Also search encrypted mail from before each mailbox's local index begins"
|
|
2955
|
+
: undefined
|
|
2956
|
+
}
|
|
2957
|
+
className="text-primary-dark hover:underline font-medium shrink-0"
|
|
2958
|
+
>
|
|
2959
|
+
{/* "All mail" already means every mailbox here, so this says what it adds. */}
|
|
2960
|
+
{crossMailbox ? "Search older encrypted mail" : "Search all mail"}
|
|
2961
|
+
</button>
|
|
2962
|
+
)}
|
|
2963
|
+
{widenable && (
|
|
2964
|
+
<button
|
|
2965
|
+
type="button"
|
|
2966
|
+
onClick={() => setSearchAllMailboxes(!searchAllMailboxes)}
|
|
2967
|
+
className="text-primary-dark hover:underline font-medium shrink-0"
|
|
2968
|
+
>
|
|
2969
|
+
{searchAllMailboxes ? "Search this mailbox only" : "Search all mailboxes"}
|
|
2970
|
+
</button>
|
|
2971
|
+
)}
|
|
2972
|
+
</span>
|
|
2973
|
+
</div>
|
|
2974
|
+
)}
|
|
2975
|
+
{crossMailbox && isSearching && searchableMailboxes.length > searchedMailboxes.length && (
|
|
2976
|
+
<div role="status" className="px-4 py-1.5 text-xs text-text-muted border-b border-border">
|
|
2977
|
+
Searched {searchedMailboxes.length} of {searchableMailboxes.length} mailboxes - the most that are searched at once.
|
|
2978
|
+
</div>
|
|
2979
|
+
)}
|
|
2980
|
+
{crossMailbox && isSearching && searchFailures.length > 0 && (
|
|
2981
|
+
<div role="status" className="px-4 py-1.5 text-xs text-text-muted border-b border-border bg-surface-alt">
|
|
2982
|
+
Some results may be missing: {describeFailures(searchFailures)}
|
|
2983
|
+
</div>
|
|
2984
|
+
)}
|
|
2985
|
+
{isSearching && !crossMailbox && coverage?.indexedFrom && (
|
|
2986
|
+
<div className="px-4 py-1.5 text-xs text-text-muted border-b border-border">
|
|
2987
|
+
Local search covers messages back to {new Date(coverage.indexedFrom).toLocaleDateString()}
|
|
2988
|
+
{coverage.building ? " (still building)" : ""}
|
|
2989
|
+
{searchAllMail
|
|
2990
|
+
? " - searching everything, not just recent mail."
|
|
2991
|
+
: " - older encrypted mail is still searched, just slower."}
|
|
2992
|
+
</div>
|
|
2993
|
+
)}
|
|
2994
|
+
{isSearching && crossMailbox && tier2Done && serverOnlyMailboxes.length > 0 && (
|
|
2995
|
+
<div className="px-4 py-1.5 text-xs text-text-muted border-b border-border">
|
|
2996
|
+
There is no local index on this device for {serverOnlyMailboxes.map((mb) => mb.displayName).join(", ")}: its encrypted mail is searched
|
|
2997
|
+
through the server instead, which is slower and looks at a limited number of messages.
|
|
2998
|
+
</div>
|
|
2999
|
+
)}
|
|
3000
|
+
{isSearching && crossMailbox && lockedSearchMailboxes.length > 0 && (
|
|
3001
|
+
<div className="px-4 py-2 border-b border-border bg-surface-alt flex flex-col gap-1">
|
|
3002
|
+
{unlockableSearchMailboxes.length > 0 && (
|
|
2780
3003
|
<button
|
|
2781
3004
|
type="button"
|
|
2782
|
-
onClick={
|
|
2783
|
-
className="text-primary-dark hover:underline
|
|
3005
|
+
onClick={handleUnlockSearch}
|
|
3006
|
+
className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
|
|
2784
3007
|
>
|
|
2785
|
-
{
|
|
3008
|
+
<HiOutlineLockClosed size={12} aria-hidden="true" />
|
|
3009
|
+
Unlock to include encrypted messages from {unlockableSearchMailboxes.map((mb) => mb.displayName).join(", ")}
|
|
2786
3010
|
</button>
|
|
2787
3011
|
)}
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
Some results may be missing: {describeFailures(searchFailures)}
|
|
2799
|
-
</div>
|
|
2800
|
-
)}
|
|
2801
|
-
{isSearching && !crossMailbox && coverage?.indexedFrom && (
|
|
2802
|
-
<div className="px-4 py-1.5 text-xs text-text-muted border-b border-border">
|
|
2803
|
-
Local search covers messages back to {new Date(coverage.indexedFrom).toLocaleDateString()}
|
|
2804
|
-
{coverage.building ? " (still building)" : ""}
|
|
2805
|
-
{searchAllMail
|
|
2806
|
-
? " - searching everything, not just recent mail."
|
|
2807
|
-
: " - older encrypted mail is still searched, just slower."}
|
|
2808
|
-
</div>
|
|
2809
|
-
)}
|
|
2810
|
-
{isSearching && crossMailbox && tier2Done && serverOnlyMailboxes.length > 0 && (
|
|
2811
|
-
<div className="px-4 py-1.5 text-xs text-text-muted border-b border-border">
|
|
2812
|
-
There is no local index on this device for {serverOnlyMailboxes.map((mb) => mb.displayName).join(", ")}: its encrypted mail is searched
|
|
2813
|
-
through the server instead, which is slower and looks at a limited number of messages.
|
|
2814
|
-
</div>
|
|
2815
|
-
)}
|
|
2816
|
-
{isSearching && crossMailbox && lockedSearchMailboxes.length > 0 && (
|
|
2817
|
-
<div className="px-4 py-2 border-b border-border bg-surface-alt flex flex-col gap-1">
|
|
2818
|
-
{unlockableSearchMailboxes.length > 0 && (
|
|
3012
|
+
{sharedLockedSearchMailboxes.length > 0 && (
|
|
3013
|
+
<p className="text-xs text-text-muted">
|
|
3014
|
+
Encrypted messages in {sharedLockedSearchMailboxes.map((mb) => mb.displayName).join(", ")} are not included: only the
|
|
3015
|
+
mailbox’s owner can unlock them.
|
|
3016
|
+
</p>
|
|
3017
|
+
)}
|
|
3018
|
+
</div>
|
|
3019
|
+
)}
|
|
3020
|
+
{isSearching && !crossMailbox && !getUnlockedKeys(mailboxUid!) && (
|
|
3021
|
+
<div className="px-4 py-2 border-b border-border bg-surface-alt">
|
|
2819
3022
|
<button
|
|
2820
3023
|
type="button"
|
|
2821
3024
|
onClick={handleUnlockSearch}
|
|
2822
3025
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
|
|
2823
3026
|
>
|
|
2824
3027
|
<HiOutlineLockClosed size={12} aria-hidden="true" />
|
|
2825
|
-
Unlock to include encrypted messages
|
|
3028
|
+
Unlock to include encrypted messages in these results
|
|
2826
3029
|
</button>
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
|
|
2842
|
-
>
|
|
2843
|
-
<HiOutlineLockClosed size={12} aria-hidden="true" />
|
|
2844
|
-
Unlock to include encrypted messages in these results
|
|
2845
|
-
</button>
|
|
2846
|
-
</div>
|
|
2847
|
-
)}
|
|
2848
|
-
{!isSearching && !asConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (
|
|
2849
|
-
<div className="px-4 py-2 border-b border-border bg-surface-alt">
|
|
2850
|
-
<button
|
|
2851
|
-
type="button"
|
|
2852
|
-
onClick={handleUnlockList}
|
|
2853
|
-
className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
|
|
2854
|
-
>
|
|
2855
|
-
<HiOutlineLockClosed size={12} aria-hidden="true" />
|
|
2856
|
-
Unlock to show {undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'"} subject
|
|
2857
|
-
</button>
|
|
2858
|
-
</div>
|
|
2859
|
-
)}
|
|
2860
|
-
|
|
2861
|
-
{error && (
|
|
2862
|
-
<div className="p-4">
|
|
2863
|
-
<Alert>{error}</Alert>
|
|
2864
|
-
</div>
|
|
2865
|
-
)}
|
|
3030
|
+
</div>
|
|
3031
|
+
)}
|
|
3032
|
+
{!isSearching && !asConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (
|
|
3033
|
+
<div className="px-4 py-2 border-b border-border bg-surface-alt">
|
|
3034
|
+
<button
|
|
3035
|
+
type="button"
|
|
3036
|
+
onClick={handleUnlockList}
|
|
3037
|
+
className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
|
|
3038
|
+
>
|
|
3039
|
+
<HiOutlineLockClosed size={12} aria-hidden="true" />
|
|
3040
|
+
Unlock to show {undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'"} subject
|
|
3041
|
+
</button>
|
|
3042
|
+
</div>
|
|
3043
|
+
)}
|
|
2866
3044
|
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
<
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
{
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
</>
|
|
2921
|
-
) : (
|
|
2922
|
-
<>
|
|
2923
|
-
<ul className={swipeEnabled ? "overflow-x-clip" : undefined}>
|
|
2924
|
-
{resultGroups
|
|
2925
|
-
? resultGroups.map((group) => (
|
|
2926
|
-
<li key={`${group.mailboxUid}\u0000${group.id}`} data-search-group={group.id}>
|
|
2927
|
-
<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">
|
|
2928
|
-
<span className="truncate font-semibold">{rowSubject(group.messages[0])}</span>
|
|
2929
|
-
<span className="shrink-0">
|
|
2930
|
-
{group.messages.length} matching message{group.messages.length === 1 ? "" : "s"}
|
|
2931
|
-
</span>
|
|
2932
|
-
</div>
|
|
2933
|
-
<ul>{group.messages.map(renderMessageRow)}</ul>
|
|
2934
|
-
</li>
|
|
2935
|
-
))
|
|
2936
|
-
: messages.map(renderMessageRow)}
|
|
2937
|
-
</ul>
|
|
2938
|
-
{hasMore && !rowCapReached && (
|
|
2939
|
-
<div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
|
|
2940
|
-
{loadMoreStatus}
|
|
2941
|
-
</div>
|
|
2942
|
-
)}
|
|
2943
|
-
{rowCapReached && (
|
|
2944
|
-
<p className="p-4 text-center text-xs text-text-muted">
|
|
2945
|
-
Showing the most recent {rowCap} messages — refine your search or filters to see more.
|
|
2946
|
-
</p>
|
|
2947
|
-
)}
|
|
2948
|
-
{aggregateFolderType && !isSearching && (
|
|
2949
|
-
<p className="p-4 text-center text-xs text-text-muted">
|
|
2950
|
-
Showing the most recent mail from each mailbox. Open a specific mailbox’s folder to
|
|
2951
|
-
see older mail.
|
|
3045
|
+
{error && (
|
|
3046
|
+
<div className="p-4">
|
|
3047
|
+
<Alert>{error}</Alert>
|
|
3048
|
+
</div>
|
|
3049
|
+
)}
|
|
3050
|
+
|
|
3051
|
+
{loading ? (
|
|
3052
|
+
// A skeleton of rows rather than a line of text: the list keeps its shape while a folder that was not shown a
|
|
3053
|
+
// moment ago loads (one that was is shown at once, from its snapshot).
|
|
3054
|
+
<div role="status" aria-busy="true" className="p-4">
|
|
3055
|
+
<span className="sr-only">Loading…</span>
|
|
3056
|
+
<SkeletonList count={8} />
|
|
3057
|
+
</div>
|
|
3058
|
+
) : asConversations ? (
|
|
3059
|
+
<>
|
|
3060
|
+
<ConversationList
|
|
3061
|
+
conversations={listedConversations}
|
|
3062
|
+
newestFirst={preferences.sortBy === "date" && preferences.sortOrder === "desc"}
|
|
3063
|
+
mailboxUid={activeMailboxUid}
|
|
3064
|
+
selectedUid={selectedUid}
|
|
3065
|
+
messageOverrides={conversationPatches}
|
|
3066
|
+
onMeetingResponded={patchListedMessage}
|
|
3067
|
+
onOpenMessage={handleOpenConversation}
|
|
3068
|
+
selectMode={selectMode}
|
|
3069
|
+
selectedConversationIds={selectedConversationIds}
|
|
3070
|
+
onToggleSelected={tickConversation}
|
|
3071
|
+
onSelectWithModifier={handleConversationModifierClick}
|
|
3072
|
+
exitingKeys={exitingKeys}
|
|
3073
|
+
swipe={{
|
|
3074
|
+
enabled: swipeEnabled,
|
|
3075
|
+
onArchive: swipeArchive,
|
|
3076
|
+
onMove: (conversation) => setMoveDialog({ conversation }),
|
|
3077
|
+
}}
|
|
3078
|
+
/>
|
|
3079
|
+
{hasMore && !rowCapReached && (
|
|
3080
|
+
<div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
|
|
3081
|
+
{loadMoreStatus}
|
|
3082
|
+
</div>
|
|
3083
|
+
)}
|
|
3084
|
+
{rowCapReached && (
|
|
3085
|
+
<p className="p-4 text-center text-xs text-text-muted">
|
|
3086
|
+
Showing the most recent {rowCap} conversations — refine your search or filters to see more.
|
|
3087
|
+
</p>
|
|
3088
|
+
)}
|
|
3089
|
+
</>
|
|
3090
|
+
) : messages.length === 0 ? (
|
|
3091
|
+
<>
|
|
3092
|
+
<p className="p-4 text-sm text-text-muted">
|
|
3093
|
+
{isSearching
|
|
3094
|
+
? `No messages match "${searchQuery}".`
|
|
3095
|
+
: effectiveFilter === "all"
|
|
3096
|
+
? "No messages in this folder."
|
|
3097
|
+
: "No messages here."}
|
|
2952
3098
|
</p>
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
3099
|
+
{/* Still offered while a filter hides every loaded row - what it's looking for may be
|
|
3100
|
+
on a later page. */}
|
|
3101
|
+
{hasMore && (
|
|
3102
|
+
<div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
|
|
3103
|
+
{loadMoreStatus}
|
|
3104
|
+
</div>
|
|
3105
|
+
)}
|
|
3106
|
+
</>
|
|
3107
|
+
) : (
|
|
3108
|
+
<>
|
|
3109
|
+
<ul className={swipeEnabled ? "overflow-x-clip" : undefined}>
|
|
3110
|
+
{resultGroups
|
|
3111
|
+
? resultGroups.map((group) => (
|
|
3112
|
+
<li key={`${group.mailboxUid}\u0000${group.id}`} data-search-group={group.id}>
|
|
3113
|
+
<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">
|
|
3114
|
+
<span className="truncate font-semibold">{rowSubject(group.messages[0])}</span>
|
|
3115
|
+
<span className="shrink-0">
|
|
3116
|
+
{group.messages.length} matching message{group.messages.length === 1 ? "" : "s"}
|
|
3117
|
+
</span>
|
|
3118
|
+
</div>
|
|
3119
|
+
<ul>{group.messages.map(renderMessageRow)}</ul>
|
|
3120
|
+
</li>
|
|
3121
|
+
))
|
|
3122
|
+
: messages.map(renderMessageRow)}
|
|
3123
|
+
</ul>
|
|
3124
|
+
{hasMore && !rowCapReached && (
|
|
3125
|
+
<div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
|
|
3126
|
+
{loadMoreStatus}
|
|
3127
|
+
</div>
|
|
3128
|
+
)}
|
|
3129
|
+
{rowCapReached && (
|
|
3130
|
+
<p className="p-4 text-center text-xs text-text-muted">
|
|
3131
|
+
Showing the most recent {rowCap} messages — refine your search or filters to see more.
|
|
3132
|
+
</p>
|
|
3133
|
+
)}
|
|
3134
|
+
{aggregateFolderType && !isSearching && (
|
|
3135
|
+
<p className="p-4 text-center text-xs text-text-muted">
|
|
3136
|
+
Showing the most recent mail from each mailbox. Open a specific mailbox’s folder to
|
|
3137
|
+
see older mail.
|
|
3138
|
+
</p>
|
|
3139
|
+
)}
|
|
3140
|
+
</>
|
|
3141
|
+
)}
|
|
3142
|
+
</div>
|
|
2956
3143
|
</div>
|
|
2957
3144
|
{/* The reading pane's own height: a row of the full-height mail view, stretched to it by the
|
|
2958
3145
|
flex chain rather than by a percentage (an explicit height would opt it out of that
|