@rapidmx/web-client 0.28.0 → 0.30.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 +38 -5
- 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/SwipeRow.tsx +26 -4
- 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/gestures/useLongPress.ts +138 -0
- 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/www/calendar/index.tsx +251 -53
- package/apps/www/index.tsx +570 -358
- 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 +16 -3
- package/dist/apps/shared/components/mail/ConversationList.js +10 -2
- 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/SwipeRow.d.ts +5 -1
- package/dist/apps/shared/components/mail/SwipeRow.js +17 -3
- 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/gestures/useLongPress.d.ts +41 -0
- package/dist/apps/shared/gestures/useLongPress.js +112 -0
- 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/www/calendar/index.js +177 -18
- package/dist/apps/www/index.js +257 -73
- 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";
|
|
@@ -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);
|
|
@@ -1040,6 +1054,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1040
1054
|
setSelectedUids(new Set());
|
|
1041
1055
|
setSelectedConversationIds(new Set());
|
|
1042
1056
|
setConversationMessagesById({});
|
|
1057
|
+
selectionAnchorRef.current = null;
|
|
1043
1058
|
listedOffsetRef.current = 0;
|
|
1044
1059
|
compositeCursorRef.current = undefined;
|
|
1045
1060
|
setHasMore(false);
|
|
@@ -1454,22 +1469,16 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1454
1469
|
// a listing still loading and a "load more" in flight, and for events about folders the list isn't showing - a
|
|
1455
1470
|
// conversation list, which can span folders, refreshes for any. Any failure is silent: the next tick tries again.
|
|
1456
1471
|
const liveRunRef = useRef(0);
|
|
1457
|
-
|
|
1458
|
-
|
|
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)) {
|
|
1459
1479
|
return;
|
|
1460
1480
|
}
|
|
1461
|
-
if (!asConversations && live.folderUids) {
|
|
1462
|
-
const shown = new Set(
|
|
1463
|
-
aggregateFolderType
|
|
1464
|
-
? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
|
|
1465
|
-
: [folderUid!],
|
|
1466
|
-
);
|
|
1467
|
-
if (![...live.folderUids].some((uid) => shown.has(uid))) {
|
|
1468
|
-
return;
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
1481
|
// Superseded by a newer refresh, or by any reload of the list itself (which bumps the search run id).
|
|
1472
|
-
const listRun = searchRunIdRef.current;
|
|
1473
1482
|
const myRun = ++liveRunRef.current;
|
|
1474
1483
|
const isCurrent = () => searchRunIdRef.current === listRun && liveRunRef.current === myRun;
|
|
1475
1484
|
void (async () => {
|
|
@@ -1514,6 +1523,22 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1514
1523
|
// Quiet by design - see above.
|
|
1515
1524
|
}
|
|
1516
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();
|
|
1517
1542
|
}, [live.tick]);
|
|
1518
1543
|
|
|
1519
1544
|
function handleSearchAllMail() {
|
|
@@ -1788,14 +1813,66 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1788
1813
|
}
|
|
1789
1814
|
}
|
|
1790
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
|
+
|
|
1791
1861
|
function removeListedMessages(uids: Set<string>) {
|
|
1792
1862
|
const selectedIndex = messagesRef.current.findIndex((m) => m.uid === selectedUid);
|
|
1793
1863
|
if (selectedUid && uids.has(selectedUid) && selectedIndex !== -1) {
|
|
1794
1864
|
removedAnchorRef.current = selectedIndex;
|
|
1795
1865
|
}
|
|
1796
|
-
setMessages((prev) => prev.filter((m) => !uids.has(m.uid)));
|
|
1797
|
-
listedOffsetRef.current = Math.max(0, listedOffsetRef.current - uids.size);
|
|
1798
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
|
+
);
|
|
1799
1876
|
}
|
|
1800
1877
|
|
|
1801
1878
|
function removeListedMessage(uid: string) {
|
|
@@ -1928,6 +2005,72 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1928
2005
|
void resolveConversations(conversations);
|
|
1929
2006
|
}
|
|
1930
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
|
+
/**
|
|
2041
|
+
* A press and hold on a phone: select mode, with this row the only one ticked and the anchor of the next Shift+click. From then on a tap ticks
|
|
2042
|
+
* (`handleSelect()`, `handleOpenConversation()`). A conversation is resolved to its messages as a ticked one is.
|
|
2043
|
+
*/
|
|
2044
|
+
function startSelectingMessage(message: Message) {
|
|
2045
|
+
selectionAnchorRef.current = message.uid;
|
|
2046
|
+
setSelectMode(true);
|
|
2047
|
+
setSelectedUids(new Set([message.uid]));
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
function startSelectingConversation(conversation: ListedConversation) {
|
|
2051
|
+
selectionAnchorRef.current = conversationKey(conversation);
|
|
2052
|
+
setSelectMode(true);
|
|
2053
|
+
setSelectedConversationIds(new Set([conversationKey(conversation)]));
|
|
2054
|
+
void resolveConversations([conversation]);
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
/** A plain click on a conversation's checkbox: ticks (or unticks) it and makes it the anchor of the next Shift+click. */
|
|
2058
|
+
function tickConversation(conversation: ListedConversation) {
|
|
2059
|
+
selectionAnchorRef.current = conversationKey(conversation);
|
|
2060
|
+
toggleConversationSelected(conversation);
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
/** 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). */
|
|
2064
|
+
function handleConversationModifierClick(conversation: ListedConversation, modifiers: ClickModifiers) {
|
|
2065
|
+
if (modifiers.shiftKey) {
|
|
2066
|
+
selectConversationRange(conversation, isToggleClick(modifiers));
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
// Ctrl (Cmd): just this row, and select mode if the reader was not in it.
|
|
2070
|
+
setSelectMode(true);
|
|
2071
|
+
tickConversation(conversation);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
1931
2074
|
/**
|
|
1932
2075
|
* Runs one bulk action over the current selection, then either patches the affected rows in place or
|
|
1933
2076
|
* drops them (a move takes them out of the folder being listed).
|
|
@@ -1945,6 +2088,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1945
2088
|
removesRows: boolean,
|
|
1946
2089
|
chosen: Message[] = selectedMessages,
|
|
1947
2090
|
): Promise<boolean> {
|
|
2091
|
+
// The listing this acts on: the action takes a while, and the reader may have opened another folder by the time it is done.
|
|
2092
|
+
const listRun = searchRunIdRef.current;
|
|
1948
2093
|
setBulkBusy(true);
|
|
1949
2094
|
try {
|
|
1950
2095
|
const updated = await action(chosen);
|
|
@@ -1961,11 +2106,16 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1961
2106
|
}
|
|
1962
2107
|
if (asConversations) {
|
|
1963
2108
|
// A conversation row is a summary of its messages - its count, unread count, participants
|
|
1964
|
-
// and preview all move when a bulk action changes or empties part of it - so the
|
|
1965
|
-
//
|
|
2109
|
+
// and preview all move when a bulk action changes or empties part of it - so the rows that
|
|
2110
|
+
// went collapse away and the list is then refetched quietly (no skeleton, nothing forgotten)
|
|
2111
|
+
// rather than patched row by row.
|
|
1966
2112
|
setSelectedConversationIds(new Set());
|
|
1967
2113
|
setConversationMessagesById({});
|
|
1968
|
-
|
|
2114
|
+
if (removesRows) {
|
|
2115
|
+
removeListedConversations(new Set(chosen.map((m) => m.uid)), listRun);
|
|
2116
|
+
} else {
|
|
2117
|
+
refreshListQuietly(listRun);
|
|
2118
|
+
}
|
|
1969
2119
|
} else if (removesRows) {
|
|
1970
2120
|
removeListedMessages(new Set(chosen.map((m) => m.uid)));
|
|
1971
2121
|
} else {
|
|
@@ -2080,6 +2230,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2080
2230
|
* (those stay listed, and the reload shows what is really there). Resolves whether anything was deleted.
|
|
2081
2231
|
*/
|
|
2082
2232
|
async function purgeChosen(chosen: Message[]): Promise<boolean> {
|
|
2233
|
+
const listRun = searchRunIdRef.current;
|
|
2083
2234
|
const outcome = await permanent.requestPermanentDelete(chosen);
|
|
2084
2235
|
if (!outcome) {
|
|
2085
2236
|
return false;
|
|
@@ -2087,7 +2238,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2087
2238
|
if (asConversations) {
|
|
2088
2239
|
setSelectedConversationIds(new Set());
|
|
2089
2240
|
setConversationMessagesById({});
|
|
2090
|
-
|
|
2241
|
+
removeListedConversations(new Set(outcome.deleted.map((m) => m.uid)), listRun);
|
|
2091
2242
|
} else {
|
|
2092
2243
|
removeListedMessages(new Set(outcome.deleted.map((m) => m.uid)));
|
|
2093
2244
|
if (outcome.failed.length > 0) {
|
|
@@ -2119,17 +2270,30 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2119
2270
|
|
|
2120
2271
|
// ---- Swiping a row on a phone (see `SwipeRow`): right to left archives, left to right asks for a folder. Both go through the same
|
|
2121
2272
|
// ---- bulk path as the selection bar's Archive and Move to, so the badges, the optimistic bookkeeping and the failure pop-up are one
|
|
2122
|
-
// ---- implementation.
|
|
2123
|
-
// ----
|
|
2124
|
-
|
|
2125
|
-
const
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
/**
|
|
2130
|
-
|
|
2273
|
+
// ---- implementation. Offered in every listing - a mailbox's own folders, the "All mailboxes" views and search results - and a row acts in
|
|
2274
|
+
// ---- its own mailbox (its Archive, its folders), as a bulk action does. Not offered for the Outbox (the server's send queue) or while rows
|
|
2275
|
+
// ---- are being ticked.
|
|
2276
|
+
const listingOutbox = !isSearching && currentFolders.find((f) => f.uid === folderUid)?.type === "outbox";
|
|
2277
|
+
const swipeEnabled = isMobile && !selectMode && !listingOutbox;
|
|
2278
|
+
/** What a swipe's Move to is about, while its folder prompt is open: one message, or every message of a conversation in the folder being listed. */
|
|
2279
|
+
const [moveDialog, setMoveDialog] = useState<{ messages: Message[] } | { conversation: ListedConversation } | null>(null);
|
|
2280
|
+
/** Where what is being moved is now: its mailbox - whose folders the prompt offers - and the folder it is in. */
|
|
2281
|
+
const movingFrom =
|
|
2282
|
+
moveDialog === null
|
|
2283
|
+
? null
|
|
2284
|
+
: "messages" in moveDialog
|
|
2285
|
+
? { mailboxUid: moveDialog.messages[0].mailboxUid, folderUid: moveDialog.messages[0].folderUid }
|
|
2286
|
+
: { mailboxUid: moveDialog.conversation.mailboxUid ?? activeMailboxUid, folderUid: moveDialog.conversation.latestFolderUid };
|
|
2287
|
+
|
|
2288
|
+
/**
|
|
2289
|
+
* The messages a swiped conversation stands for - the ones in the folder being listed - or `null` after saying why they couldn't be loaded. A row of
|
|
2290
|
+
* an "All mailboxes" view is a different mailbox's: its messages are asked for there, and only the aggregate's own folders' count (not the Sent Items
|
|
2291
|
+
* copy of a reply), as for a ticked row (`selectedConversationMessages`).
|
|
2292
|
+
*/
|
|
2293
|
+
async function swipedConversationMessages(conversation: ListedConversation): Promise<Message[] | null> {
|
|
2131
2294
|
try {
|
|
2132
|
-
|
|
2295
|
+
const loaded = await loadOpenConversation(conversation, conversation.mailboxUid ?? activeMailboxUid);
|
|
2296
|
+
return aggregateFolderType ? loaded.filter((m) => folderTypeOf(m.folderUid) === aggregateFolderType) : loaded;
|
|
2133
2297
|
} catch (err) {
|
|
2134
2298
|
notifyApiError(err, "Couldn't load the messages in that conversation");
|
|
2135
2299
|
return null;
|
|
@@ -2137,7 +2301,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2137
2301
|
}
|
|
2138
2302
|
|
|
2139
2303
|
/** Archives what a swipe took away. Resolves whether it went through, so the row comes back when it didn't. */
|
|
2140
|
-
async function swipeArchive(target: Message[] |
|
|
2304
|
+
async function swipeArchive(target: Message[] | ListedConversation): Promise<boolean> {
|
|
2141
2305
|
if (bulkBusy || resolvingSelection > 0) {
|
|
2142
2306
|
return false;
|
|
2143
2307
|
}
|
|
@@ -2179,18 +2343,24 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2179
2343
|
|
|
2180
2344
|
/** One message's row of the flat list - and, in search results grouped by conversation, of a group. */
|
|
2181
2345
|
function renderMessageRow(message: Message) {
|
|
2346
|
+
const exiting = exitingKeys.has(message.uid);
|
|
2182
2347
|
return (
|
|
2183
2348
|
<SwipeRow
|
|
2184
2349
|
as="li"
|
|
2185
|
-
|
|
2350
|
+
// A hit of a search over several mailboxes can be in an Outbox, which none of these actions are for.
|
|
2351
|
+
enabled={swipeEnabled && folderTypeOf(message.folderUid) !== "outbox"}
|
|
2186
2352
|
onArchive={() => swipeArchive([message])}
|
|
2187
2353
|
onMove={() => setMoveDialog({ messages: [message] })}
|
|
2354
|
+
onLongPress={isMobile && !selectMode ? () => startSelectingMessage(message) : undefined}
|
|
2188
2355
|
key={message.uid}
|
|
2189
2356
|
data-message-uid={message.uid}
|
|
2190
2357
|
data-unread={isUnread(message) ? "true" : undefined}
|
|
2358
|
+
data-exiting={exiting ? "true" : undefined}
|
|
2359
|
+
aria-hidden={exiting ? "true" : undefined}
|
|
2360
|
+
inert={exiting}
|
|
2191
2361
|
className={rowClass(
|
|
2192
2362
|
{ unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) },
|
|
2193
|
-
"flex items-stretch",
|
|
2363
|
+
exiting ? "flex items-stretch pointer-events-none" : "flex items-stretch",
|
|
2194
2364
|
)}
|
|
2195
2365
|
>
|
|
2196
2366
|
<UnreadBar unread={isUnread(message)} />
|
|
@@ -2199,7 +2369,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2199
2369
|
<input
|
|
2200
2370
|
type="checkbox"
|
|
2201
2371
|
checked={selectedUids.has(message.uid)}
|
|
2202
|
-
|
|
2372
|
+
// The change of a checkbox is caused by a click (or the Space key, which makes one): its modifiers are that event's.
|
|
2373
|
+
onChange={(event) => handleSelect(message, event.nativeEvent as MouseEvent)}
|
|
2203
2374
|
aria-label={`Select ${rowSubject(message)}`}
|
|
2204
2375
|
className="w-4 h-4 accent-primary"
|
|
2205
2376
|
/>
|
|
@@ -2210,7 +2381,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2210
2381
|
<button
|
|
2211
2382
|
type="button"
|
|
2212
2383
|
data-row-open
|
|
2213
|
-
onClick={() => handleSelect(message)}
|
|
2384
|
+
onClick={(event) => handleSelect(message, event)}
|
|
2214
2385
|
className={["w-full text-left px-4 py-3", ROW_FOCUS_CLASS].join(" ")}
|
|
2215
2386
|
>
|
|
2216
2387
|
<UnreadLabel unread={isUnread(message)} />
|
|
@@ -2266,6 +2437,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2266
2437
|
/** Search results arranged by conversation: the messages that matched, grouped under the conversation each belongs to, in the order of the
|
|
2267
2438
|
* first match of each - the order the results are ranked in. `null` when the list is not showing grouped results. */
|
|
2268
2439
|
const resultGroups = groupedResults ? groupByConversation(messages) : null;
|
|
2440
|
+
/** 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. */
|
|
2441
|
+
const displayedMessages = resultGroups ? resultGroups.flatMap((group) => group.messages) : messages;
|
|
2269
2442
|
|
|
2270
2443
|
/** The search box: in the list on a desktop (flat lists only), in the shell's header beside the folders button on a phone. */
|
|
2271
2444
|
const searchField = (
|
|
@@ -2280,7 +2453,22 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2280
2453
|
/>
|
|
2281
2454
|
);
|
|
2282
2455
|
|
|
2283
|
-
|
|
2456
|
+
/**
|
|
2457
|
+
* 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
|
|
2458
|
+
* 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);
|
|
2459
|
+
* a plain click ticks the row in select mode and opens it otherwise.
|
|
2460
|
+
*/
|
|
2461
|
+
function handleSelect(message: Message, modifiers?: ClickModifiers) {
|
|
2462
|
+
if (modifiers?.shiftKey) {
|
|
2463
|
+
selectMessageRange(message, isToggleClick(modifiers));
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
selectionAnchorRef.current = message.uid;
|
|
2467
|
+
if (isToggleClick(modifiers)) {
|
|
2468
|
+
setSelectMode(true);
|
|
2469
|
+
toggleSelected(message.uid);
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2284
2472
|
if (selectMode) {
|
|
2285
2473
|
toggleSelected(message.uid);
|
|
2286
2474
|
return;
|
|
@@ -2319,6 +2507,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2319
2507
|
/** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
|
|
2320
2508
|
* stands for, or the latest for a parent row. The thread pane loads the thread itself. */
|
|
2321
2509
|
function handleOpenConversation(conversation: ListedConversation, uid: string) {
|
|
2510
|
+
selectionAnchorRef.current = conversationKey(conversation);
|
|
2322
2511
|
if (selectMode) {
|
|
2323
2512
|
// Same rule as `handleSelect()` for a message row: while selecting, a row's own button ticks
|
|
2324
2513
|
// the row rather than opening it.
|
|
@@ -2346,7 +2535,15 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2346
2535
|
/** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
|
|
2347
2536
|
const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
|
|
2348
2537
|
const rowCount = inConversations ? listedConversations.length : messages.length;
|
|
2349
|
-
|
|
2538
|
+
// Not while rows are collapsing out of the list: their indexes are about to shift.
|
|
2539
|
+
const canMoveSelection = !selectMode && !isMobile && !loading && rowCount > 0 && exitingKeys.size === 0;
|
|
2540
|
+
|
|
2541
|
+
// Starts the collapse of the rows that have just been marked as leaving (see `animateRowsOut()`).
|
|
2542
|
+
useLayoutEffect(() => {
|
|
2543
|
+
if (exitingKeys.size > 0) {
|
|
2544
|
+
scrollContainerRef.current!.querySelectorAll<HTMLElement>("[data-exiting]").forEach((row) => collapseRow(row));
|
|
2545
|
+
}
|
|
2546
|
+
}, [exitingKeys]);
|
|
2350
2547
|
|
|
2351
2548
|
/** The type of a message's folder - looked for in every mailbox's, as a search over several has messages from each. */
|
|
2352
2549
|
function folderTypeOf(folderUidOfMessage: string): Folder["type"] | undefined {
|
|
@@ -2623,344 +2820,359 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2623
2820
|
|
|
2624
2821
|
return (
|
|
2625
2822
|
<div className="flex h-full min-h-0">
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
totals={
|
|
2638
|
-
asConversations
|
|
2639
|
-
? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
|
|
2640
|
-
: undefined
|
|
2641
|
-
}
|
|
2642
|
-
onSelectAll={() =>
|
|
2643
|
-
asConversations
|
|
2644
|
-
? selectAllConversations()
|
|
2645
|
-
: setSelectedUids(new Set(messages.map((m) => m.uid)))
|
|
2646
|
-
}
|
|
2647
|
-
onClearSelection={() =>
|
|
2648
|
-
asConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set())
|
|
2649
|
-
}
|
|
2650
|
-
onCancel={leaveSelectMode}
|
|
2651
|
-
folders={foldersOf(selectionMailboxUid)}
|
|
2652
|
-
currentFolderUid={folderUid}
|
|
2653
|
-
labels={mailboxLabels}
|
|
2654
|
-
mailboxUid={selectionMailboxUid}
|
|
2655
|
-
moveDisabledReason={moveDisabledReason}
|
|
2656
|
-
labelsDisabledReason={labelsDisabledReason}
|
|
2657
|
-
onLabelCreated={(label) => setMailboxLabels((prev) => [...prev, label])}
|
|
2658
|
-
onFolderCreated={onFolderCreated}
|
|
2659
|
-
onApplyLabels={applyLabelsToSelection}
|
|
2660
|
-
onSetRead={(read) =>
|
|
2661
|
-
void runBulkAction(
|
|
2662
|
-
// Optimistic: the rows and the folder badges change now, and change back if the server refuses.
|
|
2663
|
-
(chosen) => setReadStateMany(chosen, read, { patch: patchListedMessage, track: trackMessageChange }),
|
|
2664
|
-
false,
|
|
2665
|
-
)
|
|
2666
|
-
}
|
|
2667
|
-
onSetFlagged={(flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged, client), false)}
|
|
2668
|
-
shortcuts={keyboardActions}
|
|
2669
|
-
onArchive={() => void runBulkAction(bulkArchive, true)}
|
|
2670
|
-
onMoveTo={async (targetFolderUid) => {
|
|
2671
|
-
await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid, client), true);
|
|
2672
|
-
}}
|
|
2673
|
-
onReportJunk={() => void moveSelectionToType("junk", "Junk Email")}
|
|
2674
|
-
onDelete={() =>
|
|
2675
|
-
void (deletesPermanently
|
|
2676
|
-
? purgeChosen(selectedMessages)
|
|
2677
|
-
: // A mix (search results from several folders) moves only what is not in Deleted Items yet.
|
|
2678
|
-
moveSelectionToType("deleted_items", "Deleted Items", selectedMessages.filter((m) => !inDeletedItems(m))))
|
|
2679
|
-
}
|
|
2680
|
-
deletesPermanently={deletesPermanently}
|
|
2681
|
-
busy={bulkBusy || resolvingSelection > 0 || permanent.busy}
|
|
2682
|
-
/>
|
|
2683
|
-
) : (
|
|
2684
|
-
<MailListToolbar
|
|
2685
|
-
sortBy={preferences.sortBy}
|
|
2686
|
-
sortOrder={preferences.sortOrder}
|
|
2687
|
-
filter={preferences.filter}
|
|
2688
|
-
labelUids={preferences.labelUids}
|
|
2689
|
-
labels={mailboxLabels}
|
|
2690
|
-
mailboxUid={activeMailboxUid}
|
|
2691
|
-
onLabelCreated={(label) => setMailboxLabels((prev) => [...prev, label])}
|
|
2692
|
-
onLabelUidsChange={(nextLabelUids) => updatePreferences({ labelUids: nextLabelUids })}
|
|
2693
|
-
showAsConversations={preferences.showAsConversations}
|
|
2694
|
-
onSortChange={(sortBy, sortOrder) => updatePreferences({ sortBy, sortOrder })}
|
|
2695
|
-
onFilterChange={(filter) => updatePreferences({ filter })}
|
|
2696
|
-
onShowAsConversationsChange={(showAsConversations) => updatePreferences({ showAsConversations })}
|
|
2697
|
-
selectMode={selectMode}
|
|
2698
|
-
onSelectModeChange={setSelectMode}
|
|
2699
|
-
offerClassificationFilters={offerClassificationFilters}
|
|
2700
|
-
filterDisabled={isSearching}
|
|
2701
|
-
filterDisabledReason="Filters don't apply to search results"
|
|
2702
|
-
sortKeysDisabled={isSearching || !!aggregateFolderType}
|
|
2703
|
-
// A conversation row is a thread summary, so only some of the keys have anything to
|
|
2704
|
-
// order by - the rest stay pickable and are applied to the rows already fetched.
|
|
2705
|
-
unavailableSortKeys={asConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined}
|
|
2706
|
-
sortKeysNote={
|
|
2707
|
-
isSearching
|
|
2708
|
-
? "Search results are ranked by relevance rather than sorted."
|
|
2709
|
-
: aggregateFolderType
|
|
2710
|
-
? "This view merges the newest mail from every mailbox and is always listed by date."
|
|
2711
|
-
: asConversations
|
|
2712
|
-
? CONVERSATION_SORT_NOTE
|
|
2823
|
+
{/* 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
|
|
2824
|
+
the rows below scroll, each of the three columns (folders, list, reading pane) being its own scroller of the window's height. */}
|
|
2825
|
+
<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">
|
|
2826
|
+
<div data-testid="mail-list-header" className="shrink-0">
|
|
2827
|
+
{selectMode ? (
|
|
2828
|
+
<MailSelectionBar
|
|
2829
|
+
selected={selectedMessages}
|
|
2830
|
+
listed={messages}
|
|
2831
|
+
totals={
|
|
2832
|
+
asConversations
|
|
2833
|
+
? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
|
|
2713
2834
|
: undefined
|
|
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
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
{
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
{
|
|
2835
|
+
}
|
|
2836
|
+
onSelectAll={() =>
|
|
2837
|
+
asConversations
|
|
2838
|
+
? selectAllConversations()
|
|
2839
|
+
: setSelectedUids(new Set(messages.map((m) => m.uid)))
|
|
2840
|
+
}
|
|
2841
|
+
onClearSelection={() =>
|
|
2842
|
+
asConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set())
|
|
2843
|
+
}
|
|
2844
|
+
onCancel={leaveSelectMode}
|
|
2845
|
+
folders={foldersOf(selectionMailboxUid)}
|
|
2846
|
+
currentFolderUid={folderUid}
|
|
2847
|
+
labels={mailboxLabels}
|
|
2848
|
+
mailboxUid={selectionMailboxUid}
|
|
2849
|
+
moveDisabledReason={moveDisabledReason}
|
|
2850
|
+
labelsDisabledReason={labelsDisabledReason}
|
|
2851
|
+
onLabelCreated={(label) => setMailboxLabels((prev) => [...prev, label])}
|
|
2852
|
+
onFolderCreated={onFolderCreated}
|
|
2853
|
+
onApplyLabels={applyLabelsToSelection}
|
|
2854
|
+
onSetRead={(read) =>
|
|
2855
|
+
void runBulkAction(
|
|
2856
|
+
// Optimistic: the rows and the folder badges change now, and change back if the server refuses.
|
|
2857
|
+
(chosen) => setReadStateMany(chosen, read, { patch: patchListedMessage, track: trackMessageChange }),
|
|
2858
|
+
false,
|
|
2859
|
+
)
|
|
2860
|
+
}
|
|
2861
|
+
onSetFlagged={(flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged, client), false)}
|
|
2862
|
+
shortcuts={keyboardActions}
|
|
2863
|
+
onArchive={() => void runBulkAction(bulkArchive, true)}
|
|
2864
|
+
onMoveTo={async (targetFolderUid) => {
|
|
2865
|
+
await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid, client), true);
|
|
2866
|
+
}}
|
|
2867
|
+
onReportJunk={() => void moveSelectionToType("junk", "Junk Email")}
|
|
2868
|
+
onDelete={() =>
|
|
2869
|
+
void (deletesPermanently
|
|
2870
|
+
? purgeChosen(selectedMessages)
|
|
2871
|
+
: // A mix (search results from several folders) moves only what is not in Deleted Items yet.
|
|
2872
|
+
moveSelectionToType("deleted_items", "Deleted Items", selectedMessages.filter((m) => !inDeletedItems(m))))
|
|
2873
|
+
}
|
|
2874
|
+
deletesPermanently={deletesPermanently}
|
|
2875
|
+
busy={bulkBusy || resolvingSelection > 0 || permanent.busy}
|
|
2876
|
+
/>
|
|
2877
|
+
) : (
|
|
2878
|
+
<MailListToolbar
|
|
2879
|
+
sortBy={preferences.sortBy}
|
|
2880
|
+
sortOrder={preferences.sortOrder}
|
|
2881
|
+
filter={preferences.filter}
|
|
2882
|
+
labelUids={preferences.labelUids}
|
|
2883
|
+
labels={mailboxLabels}
|
|
2884
|
+
mailboxUid={activeMailboxUid}
|
|
2885
|
+
onLabelCreated={(label) => setMailboxLabels((prev) => [...prev, label])}
|
|
2886
|
+
onLabelUidsChange={(nextLabelUids) => updatePreferences({ labelUids: nextLabelUids })}
|
|
2887
|
+
showAsConversations={preferences.showAsConversations}
|
|
2888
|
+
onSortChange={(sortBy, sortOrder) => updatePreferences({ sortBy, sortOrder })}
|
|
2889
|
+
onFilterChange={(filter) => updatePreferences({ filter })}
|
|
2890
|
+
onShowAsConversationsChange={(showAsConversations) => updatePreferences({ showAsConversations })}
|
|
2891
|
+
selectMode={selectMode}
|
|
2892
|
+
onSelectModeChange={setSelectMode}
|
|
2893
|
+
offerClassificationFilters={offerClassificationFilters}
|
|
2894
|
+
filterDisabled={isSearching}
|
|
2895
|
+
filterDisabledReason="Filters don't apply to search results"
|
|
2896
|
+
sortKeysDisabled={isSearching || !!aggregateFolderType}
|
|
2897
|
+
// A conversation row is a thread summary, so only some of the keys have anything to
|
|
2898
|
+
// order by - the rest stay pickable and are applied to the rows already fetched.
|
|
2899
|
+
unavailableSortKeys={asConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined}
|
|
2900
|
+
sortKeysNote={
|
|
2901
|
+
isSearching
|
|
2902
|
+
? "Search results are ranked by relevance rather than sorted."
|
|
2903
|
+
: aggregateFolderType
|
|
2904
|
+
? "This view merges the newest mail from every mailbox and is always listed by date."
|
|
2905
|
+
: asConversations
|
|
2906
|
+
? CONVERSATION_SORT_NOTE
|
|
2907
|
+
: undefined
|
|
2908
|
+
}
|
|
2909
|
+
selectDisabled={loading || listedRowCount === 0}
|
|
2910
|
+
selectDisabledReason={loading ? "Wait for this folder to finish loading" : "There is nothing here to select"}
|
|
2911
|
+
/>
|
|
2912
|
+
)}
|
|
2913
|
+
{!isMobile && <div className="p-2 border-b border-border">{searchField}</div>}
|
|
2914
|
+
{isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot)}
|
|
2915
|
+
{emptiableFolder && !selectMode && (
|
|
2916
|
+
<EmptyFolderBar
|
|
2917
|
+
folderName={emptiableFolder.name}
|
|
2918
|
+
count={emptiableCount}
|
|
2919
|
+
disabled={!!emptyDisabledReason || permanent.busy || bulkBusy}
|
|
2920
|
+
disabledReason={emptyDisabledReason}
|
|
2921
|
+
onEmpty={() => void emptyListedFolder(emptiableFolder, emptiableCount)}
|
|
2922
|
+
/>
|
|
2923
|
+
)}
|
|
2924
|
+
{permanent.dialog}
|
|
2925
|
+
{/* Two tabs, as Outlook has: Focused and Other. There is no "All" tab - the whole Inbox is
|
|
2926
|
+
still one pick away, in the Filter menu, which is where every other named filter lives
|
|
2927
|
+
and the only place that can show which of them is really in force. A stored `all` (or
|
|
2928
|
+
Unread, Flagged, ...) therefore still lists what it always did, with neither tab
|
|
2929
|
+
pressed, rather than being migrated into one of these two halves behind the reader's
|
|
2930
|
+
back - see `MAIL_LIST_FILTERS`, which still offers it. */}
|
|
2931
|
+
{offerClassificationFilters && (
|
|
2932
|
+
<div className="flex border-b border-border text-xs">
|
|
2933
|
+
{MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (
|
|
2773
2934
|
<button
|
|
2935
|
+
key={value}
|
|
2774
2936
|
type="button"
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2937
|
+
aria-pressed={preferences.filter === value}
|
|
2938
|
+
onClick={() => updatePreferences({ filter: value })}
|
|
2939
|
+
className={[
|
|
2940
|
+
"flex-1 py-1.5 font-semibold",
|
|
2941
|
+
preferences.filter === value
|
|
2942
|
+
? "text-primary-dark border-b-2 border-primary-dark"
|
|
2943
|
+
: "text-text-muted",
|
|
2944
|
+
].join(" ")}
|
|
2782
2945
|
>
|
|
2783
|
-
{
|
|
2784
|
-
{crossMailbox ? "Search older encrypted mail" : "Search all mail"}
|
|
2946
|
+
{label}
|
|
2785
2947
|
</button>
|
|
2786
|
-
)}
|
|
2787
|
-
|
|
2948
|
+
))}
|
|
2949
|
+
</div>
|
|
2950
|
+
)}
|
|
2951
|
+
</div>
|
|
2952
|
+
<div
|
|
2953
|
+
ref={scrollContainerRef}
|
|
2954
|
+
data-testid="mail-list-scroll"
|
|
2955
|
+
// Remembered as it moves (not when the list is left: by then React has already detached the element), so
|
|
2956
|
+
// the folder - or Mail, after another app - comes back where it was.
|
|
2957
|
+
onScroll={(event) => saveListScroll(listKey, event.currentTarget.scrollTop)}
|
|
2958
|
+
// A Shift+click selects a range: without this the browser would also select the text between the two rows.
|
|
2959
|
+
onMouseDown={(event) => {
|
|
2960
|
+
if (event.shiftKey) {
|
|
2961
|
+
event.preventDefault();
|
|
2962
|
+
}
|
|
2963
|
+
}}
|
|
2964
|
+
className="flex-1 min-h-0 overflow-y-auto"
|
|
2965
|
+
>
|
|
2966
|
+
{isSearching && (
|
|
2967
|
+
<div className="px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2">
|
|
2968
|
+
{/* §_Progressive Results_: "Never show a hard count until every tier has reported.
|
|
2969
|
+
Display n of ??, or omit the count. A settled count is the signal that ordering
|
|
2970
|
+
is final." */}
|
|
2971
|
+
<span className="flex flex-col">
|
|
2972
|
+
<span>
|
|
2973
|
+
{tier1Done && tier2Done && tier3Done
|
|
2974
|
+
? `${messages.length} result${messages.length === 1 ? "" : "s"}`
|
|
2975
|
+
: `${messages.length} of ??`}
|
|
2976
|
+
</span>
|
|
2977
|
+
{crossMailbox && <span data-testid="search-scope">All mailboxes</span>}
|
|
2978
|
+
</span>
|
|
2979
|
+
<span className="flex flex-col items-end gap-0.5">
|
|
2980
|
+
{tier2Done && !searchAllMail && (
|
|
2981
|
+
<button
|
|
2982
|
+
type="button"
|
|
2983
|
+
onClick={handleSearchAllMail}
|
|
2984
|
+
title={
|
|
2985
|
+
crossMailbox
|
|
2986
|
+
? "Also search encrypted mail from before each mailbox's local index begins"
|
|
2987
|
+
: undefined
|
|
2988
|
+
}
|
|
2989
|
+
className="text-primary-dark hover:underline font-medium shrink-0"
|
|
2990
|
+
>
|
|
2991
|
+
{/* "All mail" already means every mailbox here, so this says what it adds. */}
|
|
2992
|
+
{crossMailbox ? "Search older encrypted mail" : "Search all mail"}
|
|
2993
|
+
</button>
|
|
2994
|
+
)}
|
|
2995
|
+
{widenable && (
|
|
2996
|
+
<button
|
|
2997
|
+
type="button"
|
|
2998
|
+
onClick={() => setSearchAllMailboxes(!searchAllMailboxes)}
|
|
2999
|
+
className="text-primary-dark hover:underline font-medium shrink-0"
|
|
3000
|
+
>
|
|
3001
|
+
{searchAllMailboxes ? "Search this mailbox only" : "Search all mailboxes"}
|
|
3002
|
+
</button>
|
|
3003
|
+
)}
|
|
3004
|
+
</span>
|
|
3005
|
+
</div>
|
|
3006
|
+
)}
|
|
3007
|
+
{crossMailbox && isSearching && searchableMailboxes.length > searchedMailboxes.length && (
|
|
3008
|
+
<div role="status" className="px-4 py-1.5 text-xs text-text-muted border-b border-border">
|
|
3009
|
+
Searched {searchedMailboxes.length} of {searchableMailboxes.length} mailboxes - the most that are searched at once.
|
|
3010
|
+
</div>
|
|
3011
|
+
)}
|
|
3012
|
+
{crossMailbox && isSearching && searchFailures.length > 0 && (
|
|
3013
|
+
<div role="status" className="px-4 py-1.5 text-xs text-text-muted border-b border-border bg-surface-alt">
|
|
3014
|
+
Some results may be missing: {describeFailures(searchFailures)}
|
|
3015
|
+
</div>
|
|
3016
|
+
)}
|
|
3017
|
+
{isSearching && !crossMailbox && coverage?.indexedFrom && (
|
|
3018
|
+
<div className="px-4 py-1.5 text-xs text-text-muted border-b border-border">
|
|
3019
|
+
Local search covers messages back to {new Date(coverage.indexedFrom).toLocaleDateString()}
|
|
3020
|
+
{coverage.building ? " (still building)" : ""}
|
|
3021
|
+
{searchAllMail
|
|
3022
|
+
? " - searching everything, not just recent mail."
|
|
3023
|
+
: " - older encrypted mail is still searched, just slower."}
|
|
3024
|
+
</div>
|
|
3025
|
+
)}
|
|
3026
|
+
{isSearching && crossMailbox && tier2Done && serverOnlyMailboxes.length > 0 && (
|
|
3027
|
+
<div className="px-4 py-1.5 text-xs text-text-muted border-b border-border">
|
|
3028
|
+
There is no local index on this device for {serverOnlyMailboxes.map((mb) => mb.displayName).join(", ")}: its encrypted mail is searched
|
|
3029
|
+
through the server instead, which is slower and looks at a limited number of messages.
|
|
3030
|
+
</div>
|
|
3031
|
+
)}
|
|
3032
|
+
{isSearching && crossMailbox && lockedSearchMailboxes.length > 0 && (
|
|
3033
|
+
<div className="px-4 py-2 border-b border-border bg-surface-alt flex flex-col gap-1">
|
|
3034
|
+
{unlockableSearchMailboxes.length > 0 && (
|
|
2788
3035
|
<button
|
|
2789
3036
|
type="button"
|
|
2790
|
-
onClick={
|
|
2791
|
-
className="text-primary-dark hover:underline
|
|
3037
|
+
onClick={handleUnlockSearch}
|
|
3038
|
+
className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
|
|
2792
3039
|
>
|
|
2793
|
-
{
|
|
3040
|
+
<HiOutlineLockClosed size={12} aria-hidden="true" />
|
|
3041
|
+
Unlock to include encrypted messages from {unlockableSearchMailboxes.map((mb) => mb.displayName).join(", ")}
|
|
2794
3042
|
</button>
|
|
2795
3043
|
)}
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
Some results may be missing: {describeFailures(searchFailures)}
|
|
2807
|
-
</div>
|
|
2808
|
-
)}
|
|
2809
|
-
{isSearching && !crossMailbox && coverage?.indexedFrom && (
|
|
2810
|
-
<div className="px-4 py-1.5 text-xs text-text-muted border-b border-border">
|
|
2811
|
-
Local search covers messages back to {new Date(coverage.indexedFrom).toLocaleDateString()}
|
|
2812
|
-
{coverage.building ? " (still building)" : ""}
|
|
2813
|
-
{searchAllMail
|
|
2814
|
-
? " - searching everything, not just recent mail."
|
|
2815
|
-
: " - older encrypted mail is still searched, just slower."}
|
|
2816
|
-
</div>
|
|
2817
|
-
)}
|
|
2818
|
-
{isSearching && crossMailbox && tier2Done && serverOnlyMailboxes.length > 0 && (
|
|
2819
|
-
<div className="px-4 py-1.5 text-xs text-text-muted border-b border-border">
|
|
2820
|
-
There is no local index on this device for {serverOnlyMailboxes.map((mb) => mb.displayName).join(", ")}: its encrypted mail is searched
|
|
2821
|
-
through the server instead, which is slower and looks at a limited number of messages.
|
|
2822
|
-
</div>
|
|
2823
|
-
)}
|
|
2824
|
-
{isSearching && crossMailbox && lockedSearchMailboxes.length > 0 && (
|
|
2825
|
-
<div className="px-4 py-2 border-b border-border bg-surface-alt flex flex-col gap-1">
|
|
2826
|
-
{unlockableSearchMailboxes.length > 0 && (
|
|
3044
|
+
{sharedLockedSearchMailboxes.length > 0 && (
|
|
3045
|
+
<p className="text-xs text-text-muted">
|
|
3046
|
+
Encrypted messages in {sharedLockedSearchMailboxes.map((mb) => mb.displayName).join(", ")} are not included: only the
|
|
3047
|
+
mailbox’s owner can unlock them.
|
|
3048
|
+
</p>
|
|
3049
|
+
)}
|
|
3050
|
+
</div>
|
|
3051
|
+
)}
|
|
3052
|
+
{isSearching && !crossMailbox && !getUnlockedKeys(mailboxUid!) && (
|
|
3053
|
+
<div className="px-4 py-2 border-b border-border bg-surface-alt">
|
|
2827
3054
|
<button
|
|
2828
3055
|
type="button"
|
|
2829
3056
|
onClick={handleUnlockSearch}
|
|
2830
3057
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
|
|
2831
3058
|
>
|
|
2832
3059
|
<HiOutlineLockClosed size={12} aria-hidden="true" />
|
|
2833
|
-
Unlock to include encrypted messages
|
|
3060
|
+
Unlock to include encrypted messages in these results
|
|
2834
3061
|
</button>
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
|
|
2850
|
-
>
|
|
2851
|
-
<HiOutlineLockClosed size={12} aria-hidden="true" />
|
|
2852
|
-
Unlock to include encrypted messages in these results
|
|
2853
|
-
</button>
|
|
2854
|
-
</div>
|
|
2855
|
-
)}
|
|
2856
|
-
{!isSearching && !asConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (
|
|
2857
|
-
<div className="px-4 py-2 border-b border-border bg-surface-alt">
|
|
2858
|
-
<button
|
|
2859
|
-
type="button"
|
|
2860
|
-
onClick={handleUnlockList}
|
|
2861
|
-
className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
|
|
2862
|
-
>
|
|
2863
|
-
<HiOutlineLockClosed size={12} aria-hidden="true" />
|
|
2864
|
-
Unlock to show {undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'"} subject
|
|
2865
|
-
</button>
|
|
2866
|
-
</div>
|
|
2867
|
-
)}
|
|
2868
|
-
|
|
2869
|
-
{error && (
|
|
2870
|
-
<div className="p-4">
|
|
2871
|
-
<Alert>{error}</Alert>
|
|
2872
|
-
</div>
|
|
2873
|
-
)}
|
|
3062
|
+
</div>
|
|
3063
|
+
)}
|
|
3064
|
+
{!isSearching && !asConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (
|
|
3065
|
+
<div className="px-4 py-2 border-b border-border bg-surface-alt">
|
|
3066
|
+
<button
|
|
3067
|
+
type="button"
|
|
3068
|
+
onClick={handleUnlockList}
|
|
3069
|
+
className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
|
|
3070
|
+
>
|
|
3071
|
+
<HiOutlineLockClosed size={12} aria-hidden="true" />
|
|
3072
|
+
Unlock to show {undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'"} subject
|
|
3073
|
+
</button>
|
|
3074
|
+
</div>
|
|
3075
|
+
)}
|
|
2874
3076
|
|
|
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
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
) : (
|
|
2930
|
-
<>
|
|
2931
|
-
<ul className={swipeEnabled ? "overflow-x-clip" : undefined}>
|
|
2932
|
-
{resultGroups
|
|
2933
|
-
? resultGroups.map((group) => (
|
|
2934
|
-
<li key={`${group.mailboxUid}\u0000${group.id}`} data-search-group={group.id}>
|
|
2935
|
-
<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">
|
|
2936
|
-
<span className="truncate font-semibold">{rowSubject(group.messages[0])}</span>
|
|
2937
|
-
<span className="shrink-0">
|
|
2938
|
-
{group.messages.length} matching message{group.messages.length === 1 ? "" : "s"}
|
|
2939
|
-
</span>
|
|
2940
|
-
</div>
|
|
2941
|
-
<ul>{group.messages.map(renderMessageRow)}</ul>
|
|
2942
|
-
</li>
|
|
2943
|
-
))
|
|
2944
|
-
: messages.map(renderMessageRow)}
|
|
2945
|
-
</ul>
|
|
2946
|
-
{hasMore && !rowCapReached && (
|
|
2947
|
-
<div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
|
|
2948
|
-
{loadMoreStatus}
|
|
2949
|
-
</div>
|
|
2950
|
-
)}
|
|
2951
|
-
{rowCapReached && (
|
|
2952
|
-
<p className="p-4 text-center text-xs text-text-muted">
|
|
2953
|
-
Showing the most recent {rowCap} messages — refine your search or filters to see more.
|
|
2954
|
-
</p>
|
|
2955
|
-
)}
|
|
2956
|
-
{aggregateFolderType && !isSearching && (
|
|
2957
|
-
<p className="p-4 text-center text-xs text-text-muted">
|
|
2958
|
-
Showing the most recent mail from each mailbox. Open a specific mailbox’s folder to
|
|
2959
|
-
see older mail.
|
|
3077
|
+
{error && (
|
|
3078
|
+
<div className="p-4">
|
|
3079
|
+
<Alert>{error}</Alert>
|
|
3080
|
+
</div>
|
|
3081
|
+
)}
|
|
3082
|
+
|
|
3083
|
+
{loading ? (
|
|
3084
|
+
// A skeleton of rows rather than a line of text: the list keeps its shape while a folder that was not shown a
|
|
3085
|
+
// moment ago loads (one that was is shown at once, from its snapshot).
|
|
3086
|
+
<div role="status" aria-busy="true" className="p-4">
|
|
3087
|
+
<span className="sr-only">Loading…</span>
|
|
3088
|
+
<SkeletonList count={8} />
|
|
3089
|
+
</div>
|
|
3090
|
+
) : asConversations ? (
|
|
3091
|
+
<>
|
|
3092
|
+
<ConversationList
|
|
3093
|
+
conversations={listedConversations}
|
|
3094
|
+
newestFirst={preferences.sortBy === "date" && preferences.sortOrder === "desc"}
|
|
3095
|
+
mailboxUid={activeMailboxUid}
|
|
3096
|
+
selectedUid={selectedUid}
|
|
3097
|
+
messageOverrides={conversationPatches}
|
|
3098
|
+
onMeetingResponded={patchListedMessage}
|
|
3099
|
+
onOpenMessage={handleOpenConversation}
|
|
3100
|
+
selectMode={selectMode}
|
|
3101
|
+
selectedConversationIds={selectedConversationIds}
|
|
3102
|
+
onToggleSelected={tickConversation}
|
|
3103
|
+
onSelectWithModifier={handleConversationModifierClick}
|
|
3104
|
+
exitingKeys={exitingKeys}
|
|
3105
|
+
swipe={{
|
|
3106
|
+
enabled: swipeEnabled,
|
|
3107
|
+
onArchive: swipeArchive,
|
|
3108
|
+
onMove: (conversation) => setMoveDialog({ conversation }),
|
|
3109
|
+
}}
|
|
3110
|
+
onLongPress={isMobile ? startSelectingConversation : undefined}
|
|
3111
|
+
/>
|
|
3112
|
+
{hasMore && !rowCapReached && (
|
|
3113
|
+
<div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
|
|
3114
|
+
{loadMoreStatus}
|
|
3115
|
+
</div>
|
|
3116
|
+
)}
|
|
3117
|
+
{rowCapReached && (
|
|
3118
|
+
<p className="p-4 text-center text-xs text-text-muted">
|
|
3119
|
+
Showing the most recent {rowCap} conversations — refine your search or filters to see more.
|
|
3120
|
+
</p>
|
|
3121
|
+
)}
|
|
3122
|
+
</>
|
|
3123
|
+
) : messages.length === 0 ? (
|
|
3124
|
+
<>
|
|
3125
|
+
<p className="p-4 text-sm text-text-muted">
|
|
3126
|
+
{isSearching
|
|
3127
|
+
? `No messages match "${searchQuery}".`
|
|
3128
|
+
: effectiveFilter === "all"
|
|
3129
|
+
? "No messages in this folder."
|
|
3130
|
+
: "No messages here."}
|
|
2960
3131
|
</p>
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
3132
|
+
{/* Still offered while a filter hides every loaded row - what it's looking for may be
|
|
3133
|
+
on a later page. */}
|
|
3134
|
+
{hasMore && (
|
|
3135
|
+
<div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
|
|
3136
|
+
{loadMoreStatus}
|
|
3137
|
+
</div>
|
|
3138
|
+
)}
|
|
3139
|
+
</>
|
|
3140
|
+
) : (
|
|
3141
|
+
<>
|
|
3142
|
+
<ul className={swipeEnabled ? "overflow-x-clip" : undefined}>
|
|
3143
|
+
{resultGroups
|
|
3144
|
+
? resultGroups.map((group) => (
|
|
3145
|
+
<li key={`${group.mailboxUid}\u0000${group.id}`} data-search-group={group.id}>
|
|
3146
|
+
<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">
|
|
3147
|
+
<span className="truncate font-semibold">{rowSubject(group.messages[0])}</span>
|
|
3148
|
+
<span className="shrink-0">
|
|
3149
|
+
{group.messages.length} matching message{group.messages.length === 1 ? "" : "s"}
|
|
3150
|
+
</span>
|
|
3151
|
+
</div>
|
|
3152
|
+
<ul>{group.messages.map(renderMessageRow)}</ul>
|
|
3153
|
+
</li>
|
|
3154
|
+
))
|
|
3155
|
+
: messages.map(renderMessageRow)}
|
|
3156
|
+
</ul>
|
|
3157
|
+
{hasMore && !rowCapReached && (
|
|
3158
|
+
<div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
|
|
3159
|
+
{loadMoreStatus}
|
|
3160
|
+
</div>
|
|
3161
|
+
)}
|
|
3162
|
+
{rowCapReached && (
|
|
3163
|
+
<p className="p-4 text-center text-xs text-text-muted">
|
|
3164
|
+
Showing the most recent {rowCap} messages — refine your search or filters to see more.
|
|
3165
|
+
</p>
|
|
3166
|
+
)}
|
|
3167
|
+
{aggregateFolderType && !isSearching && (
|
|
3168
|
+
<p className="p-4 text-center text-xs text-text-muted">
|
|
3169
|
+
Showing the most recent mail from each mailbox. Open a specific mailbox’s folder to
|
|
3170
|
+
see older mail.
|
|
3171
|
+
</p>
|
|
3172
|
+
)}
|
|
3173
|
+
</>
|
|
3174
|
+
)}
|
|
3175
|
+
</div>
|
|
2964
3176
|
</div>
|
|
2965
3177
|
{/* The reading pane's own height: a row of the full-height mail view, stretched to it by the
|
|
2966
3178
|
flex chain rather than by a percentage (an explicit height would opt it out of that
|
|
@@ -3023,9 +3235,9 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
3023
3235
|
<MoveToFolderDialog
|
|
3024
3236
|
open={moveDialog !== null}
|
|
3025
3237
|
onClose={() => setMoveDialog(null)}
|
|
3026
|
-
mailboxUid={activeMailboxUid}
|
|
3027
|
-
folders={
|
|
3028
|
-
currentFolderUid={folderUid}
|
|
3238
|
+
mailboxUid={movingFrom?.mailboxUid ?? activeMailboxUid}
|
|
3239
|
+
folders={foldersOf(movingFrom?.mailboxUid ?? activeMailboxUid)}
|
|
3240
|
+
currentFolderUid={movingFrom?.folderUid}
|
|
3029
3241
|
count={moveDialog === null ? 1 : "messages" in moveDialog ? moveDialog.messages.length : moveDialog.conversation.messageCount}
|
|
3030
3242
|
onMove={swipeMove}
|
|
3031
3243
|
onFolderCreated={onFolderCreated}
|