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:
@@ -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() },
|
||||
|
||||
@@ -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