Select a whole folder, not just the rows that are loaded
The header checkbox selected the loaded page. On a folder of ten thousand that is fifty of them, and the only way to act on the rest was to scroll until they loaded and tick again. A line now offers the rest by name once the page is selected, and taking it is a separate press. A checkbox that silently meant ten thousand when the screen shows fifty would be the worst of both, so each option says what it actually covers. The wider selection is a query rather than a list of ids. What it reaches is resolved from the server when an action runs, walked a page at a time, because a folder holds far more than one call returns and Email/set refuses more ids than maxObjectsInSet in one go -- which setEmails already chunks for. It resolves uncollapsed, unlike the list: "everything in this folder" means every message rather than one per thread, and expanding threads the way a click does is impossible here anyway, since that walks loaded Email objects and these are the ones that were never loaded. Two things this exposed. Undo is now withheld once a move reaches messages that were never loaded. It restores the folders each message was in, taken from what the browser holds, and for an unloaded message that is nothing -- so the undo would have written an empty mailboxIds and left the message in no folder at all, which is worse than the move it was undoing. move() and archiveByDate() both did this; both now drop the offer rather than restore something wrong. And an action consumes the wider selection. The optimistic paths cleared the selected ids but not the flag, so the next action would have silently reached the whole folder again.
This commit is contained in:
@@ -6,6 +6,7 @@ import { useMail, type ListState } from "@/store/mail";
|
||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||
import type { Email, Id } from "@/jmap/types";
|
||||
import { formatListDate } from "@/lib/format";
|
||||
import { mailboxDisplayName } from "@/lib/mailboxName";
|
||||
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
|
||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||
import { displayName, shortName } from "@/lib/address";
|
||||
@@ -242,6 +243,8 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
[ctxTargets, emails],
|
||||
);
|
||||
const allSelected = ids.length > 0 && ids.every((id) => selected[id]);
|
||||
const selectedAll = useMail((st) => st.selectedAll);
|
||||
const selectAllMatching = useMail((st) => st.selectAllMatching);
|
||||
const someUnread = ctxTargets.some((id) => !emails[id]?.keywords.$seen);
|
||||
const someUnstarred = ctxTargets.some((id) => !emails[id]?.keywords.$flagged);
|
||||
|
||||
@@ -265,7 +268,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
/>
|
||||
{selCount > 0 ? (
|
||||
<>
|
||||
<span className="tb-count">{plural(selCount, { one: "{n} selected", other: "{n} selected" })}</span>
|
||||
<span className="tb-count">{plural(selectedAll ? (list?.total ?? selCount) : selCount, { one: "{n} selected", other: "{n} selected" })}</span>
|
||||
<span className="tb-sep" />
|
||||
<button className="icon-btn" title={t("Archive (e)")} onClick={() => void actions.archive()}><Archive size={19} /></button>
|
||||
<button className="icon-btn" title={isTrashOrJunk ? t("Delete forever") : t("Delete (#)")} onClick={() => void actions.trash()}><Trash2 size={19} /></button>
|
||||
@@ -350,6 +353,29 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
The step past the checkbox. Ticking it selects the rows that are
|
||||
loaded, which on a folder of ten thousand is fifty of them -- and a
|
||||
checkbox that silently meant all ten thousand would be the worst of
|
||||
both. So the wider selection is offered here, in a line that says what
|
||||
each of the two actually covers, and taken deliberately.
|
||||
*/}
|
||||
{allSelected && !selectedAll && (list?.total ?? 0) > ids.length && (
|
||||
<div className="list-hint select-all-hint">
|
||||
<span className="grow">{t("All {n} on this page are selected.", { n: String(ids.length) })}</span>
|
||||
<button onClick={() => selectAllMatching()}>
|
||||
{t("Select all {n} in {folder}", { n: String(list!.total), folder: mailbox ? mailboxDisplayName(mailbox) : t("this view") })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{selectedAll && (
|
||||
<div className="list-hint select-all-hint">
|
||||
<span className="grow">
|
||||
{t("All {n} in {folder} are selected.", { n: String(list?.total ?? 0), folder: mailbox ? mailboxDisplayName(mailbox) : t("this view") })}
|
||||
</span>
|
||||
<button onClick={() => clearSelection()}>{t("Clear selection")}</button>
|
||||
</div>
|
||||
)}
|
||||
{list?.error && (
|
||||
<div className="list-hint">
|
||||
<span className="grow" style={{ color: "var(--danger)" }}>{list.error}</span>
|
||||
|
||||
Reference in New Issue
Block a user