@rapidmx/web-client 0.27.0 → 0.28.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/apps/shared/components/mail/ConversationList.tsx +3 -3
- package/apps/shared/mail/usePermanentDelete.tsx +14 -7
- package/apps/www/index.tsx +43 -35
- package/dist/apps/shared/components/mail/ConversationList.d.ts +2 -2
- package/dist/apps/shared/components/mail/ConversationList.js +1 -1
- package/dist/apps/shared/mail/usePermanentDelete.d.ts +2 -0
- package/dist/apps/shared/mail/usePermanentDelete.js +13 -8
- package/dist/apps/www/index.js +38 -29
- package/package.json +1 -1
|
@@ -47,9 +47,9 @@ export interface ConversationListProps {
|
|
|
47
47
|
* conversation is what this list's rows are. A child row stays a plain "open this message" button - a
|
|
48
48
|
* mixed conversation/message selection has no sensible bulk semantics (see `MailListToolbar`). */
|
|
49
49
|
selectMode?: boolean;
|
|
50
|
-
/** The `
|
|
50
|
+
/** The `conversationRowKey()`s currently ticked. */
|
|
51
51
|
selectedConversationIds?: Set<string>;
|
|
52
|
-
onToggleSelected?: (conversation:
|
|
52
|
+
onToggleSelected?: (conversation: ListedConversation) => void;
|
|
53
53
|
/**
|
|
54
54
|
* What swiping a parent row does on a phone: right to left archives the conversation (resolving whether it went through), left
|
|
55
55
|
* to right asks where to move it. Absent, or `enabled: false` (a desktop), rows don't follow a finger.
|
|
@@ -175,7 +175,7 @@ export default function ConversationList({
|
|
|
175
175
|
// list itself is arranged in. Copied before reversing - the fetched array is cached.
|
|
176
176
|
const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
|
|
177
177
|
const panelId = `conversation-messages-${conversation.mailboxUid ? `${conversation.mailboxUid}-` : ""}${conversation.conversationId}`;
|
|
178
|
-
const ticked = selectedConversationIds?.has(
|
|
178
|
+
const ticked = selectedConversationIds?.has(id) ?? false;
|
|
179
179
|
return (
|
|
180
180
|
<li key={id}>
|
|
181
181
|
<SwipeRow
|
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
export interface EmptiableFolder {
|
|
24
24
|
uid: string;
|
|
25
25
|
name: string;
|
|
26
|
+
/** Every folder to empty, when "the folder" is the "All mailboxes" view of one type (each mailbox's own Deleted Items): `uid` is then the first of them. */
|
|
27
|
+
uids?: string[];
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface PermanentDelete {
|
|
@@ -113,13 +115,18 @@ export function usePermanentDelete(): PermanentDelete {
|
|
|
113
115
|
finish(outcome);
|
|
114
116
|
return;
|
|
115
117
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
118
|
+
const outcome: EmptyFolderOutcome = { emptied: true, deleted: [], failed: [] };
|
|
119
|
+
for (const folderUid of request.folder.uids ?? [request.folder.uid]) {
|
|
120
|
+
try {
|
|
121
|
+
const one = await purgeFolder(folderUid, undefined, client);
|
|
122
|
+
outcome.emptied &&= one.emptied;
|
|
123
|
+
outcome.deleted.push(...one.deleted);
|
|
124
|
+
outcome.failed.push(...one.failed);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
notifyApiError(err, `Couldn't empty ${request.folder.name}`);
|
|
127
|
+
// Part of it may have gone before the failure: the caller reloads rather than assume nothing did.
|
|
128
|
+
outcome.emptied = false;
|
|
129
|
+
}
|
|
123
130
|
}
|
|
124
131
|
afterDeleted(outcome.deleted);
|
|
125
132
|
// The whole-folder request does not say which messages went, so the counts are read back rather than adjusted.
|
package/apps/www/index.tsx
CHANGED
|
@@ -80,7 +80,7 @@ import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
|
|
|
80
80
|
import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
|
|
81
81
|
import EmptyFolderBar from "../shared/components/mail/EmptyFolderBar.js";
|
|
82
82
|
import { EMPTIABLE_FOLDER_TYPES } from "../shared/mail/permanentDelete.js";
|
|
83
|
-
import { usePermanentDelete } from "../shared/mail/usePermanentDelete.js";
|
|
83
|
+
import { type EmptiableFolder, usePermanentDelete } from "../shared/mail/usePermanentDelete.js";
|
|
84
84
|
import { useMailboxUpdateAccess } from "../shared/mail/useMailboxUpdateAccess.js";
|
|
85
85
|
import {
|
|
86
86
|
CONVERSATION_SORT_NOTE,
|
|
@@ -1002,12 +1002,6 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1002
1002
|
setSearchAllMailboxes(false);
|
|
1003
1003
|
}
|
|
1004
1004
|
}, [searchQuery]);
|
|
1005
|
-
// Rows can be ticked in the results of a search over every mailbox, but not in the listing they came out of: clearing the search leaves select mode.
|
|
1006
|
-
useEffect(() => {
|
|
1007
|
-
if (aggregateFolderType && !isSearching) {
|
|
1008
|
-
setSelectMode(false);
|
|
1009
|
-
}
|
|
1010
|
-
}, [aggregateFolderType, isSearching]);
|
|
1011
1005
|
|
|
1012
1006
|
// Which listing this is, for the short-lived snapshot of each folder that makes going back to one instant (see
|
|
1013
1007
|
// `listSnapshots.ts`). A search, an aggregate view and a folder that hasn't resolved have no snapshot.
|
|
@@ -1842,7 +1836,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1842
1836
|
* Items copy of its reply), and the list only ever showed this folder's half of it, so a bulk action
|
|
1843
1837
|
* from here must not reach into the other folders' copies either. */
|
|
1844
1838
|
const selectedConversationMessages = [...selectedConversationIds].flatMap((id) =>
|
|
1845
|
-
(conversationMessagesById[id] ?? []).filter((m) => !folderUid || m.folderUid === folderUid),
|
|
1839
|
+
(conversationMessagesById[id] ?? []).filter((m) => (aggregateFolderType ? folderTypeOf(m.folderUid) === aggregateFolderType : !folderUid || m.folderUid === folderUid)),
|
|
1846
1840
|
);
|
|
1847
1841
|
const selectedMessages = asConversations
|
|
1848
1842
|
? selectedConversationMessages
|
|
@@ -1886,23 +1880,24 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1886
1880
|
/** Loads (once) the messages behind each of `ids`, so a ticked conversation resolves to the messages
|
|
1887
1881
|
* every bulk action below acts on. If any of them fails to load, none of that batch stays ticked and
|
|
1888
1882
|
* a pop-up says why - better than acting on the part of a selection that happened to arrive. */
|
|
1889
|
-
async function resolveConversations(
|
|
1890
|
-
const missing =
|
|
1883
|
+
async function resolveConversations(rows: ListedConversation[]) {
|
|
1884
|
+
const missing = rows.filter((row) => !conversationMessagesById[conversationKey(row)]);
|
|
1891
1885
|
if (missing.length === 0) {
|
|
1892
1886
|
return;
|
|
1893
1887
|
}
|
|
1894
1888
|
setResolvingSelection((n) => n + 1);
|
|
1895
1889
|
try {
|
|
1896
1890
|
const loaded = await Promise.all(
|
|
1897
|
-
|
|
1891
|
+
// A row of the "All mailboxes" view names its own mailbox; any other list is the open mailbox's.
|
|
1892
|
+
missing.map(async (row) => [conversationKey(row), await listConversationMessages(row.mailboxUid ?? activeMailboxUid, row.conversationId, {}, client)] as const),
|
|
1898
1893
|
);
|
|
1899
1894
|
setConversationMessagesById((prev) => ({ ...prev, ...Object.fromEntries(loaded) }));
|
|
1900
1895
|
} catch (err) {
|
|
1901
1896
|
notifyApiError(err, "Couldn't load the messages in one of those conversations");
|
|
1902
1897
|
setSelectedConversationIds((prev) => {
|
|
1903
1898
|
const next = new Set(prev);
|
|
1904
|
-
for (const
|
|
1905
|
-
next.delete(
|
|
1899
|
+
for (const row of missing) {
|
|
1900
|
+
next.delete(conversationKey(row));
|
|
1906
1901
|
}
|
|
1907
1902
|
return next;
|
|
1908
1903
|
});
|
|
@@ -1911,8 +1906,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1911
1906
|
}
|
|
1912
1907
|
}
|
|
1913
1908
|
|
|
1914
|
-
function toggleConversationSelected(conversation:
|
|
1915
|
-
const id = conversation
|
|
1909
|
+
function toggleConversationSelected(conversation: ListedConversation) {
|
|
1910
|
+
const id = conversationKey(conversation);
|
|
1916
1911
|
const ticking = !selectedConversationIds.has(id);
|
|
1917
1912
|
setSelectedConversationIds((prev) => {
|
|
1918
1913
|
const next = new Set(prev);
|
|
@@ -1924,14 +1919,13 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1924
1919
|
return next;
|
|
1925
1920
|
});
|
|
1926
1921
|
if (ticking) {
|
|
1927
|
-
void resolveConversations([
|
|
1922
|
+
void resolveConversations([conversation]);
|
|
1928
1923
|
}
|
|
1929
1924
|
}
|
|
1930
1925
|
|
|
1931
1926
|
function selectAllConversations() {
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
void resolveConversations(ids);
|
|
1927
|
+
setSelectedConversationIds(new Set(conversations.map(conversationKey)));
|
|
1928
|
+
void resolveConversations(conversations);
|
|
1935
1929
|
}
|
|
1936
1930
|
|
|
1937
1931
|
/**
|
|
@@ -2105,7 +2099,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2105
2099
|
}
|
|
2106
2100
|
|
|
2107
2101
|
/** Empty folder: permanently deletes everything in the folder being listed (Deleted Items or Junk Email) after the reader has confirmed. */
|
|
2108
|
-
async function emptyListedFolder(folder:
|
|
2102
|
+
async function emptyListedFolder(folder: EmptiableFolder, count: number | undefined) {
|
|
2109
2103
|
const outcome = await permanent.requestEmptyFolder({ uid: folder.uid, name: folder.name }, count);
|
|
2110
2104
|
if (!outcome) {
|
|
2111
2105
|
return;
|
|
@@ -2348,7 +2342,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2348
2342
|
const inConversations = asConversations;
|
|
2349
2343
|
/** The selection bar isn't offered in the aggregate listing (its rows belong to several mailboxes), and neither are these - but the results of a
|
|
2350
2344
|
* search over them are, each hit acted on in its own mailbox (see `inEachMailbox()`). */
|
|
2351
|
-
const keyboardActions =
|
|
2345
|
+
const keyboardActions = true;
|
|
2352
2346
|
/** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
|
|
2353
2347
|
const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
|
|
2354
2348
|
const rowCount = inConversations ? listedConversations.length : messages.length;
|
|
@@ -2586,13 +2580,33 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2586
2580
|
// Delete is the permanent one for what is in Deleted Items: for the selection when there is one (search results can come from any folder),
|
|
2587
2581
|
// else for the folder being viewed, so the button says so before anything is ticked.
|
|
2588
2582
|
const deletesPermanently =
|
|
2589
|
-
selectedMessages.length > 0
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2583
|
+
selectedMessages.length > 0
|
|
2584
|
+
? selectedMessages.every(inDeletedItems)
|
|
2585
|
+
: aggregateFolderType
|
|
2586
|
+
? aggregateFolderType === "deleted_items"
|
|
2587
|
+
: currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
|
|
2588
|
+
// Empty folder is offered at the top of the list of a mailbox's own Deleted Items or Junk Email, and of the "All mailboxes" view of either, which
|
|
2589
|
+
// empties each mailbox's own (a share the reader may not delete in refuses, and says so) - not for search results.
|
|
2590
|
+
const emptiableFolders: Folder[] =
|
|
2591
|
+
isSearching
|
|
2592
|
+
? []
|
|
2593
|
+
: aggregateFolderType
|
|
2594
|
+
? EMPTIABLE_FOLDER_TYPES.includes(aggregateFolderType)
|
|
2595
|
+
? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType))
|
|
2596
|
+
: []
|
|
2597
|
+
: currentFolders.filter((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type));
|
|
2598
|
+
const emptiableFolder: EmptiableFolder | undefined =
|
|
2599
|
+
emptiableFolders.length === 0
|
|
2600
|
+
? undefined
|
|
2601
|
+
: {
|
|
2602
|
+
uid: emptiableFolders[0].uid,
|
|
2603
|
+
name: aggregateFolderType ? (emptiableFolders[0].type === "junk" ? "Junk Email" : "Deleted Items") : emptiableFolders[0].name,
|
|
2604
|
+
uids: aggregateFolderType ? emptiableFolders.map((f) => f.uid) : undefined,
|
|
2605
|
+
};
|
|
2593
2606
|
// How many it holds as the sidebar's counts have it (kept right as messages leave), unless that says none while the list shows some.
|
|
2594
|
-
const
|
|
2595
|
-
const
|
|
2607
|
+
const emptiableTotal = emptiableFolders.reduce((sum, f) => sum + folderCountOf(f).total, 0);
|
|
2608
|
+
const emptiableCount = emptiableFolder && emptiableTotal > 0 ? emptiableTotal : undefined;
|
|
2609
|
+
const emptyDisabledReason = !(aggregateFolderType ? emptiableFolders.length > 0 : mailboxWritable)
|
|
2596
2610
|
? "This mailbox is shared with you view-only"
|
|
2597
2611
|
: listedRowCount === 0 && emptiableCount === undefined
|
|
2598
2612
|
? "This folder is already empty"
|
|
@@ -2698,14 +2712,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2698
2712
|
? CONVERSATION_SORT_NOTE
|
|
2699
2713
|
: undefined
|
|
2700
2714
|
}
|
|
2701
|
-
selectDisabled={
|
|
2702
|
-
selectDisabledReason={
|
|
2703
|
-
aggregateFolderType && !isSearching
|
|
2704
|
-
? "Open a mailbox's own folder to select messages"
|
|
2705
|
-
: loading
|
|
2706
|
-
? "Wait for this folder to finish loading"
|
|
2707
|
-
: "There is nothing here to select"
|
|
2708
|
-
}
|
|
2715
|
+
selectDisabled={loading || listedRowCount === 0}
|
|
2716
|
+
selectDisabledReason={loading ? "Wait for this folder to finish loading" : "There is nothing here to select"}
|
|
2709
2717
|
/>
|
|
2710
2718
|
)}
|
|
2711
2719
|
{!isMobile && <div className="p-2 border-b border-border">{searchField}</div>}
|
|
@@ -30,9 +30,9 @@ export interface ConversationListProps {
|
|
|
30
30
|
* conversation is what this list's rows are. A child row stays a plain "open this message" button - a
|
|
31
31
|
* mixed conversation/message selection has no sensible bulk semantics (see `MailListToolbar`). */
|
|
32
32
|
selectMode?: boolean;
|
|
33
|
-
/** The `
|
|
33
|
+
/** The `conversationRowKey()`s currently ticked. */
|
|
34
34
|
selectedConversationIds?: Set<string>;
|
|
35
|
-
onToggleSelected?: (conversation:
|
|
35
|
+
onToggleSelected?: (conversation: ListedConversation) => void;
|
|
36
36
|
/**
|
|
37
37
|
* What swiping a parent row does on a phone: right to left archives the conversation (resolving whether it went through), left
|
|
38
38
|
* to right asks where to move it. Absent, or `enabled: false` (a desktop), rows don't follow a finger.
|
|
@@ -104,7 +104,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
|
|
|
104
104
|
// list itself is arranged in. Copied before reversing - the fetched array is cached.
|
|
105
105
|
const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
|
|
106
106
|
const panelId = `conversation-messages-${conversation.mailboxUid ? `${conversation.mailboxUid}-` : ""}${conversation.conversationId}`;
|
|
107
|
-
const ticked = selectedConversationIds?.has(
|
|
107
|
+
const ticked = selectedConversationIds?.has(id) ?? false;
|
|
108
108
|
return (_jsxs("li", { children: [_jsxs(SwipeRow, { as: "div", enabled: !!swipe?.enabled && !selectMode, onArchive: () => swipe.onArchive(conversation), onMove: () => swipe.onMove(conversation), "data-message-uid": conversation.latestMessageUid, "data-unread": unread ? "true" : undefined, className: rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: unread }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: ["shrink-0 px-2 text-text-muted hover:text-text", ROW_FOCUS_CLASS].join(" "), children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["w-full text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: unread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: conversation.participants[0] ?? conversation.latestFrom, className: ["flex-1", senderClass(unread)].join(" ") }), conversation.participants.length > 1 && (_jsxs("span", { className: "text-xs text-text-muted shrink-0 font-normal", title: participantList(conversation), children: ["+", conversation.participants.length - 1, _jsxs("span", { className: "sr-only", children: [" more: ", participantList(conversation)] })] })), _jsx("span", { className: ["text-xs shrink-0", dateClass(unread)].join(" "), children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: ["text-sm truncate", subjectClass(unread)].join(" "), children: decrypted[conversation.latestMessageUid]?.subject || displaySubject(conversation.subject) || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversationLooksEncrypted(conversation) ? decrypted[conversation.latestMessageUid]?.preview || _jsx(EncryptedPreview, {}) : conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/15 text-primary-dark font-bold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: {
|
|
109
109
|
uid: conversation.latestMessageUid,
|
|
110
110
|
meetingMethod: conversation.latestMeetingMethod ?? undefined,
|
|
@@ -5,6 +5,8 @@ import { EmptyFolderOutcome, PurgeOutcome } from "./permanentDelete.js";
|
|
|
5
5
|
export interface EmptiableFolder {
|
|
6
6
|
uid: string;
|
|
7
7
|
name: string;
|
|
8
|
+
/** Every folder to empty, when "the folder" is the "All mailboxes" view of one type (each mailbox's own Deleted Items): `uid` is then the first of them. */
|
|
9
|
+
uids?: string[];
|
|
8
10
|
}
|
|
9
11
|
export interface PermanentDelete {
|
|
10
12
|
/**
|
|
@@ -65,14 +65,19 @@ export function usePermanentDelete() {
|
|
|
65
65
|
finish(outcome);
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
const outcome = { emptied: true, deleted: [], failed: [] };
|
|
69
|
+
for (const folderUid of request.folder.uids ?? [request.folder.uid]) {
|
|
70
|
+
try {
|
|
71
|
+
const one = await purgeFolder(folderUid, undefined, client);
|
|
72
|
+
outcome.emptied && (outcome.emptied = one.emptied);
|
|
73
|
+
outcome.deleted.push(...one.deleted);
|
|
74
|
+
outcome.failed.push(...one.failed);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
notifyApiError(err, `Couldn't empty ${request.folder.name}`);
|
|
78
|
+
// Part of it may have gone before the failure: the caller reloads rather than assume nothing did.
|
|
79
|
+
outcome.emptied = false;
|
|
80
|
+
}
|
|
76
81
|
}
|
|
77
82
|
afterDeleted(outcome.deleted);
|
|
78
83
|
// The whole-folder request does not say which messages went, so the counts are read back rather than adjusted.
|
package/dist/apps/www/index.js
CHANGED
|
@@ -804,12 +804,6 @@ function InboxContent({ userUid }) {
|
|
|
804
804
|
setSearchAllMailboxes(false);
|
|
805
805
|
}
|
|
806
806
|
}, [searchQuery]);
|
|
807
|
-
// Rows can be ticked in the results of a search over every mailbox, but not in the listing they came out of: clearing the search leaves select mode.
|
|
808
|
-
useEffect(() => {
|
|
809
|
-
if (aggregateFolderType && !isSearching) {
|
|
810
|
-
setSelectMode(false);
|
|
811
|
-
}
|
|
812
|
-
}, [aggregateFolderType, isSearching]);
|
|
813
807
|
// Which listing this is, for the short-lived snapshot of each folder that makes going back to one instant (see
|
|
814
808
|
// `listSnapshots.ts`). A search, an aggregate view and a folder that hasn't resolved have no snapshot.
|
|
815
809
|
const listKey = listSnapshotKey({
|
|
@@ -1582,7 +1576,7 @@ function InboxContent({ userUid }) {
|
|
|
1582
1576
|
* that is in the folder being listed. A conversation spans folders (an Inbox message and the Sent
|
|
1583
1577
|
* Items copy of its reply), and the list only ever showed this folder's half of it, so a bulk action
|
|
1584
1578
|
* from here must not reach into the other folders' copies either. */
|
|
1585
|
-
const selectedConversationMessages = [...selectedConversationIds].flatMap((id) => (conversationMessagesById[id] ?? []).filter((m) => !folderUid || m.folderUid === folderUid));
|
|
1579
|
+
const selectedConversationMessages = [...selectedConversationIds].flatMap((id) => (conversationMessagesById[id] ?? []).filter((m) => (aggregateFolderType ? folderTypeOf(m.folderUid) === aggregateFolderType : !folderUid || m.folderUid === folderUid)));
|
|
1586
1580
|
const selectedMessages = asConversations
|
|
1587
1581
|
? selectedConversationMessages
|
|
1588
1582
|
: messages.filter((m) => selectedUids.has(m.uid));
|
|
@@ -1622,22 +1616,24 @@ function InboxContent({ userUid }) {
|
|
|
1622
1616
|
/** Loads (once) the messages behind each of `ids`, so a ticked conversation resolves to the messages
|
|
1623
1617
|
* every bulk action below acts on. If any of them fails to load, none of that batch stays ticked and
|
|
1624
1618
|
* a pop-up says why - better than acting on the part of a selection that happened to arrive. */
|
|
1625
|
-
async function resolveConversations(
|
|
1626
|
-
const missing =
|
|
1619
|
+
async function resolveConversations(rows) {
|
|
1620
|
+
const missing = rows.filter((row) => !conversationMessagesById[conversationKey(row)]);
|
|
1627
1621
|
if (missing.length === 0) {
|
|
1628
1622
|
return;
|
|
1629
1623
|
}
|
|
1630
1624
|
setResolvingSelection((n) => n + 1);
|
|
1631
1625
|
try {
|
|
1632
|
-
const loaded = await Promise.all(
|
|
1626
|
+
const loaded = await Promise.all(
|
|
1627
|
+
// A row of the "All mailboxes" view names its own mailbox; any other list is the open mailbox's.
|
|
1628
|
+
missing.map(async (row) => [conversationKey(row), await listConversationMessages(row.mailboxUid ?? activeMailboxUid, row.conversationId, {}, client)]));
|
|
1633
1629
|
setConversationMessagesById((prev) => ({ ...prev, ...Object.fromEntries(loaded) }));
|
|
1634
1630
|
}
|
|
1635
1631
|
catch (err) {
|
|
1636
1632
|
notifyApiError(err, "Couldn't load the messages in one of those conversations");
|
|
1637
1633
|
setSelectedConversationIds((prev) => {
|
|
1638
1634
|
const next = new Set(prev);
|
|
1639
|
-
for (const
|
|
1640
|
-
next.delete(
|
|
1635
|
+
for (const row of missing) {
|
|
1636
|
+
next.delete(conversationKey(row));
|
|
1641
1637
|
}
|
|
1642
1638
|
return next;
|
|
1643
1639
|
});
|
|
@@ -1647,7 +1643,7 @@ function InboxContent({ userUid }) {
|
|
|
1647
1643
|
}
|
|
1648
1644
|
}
|
|
1649
1645
|
function toggleConversationSelected(conversation) {
|
|
1650
|
-
const id = conversation
|
|
1646
|
+
const id = conversationKey(conversation);
|
|
1651
1647
|
const ticking = !selectedConversationIds.has(id);
|
|
1652
1648
|
setSelectedConversationIds((prev) => {
|
|
1653
1649
|
const next = new Set(prev);
|
|
@@ -1660,13 +1656,12 @@ function InboxContent({ userUid }) {
|
|
|
1660
1656
|
return next;
|
|
1661
1657
|
});
|
|
1662
1658
|
if (ticking) {
|
|
1663
|
-
void resolveConversations([
|
|
1659
|
+
void resolveConversations([conversation]);
|
|
1664
1660
|
}
|
|
1665
1661
|
}
|
|
1666
1662
|
function selectAllConversations() {
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
void resolveConversations(ids);
|
|
1663
|
+
setSelectedConversationIds(new Set(conversations.map(conversationKey)));
|
|
1664
|
+
void resolveConversations(conversations);
|
|
1670
1665
|
}
|
|
1671
1666
|
/**
|
|
1672
1667
|
* Runs one bulk action over the current selection, then either patches the affected rows in place or
|
|
@@ -1951,7 +1946,7 @@ function InboxContent({ userUid }) {
|
|
|
1951
1946
|
const inConversations = asConversations;
|
|
1952
1947
|
/** The selection bar isn't offered in the aggregate listing (its rows belong to several mailboxes), and neither are these - but the results of a
|
|
1953
1948
|
* search over them are, each hit acted on in its own mailbox (see `inEachMailbox()`). */
|
|
1954
|
-
const keyboardActions =
|
|
1949
|
+
const keyboardActions = true;
|
|
1955
1950
|
/** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
|
|
1956
1951
|
const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
|
|
1957
1952
|
const rowCount = inConversations ? listedConversations.length : messages.length;
|
|
@@ -2132,13 +2127,31 @@ function InboxContent({ userUid }) {
|
|
|
2132
2127
|
: undefined;
|
|
2133
2128
|
// Delete is the permanent one for what is in Deleted Items: for the selection when there is one (search results can come from any folder),
|
|
2134
2129
|
// else for the folder being viewed, so the button says so before anything is ticked.
|
|
2135
|
-
const deletesPermanently = selectedMessages.length > 0
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2130
|
+
const deletesPermanently = selectedMessages.length > 0
|
|
2131
|
+
? selectedMessages.every(inDeletedItems)
|
|
2132
|
+
: aggregateFolderType
|
|
2133
|
+
? aggregateFolderType === "deleted_items"
|
|
2134
|
+
: currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
|
|
2135
|
+
// Empty folder is offered at the top of the list of a mailbox's own Deleted Items or Junk Email, and of the "All mailboxes" view of either, which
|
|
2136
|
+
// empties each mailbox's own (a share the reader may not delete in refuses, and says so) - not for search results.
|
|
2137
|
+
const emptiableFolders = isSearching
|
|
2138
|
+
? []
|
|
2139
|
+
: aggregateFolderType
|
|
2140
|
+
? EMPTIABLE_FOLDER_TYPES.includes(aggregateFolderType)
|
|
2141
|
+
? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType))
|
|
2142
|
+
: []
|
|
2143
|
+
: currentFolders.filter((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type));
|
|
2144
|
+
const emptiableFolder = emptiableFolders.length === 0
|
|
2145
|
+
? undefined
|
|
2146
|
+
: {
|
|
2147
|
+
uid: emptiableFolders[0].uid,
|
|
2148
|
+
name: aggregateFolderType ? (emptiableFolders[0].type === "junk" ? "Junk Email" : "Deleted Items") : emptiableFolders[0].name,
|
|
2149
|
+
uids: aggregateFolderType ? emptiableFolders.map((f) => f.uid) : undefined,
|
|
2150
|
+
};
|
|
2139
2151
|
// How many it holds as the sidebar's counts have it (kept right as messages leave), unless that says none while the list shows some.
|
|
2140
|
-
const
|
|
2141
|
-
const
|
|
2152
|
+
const emptiableTotal = emptiableFolders.reduce((sum, f) => sum + folderCountOf(f).total, 0);
|
|
2153
|
+
const emptiableCount = emptiableFolder && emptiableTotal > 0 ? emptiableTotal : undefined;
|
|
2154
|
+
const emptyDisabledReason = !(aggregateFolderType ? emptiableFolders.length > 0 : mailboxWritable)
|
|
2142
2155
|
? "This mailbox is shared with you view-only"
|
|
2143
2156
|
: listedRowCount === 0 && emptiableCount === undefined
|
|
2144
2157
|
? "This folder is already empty"
|
|
@@ -2174,11 +2187,7 @@ function InboxContent({ userUid }) {
|
|
|
2174
2187
|
? "This view merges the newest mail from every mailbox and is always listed by date."
|
|
2175
2188
|
: asConversations
|
|
2176
2189
|
? CONVERSATION_SORT_NOTE
|
|
2177
|
-
: undefined, selectDisabled: (
|
|
2178
|
-
? "Open a mailbox's own folder to select messages"
|
|
2179
|
-
: loading
|
|
2180
|
-
? "Wait for this folder to finish loading"
|
|
2181
|
-
: "There is nothing here to select" })), !isMobile && _jsx("div", { className: "p-2 border-b border-border", children: searchField }), isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot), emptiableFolder && !selectMode && (_jsx(EmptyFolderBar, { folderName: emptiableFolder.name, count: emptiableCount, disabled: !!emptyDisabledReason || permanent.busy || bulkBusy, disabledReason: emptyDisabledReason, onEmpty: () => void emptyListedFolder(emptiableFolder, emptiableCount) })), permanent.dialog, offerClassificationFilters && (_jsx("div", { className: "flex border-b border-border text-xs", children: MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (_jsx("button", { type: "button", "aria-pressed": preferences.filter === value, onClick: () => updatePreferences({ filter: value }), className: [
|
|
2190
|
+
: 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: [
|
|
2182
2191
|
"flex-1 py-1.5 font-semibold",
|
|
2183
2192
|
preferences.filter === value
|
|
2184
2193
|
? "text-primary-dark border-b-2 border-primary-dark"
|
package/package.json
CHANGED