Merge branch 'main' into feat/nested-labels

Both sides added a field next to the mail store's selection: the label
counts the sidebar draws, and the flag for a selection that means the
whole query rather than the loaded page. They are independent, so the
resolution keeps both.
This commit is contained in:
2026-09-01 23:27:52 -07:00
6 changed files with 346 additions and 20 deletions
+23 -12
View File
@@ -105,6 +105,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
const emails = useMail((s) => s.emails);
const threads = useMail((s) => s.threads);
const selected = useMail((s) => s.selected);
const selectedAll = useMail((s) => s.selectedAll);
const rowThreadId = useCallback((rowId: Id) => emails[rowId]?.threadId, [emails]);
const currentRowIndex = useMemo(() => {
@@ -118,7 +119,17 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
/** Email ids affected by an action on rows (selection or focused/open row). */
const targetIds = useCallback(
(rowIds?: Id[]): Id[] => {
async (rowIds?: Id[]): Promise<Id[]> => {
/*
* Everything the query matches, rather than the rows that happen to be
* loaded. Resolved from the server here and not expanded below: the
* uncollapsed query already returns every message, and the expansion
* below walks loaded Email objects, which is exactly what these are not.
*
* Only when the action came from the selection. An action aimed at one
* row -- a right-click, a swipe -- means that row, whatever is ticked.
*/
if (!rowIds && selectedAll) return await useMail.getState().queryAllIds();
const rows = rowIds ?? (Object.keys(selected).length ? Object.keys(selected) : focusId ? [focusId] : threadId ? ids.filter((id) => rowThreadId(id) === threadId) : []);
const out = new Set<Id>();
for (const r of rows) {
@@ -132,7 +143,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
}
return [...out];
},
[selected, focusId, threadId, ids, rowThreadId, emails, threads, list],
[selected, selectedAll, focusId, threadId, ids, rowThreadId, emails, threads, list],
);
const afterAction = useCallback(
@@ -189,13 +200,13 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
const actions = useMemo(
() => ({
archive: async (rows?: Id[]) => {
const t = targetIds(rows);
const t = await targetIds(rows);
if (!t.length) return;
await useMail.getState().archive(t);
afterAction(true);
},
trash: async (rows?: Id[]) => {
const t = targetIds(rows);
const t = await targetIds(rows);
if (!t.length) return;
const mail = useMail.getState();
const trashId = mail.roleId("trash");
@@ -208,7 +219,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
afterAction(true);
},
spam: async (rows?: Id[]) => {
const t = targetIds(rows);
const t = await targetIds(rows);
if (!t.length) return;
const mail = useMail.getState();
const junk = mail.roleId("junk");
@@ -217,20 +228,20 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
afterAction(true);
},
read: async (read: boolean, rows?: Id[]) => {
const t = targetIds(rows);
const t = await targetIds(rows);
if (t.length) await useMail.getState().markRead(t, read);
useMail.getState().clearSelection();
},
star: async (on: boolean, rows?: Id[]) => {
const t = targetIds(rows);
const t = await targetIds(rows);
if (t.length) await useMail.getState().star(t, on);
},
move: (rows?: Id[]) => {
const t = targetIds(rows);
move: async (rows?: Id[]) => {
const t = await targetIds(rows);
if (t.length) setMovePicker({ ids: t });
},
label: (rows: Id[] | undefined, anchor: { x: number; y: number }) => {
const t = targetIds(rows);
label: async (rows: Id[] | undefined, anchor: { x: number; y: number }) => {
const t = await targetIds(rows);
if (t.length) setLabelPicker({ ids: t, anchor });
},
moveTo: async (ids: Id[], mailboxId: Id) => {
@@ -276,7 +287,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
{ keys: "#", description: "Delete", group: "Actions", handler: () => void actions.trash() },
{ keys: "delete", description: "", group: "Actions", handler: () => void actions.trash() },
{ keys: "!", description: "Report spam / not spam", group: "Actions", handler: () => void actions.spam() },
{ keys: "s", description: "Star / unstar", group: "Actions", handler: () => { const t = targetIds(); const on = !t.every((id) => emails[id]?.keywords.$flagged); void actions.star(on); } },
{ keys: "s", description: "Star / unstar", group: "Actions", handler: () => { void (async () => { const t = await targetIds(); const on = !t.every((id) => emails[id]?.keywords.$flagged); void actions.star(on); })(); } },
{ keys: "shift+i", description: "Mark as read", group: "Actions", handler: () => void actions.read(true) },
{ keys: "shift+u", description: "Mark as unread", group: "Actions", handler: () => void actions.read(false) },
{ keys: "v", description: "Move to…", group: "Actions", handler: () => actions.move() },
+27 -1
View File
@@ -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>