@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/dist/apps/www/index.js
CHANGED
|
@@ -26,6 +26,8 @@ import MailShell, { useMailShell, } from "../shared/components/mail/layout/MailS
|
|
|
26
26
|
import MailAddress from "../shared/components/mail/MailAddress.js";
|
|
27
27
|
import OutboxRowStatus from "../shared/components/mail/OutboxRowStatus.js";
|
|
28
28
|
import { mergeFirstPage } from "../shared/mail/mergeFirstPage.js";
|
|
29
|
+
import { isToggleClick, rangeBetween, resolveAnchor } from "../shared/mail/rangeSelection.js";
|
|
30
|
+
import { ROW_EXIT_MS, canAnimateRowExit, collapseRow } from "../shared/mail/rowExit.js";
|
|
29
31
|
import { isOwnMailbox, orderMailboxes, primaryMailboxUid } from "../shared/mail/primaryMailbox.js";
|
|
30
32
|
import { listSnapshotKey, readListSnapshot, saveListScroll, writeListSnapshot } from "../shared/mail/listSnapshots.js";
|
|
31
33
|
import { setReadStateMany } from "../shared/mail/messageReadState.js";
|
|
@@ -586,6 +588,18 @@ function InboxContent({ userUid }) {
|
|
|
586
588
|
// Where the message the keyboard just removed from the list (deleted, archived, moved) was: the next Down/Up continues from that spot, so
|
|
587
589
|
// clearing an inbox from the keyboard walks down it instead of jumping back to the top. Cleared by any selection.
|
|
588
590
|
const removedAnchorRef = useRef(null);
|
|
591
|
+
// The row a Shift+click range starts from: the row last clicked or opened (a `message.uid`, or a `conversationRowKey()` in the conversation list).
|
|
592
|
+
// A Shift+click selects from here to the clicked row but leaves it where it is, so the next Shift+click re-spans from the same row, as in Explorer and
|
|
593
|
+
// Outlook; a plain click or a Ctrl+click moves it to the row clicked. Forgotten when the listing is replaced. See `resolveAnchor()`.
|
|
594
|
+
const selectionAnchorRef = useRef(null);
|
|
595
|
+
// Rows on their way out of the list (moved, deleted, archived): `message.uid`s, or `conversationRowKey()`s in the conversation list. They stay in the
|
|
596
|
+
// data while they collapse (`collapseRow()`, `ROW_EXIT_MS`), so the rows below slide up into the gap, and are dropped by `animateRowsOut()` after.
|
|
597
|
+
const [exitingKeys, setExitingKeys] = useState(new Set());
|
|
598
|
+
const exitTimersRef = useRef(new Set());
|
|
599
|
+
useEffect(() => {
|
|
600
|
+
const timers = exitTimersRef.current;
|
|
601
|
+
return () => timers.forEach(clearTimeout);
|
|
602
|
+
}, []);
|
|
589
603
|
// State (via a callback ref), not a plain ref: the sentinel mounts and unmounts as the list loads,
|
|
590
604
|
// filters, and empties, and the observer effect below must re-attach to whichever node is current.
|
|
591
605
|
const [sentinel, setSentinel] = useState(null);
|
|
@@ -840,6 +854,7 @@ function InboxContent({ userUid }) {
|
|
|
840
854
|
setSelectedUids(new Set());
|
|
841
855
|
setSelectedConversationIds(new Set());
|
|
842
856
|
setConversationMessagesById({});
|
|
857
|
+
selectionAnchorRef.current = null;
|
|
843
858
|
listedOffsetRef.current = 0;
|
|
844
859
|
compositeCursorRef.current = undefined;
|
|
845
860
|
setHasMore(false);
|
|
@@ -1225,20 +1240,16 @@ function InboxContent({ userUid }) {
|
|
|
1225
1240
|
// a listing still loading and a "load more" in flight, and for events about folders the list isn't showing - a
|
|
1226
1241
|
// conversation list, which can span folders, refreshes for any. Any failure is silent: the next tick tries again.
|
|
1227
1242
|
const liveRunRef = useRef(0);
|
|
1228
|
-
|
|
1229
|
-
|
|
1243
|
+
/**
|
|
1244
|
+
* Quietly refetches the first page of whatever is listed and folds it in (see above) - the same refresh a push event makes, which a bulk action or
|
|
1245
|
+
* a move also makes once its rows have left, so the list is brought up to date (counts, previews) without the skeleton or losing the reader's place.
|
|
1246
|
+
* `listRun` is the listing the caller means: a refresh that finds another listing on screen by the time it lands is thrown away.
|
|
1247
|
+
*/
|
|
1248
|
+
function refreshListQuietly(listRun = searchRunIdRef.current) {
|
|
1249
|
+
if (isSearching || loading || loadMoreInFlightRef.current || (!folderUid && !aggregateFolderType)) {
|
|
1230
1250
|
return;
|
|
1231
1251
|
}
|
|
1232
|
-
if (!asConversations && live.folderUids) {
|
|
1233
|
-
const shown = new Set(aggregateFolderType
|
|
1234
|
-
? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
|
|
1235
|
-
: [folderUid]);
|
|
1236
|
-
if (![...live.folderUids].some((uid) => shown.has(uid))) {
|
|
1237
|
-
return;
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
1252
|
// Superseded by a newer refresh, or by any reload of the list itself (which bumps the search run id).
|
|
1241
|
-
const listRun = searchRunIdRef.current;
|
|
1242
1253
|
const myRun = ++liveRunRef.current;
|
|
1243
1254
|
const isCurrent = () => searchRunIdRef.current === listRun && liveRunRef.current === myRun;
|
|
1244
1255
|
void (async () => {
|
|
@@ -1285,6 +1296,20 @@ function InboxContent({ userUid }) {
|
|
|
1285
1296
|
// Quiet by design - see above.
|
|
1286
1297
|
}
|
|
1287
1298
|
})();
|
|
1299
|
+
}
|
|
1300
|
+
useEffect(() => {
|
|
1301
|
+
if (live.tick === 0) {
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
if (!asConversations && live.folderUids) {
|
|
1305
|
+
const shown = new Set(aggregateFolderType
|
|
1306
|
+
? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
|
|
1307
|
+
: [folderUid]);
|
|
1308
|
+
if (![...live.folderUids].some((uid) => shown.has(uid))) {
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
refreshListQuietly();
|
|
1288
1313
|
}, [live.tick]);
|
|
1289
1314
|
function handleSearchAllMail() {
|
|
1290
1315
|
setSearchAllMailKey(searchAllMailScope);
|
|
@@ -1535,14 +1560,61 @@ function InboxContent({ userUid }) {
|
|
|
1535
1560
|
: conversation));
|
|
1536
1561
|
}
|
|
1537
1562
|
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Takes rows out of the list without a jolt: they collapse over `ROW_EXIT_MS` (the rows below slide up into the gap - see `collapseRow()`), then
|
|
1565
|
+
* `commit()` drops them from the data. `keys` are the rows' `exitingKeys`. With nothing to animate - none of them is listed, the browser can't
|
|
1566
|
+
* animate, or the reader prefers reduced motion - `commit()` runs at once.
|
|
1567
|
+
*/
|
|
1568
|
+
function animateRowsOut(keys, commit) {
|
|
1569
|
+
if (keys.length === 0 || !canAnimateRowExit()) {
|
|
1570
|
+
commit();
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
setExitingKeys((prev) => new Set([...prev, ...keys]));
|
|
1574
|
+
const timer = setTimeout(() => {
|
|
1575
|
+
exitTimersRef.current.delete(timer);
|
|
1576
|
+
commit();
|
|
1577
|
+
setExitingKeys((prev) => {
|
|
1578
|
+
const next = new Set(prev);
|
|
1579
|
+
keys.forEach((key) => next.delete(key));
|
|
1580
|
+
return next;
|
|
1581
|
+
});
|
|
1582
|
+
}, ROW_EXIT_MS);
|
|
1583
|
+
exitTimersRef.current.add(timer);
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Takes the conversations whose every message is in `uids` out of the conversation list (animated), then quietly refreshes the list: a conversation
|
|
1587
|
+
* that keeps messages elsewhere stays, with its count and preview brought up to date by the refresh rather than by reloading the whole list.
|
|
1588
|
+
* `listRun` is the listing the caller acted on; if another is on screen by now there is nothing left to do.
|
|
1589
|
+
*/
|
|
1590
|
+
function removeListedConversations(uids, listRun = searchRunIdRef.current) {
|
|
1591
|
+
if (searchRunIdRef.current !== listRun) {
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
const leaving = new Set(conversationsRef.current.filter((c) => c.messageUids.every((uid) => uids.has(uid))).map(conversationKey));
|
|
1595
|
+
animateRowsOut([...leaving], () => {
|
|
1596
|
+
if (searchRunIdRef.current !== listRun) {
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
setConversations((prev) => prev.filter((c) => !leaving.has(conversationKey(c))));
|
|
1600
|
+
// The refresh below merges into the ref, which only catches up with the state on the next render.
|
|
1601
|
+
conversationsRef.current = conversationsRef.current.filter((c) => !leaving.has(conversationKey(c)));
|
|
1602
|
+
listedOffsetRef.current = Math.max(0, listedOffsetRef.current - leaving.size);
|
|
1603
|
+
refreshListQuietly(listRun);
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1538
1606
|
function removeListedMessages(uids) {
|
|
1539
1607
|
const selectedIndex = messagesRef.current.findIndex((m) => m.uid === selectedUid);
|
|
1540
1608
|
if (selectedUid && uids.has(selectedUid) && selectedIndex !== -1) {
|
|
1541
1609
|
removedAnchorRef.current = selectedIndex;
|
|
1542
1610
|
}
|
|
1543
|
-
setMessages((prev) => prev.filter((m) => !uids.has(m.uid)));
|
|
1544
|
-
listedOffsetRef.current = Math.max(0, listedOffsetRef.current - uids.size);
|
|
1545
1611
|
setSelectedUid((prev) => (prev && uids.has(prev) ? null : prev));
|
|
1612
|
+
if (asConversations) {
|
|
1613
|
+
removeListedConversations(uids);
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
listedOffsetRef.current = Math.max(0, listedOffsetRef.current - uids.size);
|
|
1617
|
+
animateRowsOut(messagesRef.current.filter((m) => uids.has(m.uid)).map((m) => m.uid), () => setMessages((prev) => prev.filter((m) => !uids.has(m.uid))));
|
|
1546
1618
|
}
|
|
1547
1619
|
function removeListedMessage(uid) {
|
|
1548
1620
|
removeListedMessages(new Set([uid]));
|
|
@@ -1663,6 +1735,61 @@ function InboxContent({ userUid }) {
|
|
|
1663
1735
|
setSelectedConversationIds(new Set(conversations.map(conversationKey)));
|
|
1664
1736
|
void resolveConversations(conversations);
|
|
1665
1737
|
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Shift+click: the keys from the anchor to `target` in `keys` (the list in the order it is displayed), entering select mode. The anchor is the
|
|
1740
|
+
* row last clicked or opened (`selectionAnchorRef`), else the row open in the reading pane (`openKey`), else the clicked row itself - and it is
|
|
1741
|
+
* pinned where it was, not moved to `target`, so a second Shift+click elsewhere re-spans from the same row.
|
|
1742
|
+
*/
|
|
1743
|
+
function selectRange(keys, target, openKey) {
|
|
1744
|
+
const anchor = resolveAnchor(keys, selectionAnchorRef.current, openKey);
|
|
1745
|
+
selectionAnchorRef.current = anchor ?? target;
|
|
1746
|
+
setSelectMode(true);
|
|
1747
|
+
return rangeBetween(keys, anchor, target);
|
|
1748
|
+
}
|
|
1749
|
+
/** Shift+click on a message row. Plain Shift makes the range the whole selection; Ctrl+Shift (`extend`) adds it to what is ticked already. */
|
|
1750
|
+
function selectMessageRange(message, extend) {
|
|
1751
|
+
const range = selectRange(displayedMessages.map((m) => m.uid), message.uid, selectedUid);
|
|
1752
|
+
setSelectedUids((prev) => new Set(extend ? [...prev, ...range] : range));
|
|
1753
|
+
}
|
|
1754
|
+
/** Shift+click on a conversation row: as `selectMessageRange()`, over the rows in the order they are displayed, each resolved to its messages like a ticked one. */
|
|
1755
|
+
function selectConversationRange(conversation, extend) {
|
|
1756
|
+
const keys = listedConversations.map(conversationKey);
|
|
1757
|
+
const openIndex = selectedRowIndex();
|
|
1758
|
+
const range = selectRange(keys, conversationKey(conversation), openIndex === -1 ? null : keys[openIndex]);
|
|
1759
|
+
setSelectedConversationIds((prev) => new Set(extend ? [...prev, ...range] : range));
|
|
1760
|
+
const inRange = new Set(range);
|
|
1761
|
+
void resolveConversations(listedConversations.filter((c) => inRange.has(conversationKey(c))));
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* 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
|
|
1765
|
+
* (`handleSelect()`, `handleOpenConversation()`). A conversation is resolved to its messages as a ticked one is.
|
|
1766
|
+
*/
|
|
1767
|
+
function startSelectingMessage(message) {
|
|
1768
|
+
selectionAnchorRef.current = message.uid;
|
|
1769
|
+
setSelectMode(true);
|
|
1770
|
+
setSelectedUids(new Set([message.uid]));
|
|
1771
|
+
}
|
|
1772
|
+
function startSelectingConversation(conversation) {
|
|
1773
|
+
selectionAnchorRef.current = conversationKey(conversation);
|
|
1774
|
+
setSelectMode(true);
|
|
1775
|
+
setSelectedConversationIds(new Set([conversationKey(conversation)]));
|
|
1776
|
+
void resolveConversations([conversation]);
|
|
1777
|
+
}
|
|
1778
|
+
/** A plain click on a conversation's checkbox: ticks (or unticks) it and makes it the anchor of the next Shift+click. */
|
|
1779
|
+
function tickConversation(conversation) {
|
|
1780
|
+
selectionAnchorRef.current = conversationKey(conversation);
|
|
1781
|
+
toggleConversationSelected(conversation);
|
|
1782
|
+
}
|
|
1783
|
+
/** 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). */
|
|
1784
|
+
function handleConversationModifierClick(conversation, modifiers) {
|
|
1785
|
+
if (modifiers.shiftKey) {
|
|
1786
|
+
selectConversationRange(conversation, isToggleClick(modifiers));
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
// Ctrl (Cmd): just this row, and select mode if the reader was not in it.
|
|
1790
|
+
setSelectMode(true);
|
|
1791
|
+
tickConversation(conversation);
|
|
1792
|
+
}
|
|
1666
1793
|
/**
|
|
1667
1794
|
* Runs one bulk action over the current selection, then either patches the affected rows in place or
|
|
1668
1795
|
* drops them (a move takes them out of the folder being listed).
|
|
@@ -1676,6 +1803,8 @@ function InboxContent({ userUid }) {
|
|
|
1676
1803
|
* names its own. Resolves whether it succeeded.
|
|
1677
1804
|
*/
|
|
1678
1805
|
async function runBulkAction(action, removesRows, chosen = selectedMessages) {
|
|
1806
|
+
// The listing this acts on: the action takes a while, and the reader may have opened another folder by the time it is done.
|
|
1807
|
+
const listRun = searchRunIdRef.current;
|
|
1679
1808
|
setBulkBusy(true);
|
|
1680
1809
|
try {
|
|
1681
1810
|
const updated = await action(chosen);
|
|
@@ -1692,11 +1821,17 @@ function InboxContent({ userUid }) {
|
|
|
1692
1821
|
}
|
|
1693
1822
|
if (asConversations) {
|
|
1694
1823
|
// A conversation row is a summary of its messages - its count, unread count, participants
|
|
1695
|
-
// and preview all move when a bulk action changes or empties part of it - so the
|
|
1696
|
-
//
|
|
1824
|
+
// and preview all move when a bulk action changes or empties part of it - so the rows that
|
|
1825
|
+
// went collapse away and the list is then refetched quietly (no skeleton, nothing forgotten)
|
|
1826
|
+
// rather than patched row by row.
|
|
1697
1827
|
setSelectedConversationIds(new Set());
|
|
1698
1828
|
setConversationMessagesById({});
|
|
1699
|
-
|
|
1829
|
+
if (removesRows) {
|
|
1830
|
+
removeListedConversations(new Set(chosen.map((m) => m.uid)), listRun);
|
|
1831
|
+
}
|
|
1832
|
+
else {
|
|
1833
|
+
refreshListQuietly(listRun);
|
|
1834
|
+
}
|
|
1700
1835
|
}
|
|
1701
1836
|
else if (removesRows) {
|
|
1702
1837
|
removeListedMessages(new Set(chosen.map((m) => m.uid)));
|
|
@@ -1799,6 +1934,7 @@ function InboxContent({ userUid }) {
|
|
|
1799
1934
|
* (those stay listed, and the reload shows what is really there). Resolves whether anything was deleted.
|
|
1800
1935
|
*/
|
|
1801
1936
|
async function purgeChosen(chosen) {
|
|
1937
|
+
const listRun = searchRunIdRef.current;
|
|
1802
1938
|
const outcome = await permanent.requestPermanentDelete(chosen);
|
|
1803
1939
|
if (!outcome) {
|
|
1804
1940
|
return false;
|
|
@@ -1806,7 +1942,7 @@ function InboxContent({ userUid }) {
|
|
|
1806
1942
|
if (asConversations) {
|
|
1807
1943
|
setSelectedConversationIds(new Set());
|
|
1808
1944
|
setConversationMessagesById({});
|
|
1809
|
-
|
|
1945
|
+
removeListedConversations(new Set(outcome.deleted.map((m) => m.uid)), listRun);
|
|
1810
1946
|
}
|
|
1811
1947
|
else {
|
|
1812
1948
|
removeListedMessages(new Set(outcome.deleted.map((m) => m.uid)));
|
|
@@ -1837,16 +1973,28 @@ function InboxContent({ userUid }) {
|
|
|
1837
1973
|
}
|
|
1838
1974
|
// ---- Swiping a row on a phone (see `SwipeRow`): right to left archives, left to right asks for a folder. Both go through the same
|
|
1839
1975
|
// ---- bulk path as the selection bar's Archive and Move to, so the badges, the optimistic bookkeeping and the failure pop-up are one
|
|
1840
|
-
// ---- implementation.
|
|
1841
|
-
// ----
|
|
1842
|
-
|
|
1843
|
-
const
|
|
1844
|
-
|
|
1976
|
+
// ---- implementation. Offered in every listing - a mailbox's own folders, the "All mailboxes" views and search results - and a row acts in
|
|
1977
|
+
// ---- 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
|
|
1978
|
+
// ---- are being ticked.
|
|
1979
|
+
const listingOutbox = !isSearching && currentFolders.find((f) => f.uid === folderUid)?.type === "outbox";
|
|
1980
|
+
const swipeEnabled = isMobile && !selectMode && !listingOutbox;
|
|
1981
|
+
/** 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. */
|
|
1845
1982
|
const [moveDialog, setMoveDialog] = useState(null);
|
|
1846
|
-
/**
|
|
1983
|
+
/** Where what is being moved is now: its mailbox - whose folders the prompt offers - and the folder it is in. */
|
|
1984
|
+
const movingFrom = moveDialog === null
|
|
1985
|
+
? null
|
|
1986
|
+
: "messages" in moveDialog
|
|
1987
|
+
? { mailboxUid: moveDialog.messages[0].mailboxUid, folderUid: moveDialog.messages[0].folderUid }
|
|
1988
|
+
: { mailboxUid: moveDialog.conversation.mailboxUid ?? activeMailboxUid, folderUid: moveDialog.conversation.latestFolderUid };
|
|
1989
|
+
/**
|
|
1990
|
+
* 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
|
|
1991
|
+
* 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
|
|
1992
|
+
* copy of a reply), as for a ticked row (`selectedConversationMessages`).
|
|
1993
|
+
*/
|
|
1847
1994
|
async function swipedConversationMessages(conversation) {
|
|
1848
1995
|
try {
|
|
1849
|
-
|
|
1996
|
+
const loaded = await loadOpenConversation(conversation, conversation.mailboxUid ?? activeMailboxUid);
|
|
1997
|
+
return aggregateFolderType ? loaded.filter((m) => folderTypeOf(m.folderUid) === aggregateFolderType) : loaded;
|
|
1850
1998
|
}
|
|
1851
1999
|
catch (err) {
|
|
1852
2000
|
notifyApiError(err, "Couldn't load the messages in that conversation");
|
|
@@ -1878,7 +2026,12 @@ function InboxContent({ userUid }) {
|
|
|
1878
2026
|
const loadMoreStatus = loadingMore ? ("Loading more\u2026") : loadMoreError ? (_jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { role: "alert", className: "text-danger", children: loadMoreError }), _jsx("button", { type: "button", onClick: () => void loadMoreRef.current(), className: "text-primary-dark hover:underline font-medium", children: "Retry" })] })) : loadMoreStalled ? (_jsx("button", { type: "button", onClick: () => void loadMoreRef.current(), className: "text-primary-dark hover:underline font-medium", children: "Load more" })) : null;
|
|
1879
2027
|
/** One message's row of the flat list - and, in search results grouped by conversation, of a group. */
|
|
1880
2028
|
function renderMessageRow(message) {
|
|
1881
|
-
|
|
2029
|
+
const exiting = exitingKeys.has(message.uid);
|
|
2030
|
+
return (_jsxs(SwipeRow, { as: "li",
|
|
2031
|
+
// A hit of a search over several mailboxes can be in an Outbox, which none of these actions are for.
|
|
2032
|
+
enabled: swipeEnabled && folderTypeOf(message.folderUid) !== "outbox", onArchive: () => swipeArchive([message]), onMove: () => setMoveDialog({ messages: [message] }), onLongPress: isMobile && !selectMode ? () => startSelectingMessage(message) : undefined, "data-message-uid": message.uid, "data-unread": isUnread(message) ? "true" : undefined, "data-exiting": exiting ? "true" : undefined, "aria-hidden": exiting ? "true" : undefined, inert: exiting, className: rowClass({ unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) }, exiting ? "flex items-stretch pointer-events-none" : "flex items-stretch"), children: [_jsx(UnreadBar, { unread: isUnread(message) }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: selectedUids.has(message.uid),
|
|
2033
|
+
// The change of a checkbox is caused by a click (or the Space key, which makes one): its modifiers are that event's.
|
|
2034
|
+
onChange: (event) => handleSelect(message, event.nativeEvent), "aria-label": `Select ${rowSubject(message)}`, className: "w-4 h-4 accent-primary" }) })), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: (event) => handleSelect(message, event), className: ["w-full text-left px-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: isUnread(message) }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(isUnread(message)) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(isUnread(message))].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), crossMailbox && (_jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName })), isSearching && pendingUids.has(message.uid) ? (_jsxs("div", { className: "flex flex-col gap-1.5 py-0.5", children: [_jsx(Skeleton, { height: "h-3.5", className: "w-2/3 rounded-sm" }), _jsx(Skeleton, { height: "h-3", className: "w-full rounded-sm" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: ["text-sm truncate", subjectClass(isUnread(message))].join(" "), children: rowSubject(message) }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: snippets[message.uid] ||
|
|
1882
2035
|
decryptedRows[message.uid]?.preview ||
|
|
1883
2036
|
message.bodyPreview ||
|
|
1884
2037
|
(message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] }), isOutbox && _jsx(OutboxRowStatus, { message: message })] }))] }), _jsx(InviteRowChip, { message: message, onResponded: (updated) => patchListedMessage(updated) })] })] }, message.uid));
|
|
@@ -1886,9 +2039,26 @@ function InboxContent({ userUid }) {
|
|
|
1886
2039
|
/** Search results arranged by conversation: the messages that matched, grouped under the conversation each belongs to, in the order of the
|
|
1887
2040
|
* first match of each - the order the results are ranked in. `null` when the list is not showing grouped results. */
|
|
1888
2041
|
const resultGroups = groupedResults ? groupByConversation(messages) : null;
|
|
2042
|
+
/** 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. */
|
|
2043
|
+
const displayedMessages = resultGroups ? resultGroups.flatMap((group) => group.messages) : messages;
|
|
1889
2044
|
/** The search box: in the list on a desktop (flat lists only), in the shell's header beside the folders button on a phone. */
|
|
1890
2045
|
const searchField = (_jsx("input", { ref: searchInputRef, type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: "Search all mail\u2026", "aria-label": "Search all mail", className: ["w-full text-sm px-3 rounded-md border border-border bg-surface", isMobile ? "py-2" : "py-1.5"].join(" ") }));
|
|
1891
|
-
|
|
2046
|
+
/**
|
|
2047
|
+
* 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
|
|
2048
|
+
* 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);
|
|
2049
|
+
* a plain click ticks the row in select mode and opens it otherwise.
|
|
2050
|
+
*/
|
|
2051
|
+
function handleSelect(message, modifiers) {
|
|
2052
|
+
if (modifiers?.shiftKey) {
|
|
2053
|
+
selectMessageRange(message, isToggleClick(modifiers));
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
selectionAnchorRef.current = message.uid;
|
|
2057
|
+
if (isToggleClick(modifiers)) {
|
|
2058
|
+
setSelectMode(true);
|
|
2059
|
+
toggleSelected(message.uid);
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
1892
2062
|
if (selectMode) {
|
|
1893
2063
|
toggleSelected(message.uid);
|
|
1894
2064
|
return;
|
|
@@ -1924,6 +2094,7 @@ function InboxContent({ userUid }) {
|
|
|
1924
2094
|
/** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
|
|
1925
2095
|
* stands for, or the latest for a parent row. The thread pane loads the thread itself. */
|
|
1926
2096
|
function handleOpenConversation(conversation, uid) {
|
|
2097
|
+
selectionAnchorRef.current = conversationKey(conversation);
|
|
1927
2098
|
if (selectMode) {
|
|
1928
2099
|
// Same rule as `handleSelect()` for a message row: while selecting, a row's own button ticks
|
|
1929
2100
|
// the row rather than opening it.
|
|
@@ -1950,7 +2121,14 @@ function InboxContent({ userUid }) {
|
|
|
1950
2121
|
/** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
|
|
1951
2122
|
const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
|
|
1952
2123
|
const rowCount = inConversations ? listedConversations.length : messages.length;
|
|
1953
|
-
|
|
2124
|
+
// Not while rows are collapsing out of the list: their indexes are about to shift.
|
|
2125
|
+
const canMoveSelection = !selectMode && !isMobile && !loading && rowCount > 0 && exitingKeys.size === 0;
|
|
2126
|
+
// Starts the collapse of the rows that have just been marked as leaving (see `animateRowsOut()`).
|
|
2127
|
+
useLayoutEffect(() => {
|
|
2128
|
+
if (exitingKeys.size > 0) {
|
|
2129
|
+
scrollContainerRef.current.querySelectorAll("[data-exiting]").forEach((row) => collapseRow(row));
|
|
2130
|
+
}
|
|
2131
|
+
}, [exitingKeys]);
|
|
1954
2132
|
/** The type of a message's folder - looked for in every mailbox's, as a search over several has messages from each. */
|
|
1955
2133
|
function folderTypeOf(folderUidOfMessage) {
|
|
1956
2134
|
return mailboxFolders.flatMap((mf) => mf.folders).find((f) => f.uid === folderUidOfMessage)?.type;
|
|
@@ -2164,51 +2342,57 @@ function InboxContent({ userUid }) {
|
|
|
2164
2342
|
// indistinguishable to anything (a test, a user re-reading the screen) that catches this one.
|
|
2165
2343
|
return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Loading your mailbox\u2026" });
|
|
2166
2344
|
}
|
|
2167
|
-
return (_jsxs("div", { className: "flex h-full min-h-0", children: [_jsxs("div", {
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2345
|
+
return (_jsxs("div", { className: "flex h-full min-h-0", children: [_jsxs("div", { "data-testid": "mail-list-column", className: "w-full md:w-96 shrink-0 md:border-r border-border flex flex-col min-h-0", children: [_jsxs("div", { "data-testid": "mail-list-header", className: "shrink-0", children: [selectMode ? (_jsx(MailSelectionBar, { selected: selectedMessages, listed: messages, totals: asConversations
|
|
2346
|
+
? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
|
|
2347
|
+
: undefined, onSelectAll: () => asConversations
|
|
2348
|
+
? selectAllConversations()
|
|
2349
|
+
: setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () => asConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set()), onCancel: leaveSelectMode, folders: foldersOf(selectionMailboxUid), currentFolderUid: folderUid, labels: mailboxLabels, mailboxUid: selectionMailboxUid, moveDisabledReason: moveDisabledReason, labelsDisabledReason: labelsDisabledReason, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated, onApplyLabels: applyLabelsToSelection, onSetRead: (read) => void runBulkAction(
|
|
2350
|
+
// Optimistic: the rows and the folder badges change now, and change back if the server refuses.
|
|
2351
|
+
(chosen) => setReadStateMany(chosen, read, { patch: patchListedMessage, track: trackMessageChange }), false), onSetFlagged: (flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged, client), false), shortcuts: keyboardActions, onArchive: () => void runBulkAction(bulkArchive, true), onMoveTo: async (targetFolderUid) => {
|
|
2352
|
+
await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid, client), true);
|
|
2353
|
+
}, onReportJunk: () => void moveSelectionToType("junk", "Junk Email"), onDelete: () => void (deletesPermanently
|
|
2354
|
+
? purgeChosen(selectedMessages)
|
|
2355
|
+
: // A mix (search results from several folders) moves only what is not in Deleted Items yet.
|
|
2356
|
+
moveSelectionToType("deleted_items", "Deleted Items", selectedMessages.filter((m) => !inDeletedItems(m)))), deletesPermanently: deletesPermanently, busy: bulkBusy || resolvingSelection > 0 || permanent.busy })) : (_jsx(MailListToolbar, { sortBy: preferences.sortBy, sortOrder: preferences.sortOrder, filter: preferences.filter, labelUids: preferences.labelUids, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onLabelUidsChange: (nextLabelUids) => updatePreferences({ labelUids: nextLabelUids }), showAsConversations: preferences.showAsConversations, onSortChange: (sortBy, sortOrder) => updatePreferences({ sortBy, sortOrder }), onFilterChange: (filter) => updatePreferences({ filter }), onShowAsConversationsChange: (showAsConversations) => updatePreferences({ showAsConversations }), selectMode: selectMode, onSelectModeChange: setSelectMode, offerClassificationFilters: offerClassificationFilters, filterDisabled: isSearching, filterDisabledReason: "Filters don't apply to search results", sortKeysDisabled: isSearching || !!aggregateFolderType,
|
|
2357
|
+
// A conversation row is a thread summary, so only some of the keys have anything to
|
|
2358
|
+
// order by - the rest stay pickable and are applied to the rows already fetched.
|
|
2359
|
+
unavailableSortKeys: asConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined, sortKeysNote: isSearching
|
|
2360
|
+
? "Search results are ranked by relevance rather than sorted."
|
|
2361
|
+
: aggregateFolderType
|
|
2362
|
+
? "This view merges the newest mail from every mailbox and is always listed by date."
|
|
2363
|
+
: asConversations
|
|
2364
|
+
? CONVERSATION_SORT_NOTE
|
|
2365
|
+
: undefined, selectDisabled: loading || listedRowCount === 0, selectDisabledReason: loading ? "Wait for this folder to finish loading" : "There is nothing here to select" })), !isMobile && _jsx("div", { className: "p-2 border-b border-border", children: searchField }), isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot), emptiableFolder && !selectMode && (_jsx(EmptyFolderBar, { folderName: emptiableFolder.name, count: emptiableCount, disabled: !!emptyDisabledReason || permanent.busy || bulkBusy, disabledReason: emptyDisabledReason, onEmpty: () => void emptyListedFolder(emptiableFolder, emptiableCount) })), permanent.dialog, offerClassificationFilters && (_jsx("div", { className: "flex border-b border-border text-xs", children: MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (_jsx("button", { type: "button", "aria-pressed": preferences.filter === value, onClick: () => updatePreferences({ filter: value }), className: [
|
|
2366
|
+
"flex-1 py-1.5 font-semibold",
|
|
2367
|
+
preferences.filter === value
|
|
2368
|
+
? "text-primary-dark border-b-2 border-primary-dark"
|
|
2369
|
+
: "text-text-muted",
|
|
2370
|
+
].join(" "), children: label }, value))) }))] }), _jsxs("div", { ref: scrollContainerRef, "data-testid": "mail-list-scroll",
|
|
2371
|
+
// Remembered as it moves (not when the list is left: by then React has already detached the element), so
|
|
2372
|
+
// the folder - or Mail, after another app - comes back where it was.
|
|
2373
|
+
onScroll: (event) => saveListScroll(listKey, event.currentTarget.scrollTop),
|
|
2374
|
+
// A Shift+click selects a range: without this the browser would also select the text between the two rows.
|
|
2375
|
+
onMouseDown: (event) => {
|
|
2376
|
+
if (event.shiftKey) {
|
|
2377
|
+
event.preventDefault();
|
|
2378
|
+
}
|
|
2379
|
+
}, className: "flex-1 min-h-0 overflow-y-auto", children: [isSearching && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2", children: [_jsxs("span", { className: "flex flex-col", children: [_jsx("span", { children: tier1Done && tier2Done && tier3Done
|
|
2380
|
+
? `${messages.length} result${messages.length === 1 ? "" : "s"}`
|
|
2381
|
+
: `${messages.length} of ??` }), crossMailbox && _jsx("span", { "data-testid": "search-scope", children: "All mailboxes" })] }), _jsxs("span", { className: "flex flex-col items-end gap-0.5", children: [tier2Done && !searchAllMail && (_jsx("button", { type: "button", onClick: handleSearchAllMail, title: crossMailbox
|
|
2382
|
+
? "Also search encrypted mail from before each mailbox's local index begins"
|
|
2383
|
+
: undefined, className: "text-primary-dark hover:underline font-medium shrink-0", children: crossMailbox ? "Search older encrypted mail" : "Search all mail" })), widenable && (_jsx("button", { type: "button", onClick: () => setSearchAllMailboxes(!searchAllMailboxes), className: "text-primary-dark hover:underline font-medium shrink-0", children: searchAllMailboxes ? "Search this mailbox only" : "Search all mailboxes" }))] })] })), crossMailbox && isSearching && searchableMailboxes.length > searchedMailboxes.length && (_jsxs("div", { role: "status", className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["Searched ", searchedMailboxes.length, " of ", searchableMailboxes.length, " mailboxes - the most that are searched at once."] })), crossMailbox && isSearching && searchFailures.length > 0 && (_jsxs("div", { role: "status", className: "px-4 py-1.5 text-xs text-text-muted border-b border-border bg-surface-alt", children: ["Some results may be missing: ", describeFailures(searchFailures)] })), isSearching && !crossMailbox && coverage?.indexedFrom && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["Local search covers messages back to ", new Date(coverage.indexedFrom).toLocaleDateString(), coverage.building ? " (still building)" : "", searchAllMail
|
|
2384
|
+
? " - searching everything, not just recent mail."
|
|
2385
|
+
: " - older encrypted mail is still searched, just slower."] })), isSearching && crossMailbox && tier2Done && serverOnlyMailboxes.length > 0 && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["There is no local index on this device for ", serverOnlyMailboxes.map((mb) => mb.displayName).join(", "), ": its encrypted mail is searched through the server instead, which is slower and looks at a limited number of messages."] })), isSearching && crossMailbox && lockedSearchMailboxes.length > 0 && (_jsxs("div", { className: "px-4 py-2 border-b border-border bg-surface-alt flex flex-col gap-1", children: [unlockableSearchMailboxes.length > 0 && (_jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages from ", unlockableSearchMailboxes.map((mb) => mb.displayName).join(", ")] })), sharedLockedSearchMailboxes.length > 0 && (_jsxs("p", { className: "text-xs text-text-muted", children: ["Encrypted messages in ", sharedLockedSearchMailboxes.map((mb) => mb.displayName).join(", "), " are not included: only the mailbox\u2019s owner can unlock them."] }))] })), isSearching && !crossMailbox && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching && !asConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockList, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to show ", undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'", " subject"] }) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), loading ? (_jsxs("div", { role: "status", "aria-busy": "true", className: "p-4", children: [_jsx("span", { className: "sr-only", children: "Loading\u2026" }), _jsx(SkeletonList, { count: 8 })] })) : asConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onMeetingResponded: patchListedMessage, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: tickConversation, onSelectWithModifier: handleConversationModifierClick, exitingKeys: exitingKeys, swipe: {
|
|
2386
|
+
enabled: swipeEnabled,
|
|
2387
|
+
onArchive: swipeArchive,
|
|
2388
|
+
onMove: (conversation) => setMoveDialog({ conversation }),
|
|
2389
|
+
}, onLongPress: isMobile ? startSelectingConversation : undefined }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", rowCap, " conversations \u2014 refine your search or filters to see more."] }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
|
|
2390
|
+
? `No messages match "${searchQuery}".`
|
|
2391
|
+
: effectiveFilter === "all"
|
|
2392
|
+
? "No messages in this folder."
|
|
2393
|
+
: "No messages here." }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : (_jsxs(_Fragment, { children: [_jsx("ul", { className: swipeEnabled ? "overflow-x-clip" : undefined, children: resultGroups
|
|
2394
|
+
? resultGroups.map((group) => (_jsxs("li", { "data-search-group": group.id, children: [_jsxs("div", { className: "px-4 py-1.5 bg-surface-alt border-b border-border text-xs text-text-muted flex items-center justify-between gap-2", children: [_jsx("span", { className: "truncate font-semibold", children: rowSubject(group.messages[0]) }), _jsxs("span", { className: "shrink-0", children: [group.messages.length, " matching message", group.messages.length === 1 ? "" : "s"] })] }), _jsx("ul", { children: group.messages.map(renderMessageRow) })] }, `${group.mailboxUid}\u0000${group.id}`)))
|
|
2395
|
+
: messages.map(renderMessageRow) }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", rowCap, " messages \u2014 refine your search or filters to see more."] })), aggregateFolderType && !isSearching && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] })] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0 min-h-0", children: threadPane ? (_jsx(LazyConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: labelsMailboxUid, folders: foldersOf(labelsMailboxUid), labels: labels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(LazyMessageDetailPane, { shortcuts: !selectMode, message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
|
|
2212
2396
|
// Same reasoning as onArchived below - a move takes the message out of the folder
|
|
2213
2397
|
// being listed, so it leaves the list rather than being patched in place.
|
|
2214
2398
|
removeListedMessage(updated.uid);
|
|
@@ -2222,7 +2406,7 @@ function InboxContent({ userUid }) {
|
|
|
2222
2406
|
// whichever folder is currently being viewed (into Archive), so it's removed
|
|
2223
2407
|
// from the list rather than patched in place.
|
|
2224
2408
|
removeListedMessage(updated.uid);
|
|
2225
|
-
}, onChanged: patchListedMessage, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) }), _jsx(MoveToFolderDialog, { open: moveDialog !== null, onClose: () => setMoveDialog(null), mailboxUid: activeMailboxUid, folders:
|
|
2409
|
+
}, onChanged: patchListedMessage, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) }), _jsx(MoveToFolderDialog, { open: moveDialog !== null, onClose: () => setMoveDialog(null), mailboxUid: movingFrom?.mailboxUid ?? activeMailboxUid, folders: foldersOf(movingFrom?.mailboxUid ?? activeMailboxUid), currentFolderUid: movingFrom?.folderUid, count: moveDialog === null ? 1 : "messages" in moveDialog ? moveDialog.messages.length : moveDialog.conversation.messageCount, onMove: swipeMove, onFolderCreated: onFolderCreated })] }));
|
|
2226
2410
|
}
|
|
2227
2411
|
export default InboxPage;
|
|
2228
2412
|
/** The tab's title: `Brand: Mail` (see `pageTitle()`). */
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The calendar's search: which events match what was typed, and the chronological list of their occurrences to step through. The calendar page
|
|
3
|
+
* already holds every event of the checked calendars (`listCalendarEvents()` fetches whole folders - there is no server-side text or range query
|
|
4
|
+
* to lean on), so this is entirely client-side and covers all time, not just the visible range. A series contributes the occurrences inside a window
|
|
5
|
+
* of `SEARCH_WINDOW_YEARS` either side of today, at most `SEARCH_MAX_OCCURRENCES_PER_SERIES` of them (the ones nearest today).
|
|
6
|
+
*/
|
|
7
|
+
import { CalendarEvent } from "./calendarApi.js";
|
|
8
|
+
import { CalendarOccurrence } from "./recurrence.js";
|
|
9
|
+
/** How far either side of today a recurring event's occurrences are searched. */
|
|
10
|
+
export declare const SEARCH_WINDOW_YEARS = 5;
|
|
11
|
+
/** The most occurrences one recurring event contributes (the ones nearest today), so a daily series cannot flood the results. */
|
|
12
|
+
export declare const SEARCH_MAX_OCCURRENCES_PER_SERIES = 500;
|
|
13
|
+
/** The words of `query`, folded; an event must match every one of them. Empty for a blank query. */
|
|
14
|
+
export declare function searchTerms(query: string): string[];
|
|
15
|
+
/**
|
|
16
|
+
* Whether `event` has every one of `terms` (from `searchTerms()`) somewhere in its readable text. A busy block (`redacted`: a private event on a
|
|
17
|
+
* shared calendar) never matches - its title is a placeholder, not what the event says.
|
|
18
|
+
*/
|
|
19
|
+
export declare function eventMatchesTerms(event: CalendarEvent, terms: string[]): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* The occurrences of the events of `events` that match `query`, in chronological order. A single event is one occurrence wherever it falls; a
|
|
22
|
+
* recurring one contributes those within `SEARCH_WINDOW_YEARS` of `now`, the `SEARCH_MAX_OCCURRENCES_PER_SERIES` nearest to it at most.
|
|
23
|
+
*/
|
|
24
|
+
export declare function searchOccurrences(events: CalendarEvent[], query: string, now?: Date): CalendarOccurrence[];
|
|
25
|
+
/** Every occurrence of every event, in chronological order, bounded the same way a search is - the calendar's List view shows these. */
|
|
26
|
+
export declare function allOccurrences(events: CalendarEvent[], now?: Date): CalendarOccurrence[];
|
|
27
|
+
/** The `occurrences` that overlap `[rangeStart, rangeEnd]` - what `expandOccurrences()` keeps, so a view shows exactly these. */
|
|
28
|
+
export declare function occurrencesInRange(occurrences: CalendarOccurrence[], rangeStart: Date, rangeEnd: Date): CalendarOccurrence[];
|
|
29
|
+
/**
|
|
30
|
+
* Which of the chronological `matches` to show first for a search: the first one inside the view (`[rangeStart, rangeEnd]`) when there is one,
|
|
31
|
+
* else the first that ends after `from` (the day being viewed), else the last (the nearest before). `-1` when there are no matches.
|
|
32
|
+
*/
|
|
33
|
+
export declare function initialMatchIndex(matches: CalendarOccurrence[], rangeStart: Date, rangeEnd: Date, from: Date): number;
|
|
34
|
+
/**
|
|
35
|
+
* The index of the occurrence one step from the current one in `sequence` (chronological), or `-1` for none. The current occurrence is found by
|
|
36
|
+
* `currentKey`; when it is not in the sequence (it was deleted or changed under an open card) the step is from where it started
|
|
37
|
+
* (`currentStart`): the first after it or the last before it, and from the first or last when that is not known either. Past either end it
|
|
38
|
+
* wraps round when `wrap`, else there is nothing there (`-1`).
|
|
39
|
+
*/
|
|
40
|
+
export declare function stepSequenceIndex(sequence: CalendarOccurrence[], currentKey: string | null, currentStart: Date | undefined, direction: 1 | -1, wrap: boolean): number;
|
|
41
|
+
/** The index one step from `index` (`-1`: none is current) in `direction` through `count` matches, wrapping round at either end. `-1` with none. */
|
|
42
|
+
export declare function stepMatchIndex(index: number, count: number, direction: 1 | -1): number;
|