@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.
@@ -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 `conversationId`s currently ticked. */
50
+ /** The `conversationRowKey()`s currently ticked. */
51
51
  selectedConversationIds?: Set<string>;
52
- onToggleSelected?: (conversation: ConversationSummary) => void;
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(conversation.conversationId) ?? false;
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
- let outcome: EmptyFolderOutcome;
117
- try {
118
- outcome = await purgeFolder(request.folder.uid, undefined, client);
119
- } catch (err) {
120
- notifyApiError(err, `Couldn't empty ${request.folder.name}`);
121
- // Part of it may have gone before the failure: the caller reloads rather than assume nothing did.
122
- outcome = { emptied: false, deleted: [], failed: [] };
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.
@@ -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(ids: string[]) {
1890
- const missing = ids.filter((id) => !conversationMessagesById[id]);
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
- missing.map(async (id) => [id, await listConversationMessages(activeMailboxUid, id, {}, client)] as const),
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 id of missing) {
1905
- next.delete(id);
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: ConversationSummary) {
1915
- const id = conversation.conversationId;
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([id]);
1922
+ void resolveConversations([conversation]);
1928
1923
  }
1929
1924
  }
1930
1925
 
1931
1926
  function selectAllConversations() {
1932
- const ids = conversations.map(conversationKey);
1933
- setSelectedConversationIds(new Set(ids));
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: Folder, count: number | undefined) {
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 = !aggregateFolderType || isSearching;
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 ? selectedMessages.every(inDeletedItems) : currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
2590
- // Empty folder is offered at the top of the list of a mailbox's own Deleted Items or Junk Email - not for search results, nor for the "All
2591
- // mailboxes" views, which have no one folder to empty.
2592
- const emptiableFolder = !isSearching && !aggregateFolderType ? currentFolders.find((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type)) : undefined;
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 emptiableCount = emptiableFolder && folderCountOf(emptiableFolder).total > 0 ? folderCountOf(emptiableFolder).total : undefined;
2595
- const emptyDisabledReason = !mailboxWritable
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={(!!aggregateFolderType && !isSearching) || loading || listedRowCount === 0}
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 `conversationId`s currently ticked. */
33
+ /** The `conversationRowKey()`s currently ticked. */
34
34
  selectedConversationIds?: Set<string>;
35
- onToggleSelected?: (conversation: ConversationSummary) => void;
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(conversation.conversationId) ?? false;
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
- let outcome;
69
- try {
70
- outcome = await purgeFolder(request.folder.uid, undefined, client);
71
- }
72
- catch (err) {
73
- notifyApiError(err, `Couldn't empty ${request.folder.name}`);
74
- // Part of it may have gone before the failure: the caller reloads rather than assume nothing did.
75
- outcome = { emptied: false, deleted: [], failed: [] };
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.
@@ -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(ids) {
1626
- const missing = ids.filter((id) => !conversationMessagesById[id]);
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(missing.map(async (id) => [id, await listConversationMessages(activeMailboxUid, id, {}, client)]));
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 id of missing) {
1640
- next.delete(id);
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.conversationId;
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([id]);
1659
+ void resolveConversations([conversation]);
1664
1660
  }
1665
1661
  }
1666
1662
  function selectAllConversations() {
1667
- const ids = conversations.map(conversationKey);
1668
- setSelectedConversationIds(new Set(ids));
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 = !aggregateFolderType || isSearching;
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 ? selectedMessages.every(inDeletedItems) : currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
2136
- // Empty folder is offered at the top of the list of a mailbox's own Deleted Items or Junk Email - not for search results, nor for the "All
2137
- // mailboxes" views, which have no one folder to empty.
2138
- const emptiableFolder = !isSearching && !aggregateFolderType ? currentFolders.find((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type)) : undefined;
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 emptiableCount = emptiableFolder && folderCountOf(emptiableFolder).total > 0 ? folderCountOf(emptiableFolder).total : undefined;
2141
- const emptyDisabledReason = !mailboxWritable
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: (!!aggregateFolderType && !isSearching) || loading || listedRowCount === 0, selectDisabledReason: aggregateFolderType && !isSearching
2178
- ? "Open a mailbox's own folder to select messages"
2179
- : loading
2180
- ? "Wait for this folder to finish loading"
2181
- : "There is nothing here to select" })), !isMobile && _jsx("div", { className: "p-2 border-b border-border", children: searchField }), isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot), emptiableFolder && !selectMode && (_jsx(EmptyFolderBar, { folderName: emptiableFolder.name, count: emptiableCount, disabled: !!emptyDisabledReason || permanent.busy || bulkBusy, disabledReason: emptyDisabledReason, onEmpty: () => void emptyListedFolder(emptiableFolder, emptiableCount) })), permanent.dialog, offerClassificationFilters && (_jsx("div", { className: "flex border-b border-border text-xs", children: MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (_jsx("button", { type: "button", "aria-pressed": preferences.filter === value, onClick: () => updatePreferences({ filter: value }), className: [
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/web-client",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "RapidMX's webmail and admin console React UI for web-based clients.",
5
5
  "type": "module",
6
6
  "repository": "https://github.com/rapidmx/web-client",