Merge branch 'main' into feat/palettes
# Conflicts: # web/src/store/settings.ts
This commit is contained in:
+22
@@ -150,6 +150,28 @@ more attempt. A drag that is merely more sideways than not stays a scroll.
|
|||||||
- **Infinite scroll** with server-side paging, 50 at a time by default.
|
- **Infinite scroll** with server-side paging, 50 at a time by default.
|
||||||
- **Conversation view** groups a thread into one row, with the thread's own
|
- **Conversation view** groups a thread into one row, with the thread's own
|
||||||
message count; it can be switched off to list messages individually.
|
message count; it can be switched off to list messages individually.
|
||||||
|
- **Message order** is a setting: newest or oldest first, unread first, starred
|
||||||
|
first, largest first, by sender or by subject — or up to three levels of your
|
||||||
|
own, chosen in Settings › General. It covers the Inbox alone by default,
|
||||||
|
because unread-first is what people want where they triage and confusing in
|
||||||
|
Sent, where everything is read; it can be widened to every folder.
|
||||||
|
|
||||||
|
The ordering is done by the **server**, over the whole folder, for the same
|
||||||
|
reason search is: a list sorted in the browser is sorted only as far as the
|
||||||
|
browser has loaded, which on a folder of ten thousand is the first fifty and
|
||||||
|
a lie about the rest. Search keeps newest-first whatever the setting says — a
|
||||||
|
result list is already ordered by the question that was asked.
|
||||||
|
|
||||||
|
Every order ends with newest-first as a tiebreak, so rows inside a tie do not
|
||||||
|
shuffle between two looks at the same folder.
|
||||||
|
|
||||||
|
**Sorting on a keyword is optional in RFC 8621**, and a server that will not
|
||||||
|
do it fails the whole query rather than degrading it — so "unread first" on
|
||||||
|
such a server would mean a folder that does not open at all. The refusal is
|
||||||
|
caught once, the keyword levels dropped, and the query retried, quietly: the
|
||||||
|
reader asked for an order and got the closest the server can give, and a
|
||||||
|
toast on every folder change would be the app complaining about its own
|
||||||
|
request. `MOCK_NO_KEYWORD_SORT=1` reproduces such a server.
|
||||||
- **Multi-select** with `x`, shift-click for ranges, `Ctrl/Cmd+A` for all, and
|
- **Multi-select** with `x`, shift-click for ranges, `Ctrl/Cmd+A` for all, and
|
||||||
a long press on a touchscreen.
|
a long press on a touchscreen.
|
||||||
- **Select the whole folder**, not just the rows that happen to be loaded. The
|
- **Select the whole folder**, not just the rows that happen to be loaded. The
|
||||||
|
|||||||
+2
-1
@@ -23,7 +23,8 @@
|
|||||||
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
||||||
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
||||||
"i18n:coverage": "node scripts/i18n-coverage.mjs",
|
"i18n:coverage": "node scripts/i18n-coverage.mjs",
|
||||||
"i18n:check": "node scripts/i18n-catalog-check.mjs && node scripts/i18n-literals.mjs"
|
"i18n:check": "node scripts/i18n-catalog-check.mjs && node scripts/i18n-literals.mjs",
|
||||||
|
"dev:mock:no-keyword-sort": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-keyword-sort -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,8 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"test": "tsx --test src/*.test.ts src/**/*.test.ts",
|
"test": "tsx --test src/*.test.ts src/**/*.test.ts",
|
||||||
"mock": "tsx src/mock/index.ts",
|
"mock": "tsx src/mock/index.ts",
|
||||||
"mock:no-future-release": "MOCK_NO_FUTURE_RELEASE=1 tsx src/mock/index.ts"
|
"mock:no-future-release": "MOCK_NO_FUTURE_RELEASE=1 tsx src/mock/index.ts",
|
||||||
|
"mock:no-keyword-sort": "MOCK_NO_KEYWORD_SORT=1 tsx src/mock/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.8",
|
"@hono/node-server": "^1.13.8",
|
||||||
|
|||||||
@@ -264,6 +264,31 @@ const sharedCards: Obj[] = [
|
|||||||
{ id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
{ id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
||||||
{ id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
{ id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
||||||
];
|
];
|
||||||
|
/**
|
||||||
|
* One sort property, as Email/query defines them. `hasKeyword` sorts a
|
||||||
|
* boolean, and false comes before true -- which is what makes "unread first"
|
||||||
|
* an *ascending* sort on $seen.
|
||||||
|
*/
|
||||||
|
function compareBy(x: Obj, y: Obj, property: string, keyword?: string): number {
|
||||||
|
const addr = (v: unknown) => String(((v as Obj[] | undefined)?.[0] as Obj | undefined)?.email ?? "");
|
||||||
|
switch (property) {
|
||||||
|
case "receivedAt": return String(x.receivedAt).localeCompare(String(y.receivedAt));
|
||||||
|
case "sentAt": return String(x.sentAt ?? x.receivedAt).localeCompare(String(y.sentAt ?? y.receivedAt));
|
||||||
|
case "size": return Number(x.size ?? 0) - Number(y.size ?? 0);
|
||||||
|
case "subject": return String(x.subject ?? "").localeCompare(String(y.subject ?? ""));
|
||||||
|
case "from": return addr(x.from).localeCompare(addr(y.from));
|
||||||
|
case "to": return addr(x.to).localeCompare(addr(y.to));
|
||||||
|
case "hasKeyword": {
|
||||||
|
const has = (e: Obj) => (keyword && (e.keywords as Obj | undefined)?.[keyword] ? 1 : 0);
|
||||||
|
return has(x) - has(y);
|
||||||
|
}
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A server that does not implement sorting on keywords, so the fallback can be developed against. */
|
||||||
|
const NO_KEYWORD_SORT = process.env.MOCK_NO_KEYWORD_SORT === "1";
|
||||||
|
|
||||||
const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
|
const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
|
||||||
/** One per contact, by index; a gap means that card has no birthday. */
|
/** One per contact, by index; a gap means that card has no birthday. */
|
||||||
const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [
|
const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [
|
||||||
@@ -734,7 +759,26 @@ const handlers: Record<string, Handler> = {
|
|||||||
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
|
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
|
||||||
"Email/query": (a) => {
|
"Email/query": (a) => {
|
||||||
let list = emails.filter((e) => matchFilter(e, a.filter as Obj));
|
let list = emails.filter((e) => matchFilter(e, a.filter as Obj));
|
||||||
list.sort((x, y) => String(y.receivedAt).localeCompare(String(x.receivedAt)));
|
/*
|
||||||
|
* Honour the sort rather than always answering newest-first. This used to
|
||||||
|
* ignore it entirely, which reproduced a server that silently returns a
|
||||||
|
* different order from the one asked for -- the one shape of wrongness a
|
||||||
|
* client cannot detect.
|
||||||
|
*/
|
||||||
|
const sort = (a.sort as Obj[] | undefined) ?? [{ property: "receivedAt", isAscending: false }];
|
||||||
|
if (NO_KEYWORD_SORT && sort.some((c) => String(c.property) === "hasKeyword")) {
|
||||||
|
// A method-level failure, the way a real server refuses an optional sort:
|
||||||
|
// the whole call fails rather than the sort being quietly dropped.
|
||||||
|
throw new MethodError("unsupportedSort", "Sorting on hasKeyword is not supported.");
|
||||||
|
}
|
||||||
|
list.sort((x, y) => {
|
||||||
|
for (const c of sort) {
|
||||||
|
const asc = c.isAscending !== false;
|
||||||
|
const cmp = compareBy(x, y, String(c.property), c.keyword as string | undefined);
|
||||||
|
if (cmp !== 0) return asc ? cmp : -cmp;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
if (a.collapseThreads) {
|
if (a.collapseThreads) {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
list = list.filter((e) => { const t = e.threadId as string; if (seen.has(t)) return false; seen.add(t); return true; });
|
list = list.filter((e) => { const t = e.threadId as string; if (seen.has(t)) return false; seen.add(t); return true; });
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { appliesTo, comparatorsFor, isOptionalSort, MAX_LEVELS, withoutOptionalSorts } from "@/lib/listSort";
|
||||||
|
|
||||||
|
describe("comparatorsFor, presets", () => {
|
||||||
|
it("puts newest first by default, and can reverse it", () => {
|
||||||
|
expect(comparatorsFor("newest")).toEqual([{ property: "receivedAt", isAscending: false }]);
|
||||||
|
// No tiebreak appended: receivedAt already *is* the tiebreaker, and adding
|
||||||
|
// a contradictory second one after it would say nothing.
|
||||||
|
expect(comparatorsFor("oldest")).toEqual([{ property: "receivedAt", isAscending: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts unread first as $seen ASCENDING, because false sorts before true", () => {
|
||||||
|
// Getting this backwards puts exactly the mail you were looking for at the
|
||||||
|
// bottom, which is why it is asserted rather than assumed.
|
||||||
|
expect(comparatorsFor("unreadFirst")[0]).toEqual({ property: "hasKeyword", keyword: "$seen", isAscending: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts starred first as $flagged DESCENDING, which is the other way round", () => {
|
||||||
|
expect(comparatorsFor("starredFirst")[0]).toEqual({ property: "hasKeyword", keyword: "$flagged", isAscending: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles the plain field presets", () => {
|
||||||
|
expect(comparatorsFor("largest")[0]).toEqual({ property: "size", isAscending: false });
|
||||||
|
expect(comparatorsFor("sender")[0]).toEqual({ property: "from", isAscending: true });
|
||||||
|
expect(comparatorsFor("subject")[0]).toEqual({ property: "subject", isAscending: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to newest for a preset it does not know", () => {
|
||||||
|
expect(comparatorsFor("nonsense" as never)).toEqual([{ property: "receivedAt", isAscending: false }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("comparatorsFor, the tiebreak", () => {
|
||||||
|
it("always ends newest-first, so a tie does not shuffle between loads", () => {
|
||||||
|
for (const p of ["unreadFirst", "starredFirst", "largest", "sender", "subject"] as const) {
|
||||||
|
const out = comparatorsFor(p);
|
||||||
|
expect(out[out.length - 1]).toEqual({ property: "receivedAt", isAscending: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add a second one when the sort already ends on receivedAt", () => {
|
||||||
|
expect(comparatorsFor("newest")).toHaveLength(1);
|
||||||
|
expect(comparatorsFor("oldest")).toHaveLength(1);
|
||||||
|
expect(comparatorsFor("custom", [{ field: "date", descending: false }])).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("comparatorsFor, custom levels", () => {
|
||||||
|
it("keeps the levels in the order given", () => {
|
||||||
|
const out = comparatorsFor("custom", [
|
||||||
|
{ field: "starred", descending: true },
|
||||||
|
{ field: "unread", descending: true },
|
||||||
|
]);
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ property: "hasKeyword", keyword: "$flagged", isAscending: false },
|
||||||
|
{ property: "hasKeyword", keyword: "$seen", isAscending: true },
|
||||||
|
{ property: "receivedAt", isAscending: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes at most three, since past that nobody can predict the result", () => {
|
||||||
|
const out = comparatorsFor("custom", [
|
||||||
|
{ field: "starred", descending: true },
|
||||||
|
{ field: "unread", descending: true },
|
||||||
|
{ field: "from", descending: false },
|
||||||
|
{ field: "size", descending: true },
|
||||||
|
]);
|
||||||
|
expect(out.filter((c) => c.property === "size")).toEqual([]);
|
||||||
|
expect(out).toHaveLength(MAX_LEVELS + 1); // three levels plus the tiebreak
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a field repeated at two levels, which can only be a mistake", () => {
|
||||||
|
const out = comparatorsFor("custom", [
|
||||||
|
{ field: "from", descending: false },
|
||||||
|
{ field: "from", descending: true },
|
||||||
|
]);
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ property: "from", isAscending: true },
|
||||||
|
{ property: "receivedAt", isAscending: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the tiebreak alone when no levels were given", () => {
|
||||||
|
expect(comparatorsFor("custom", [])).toEqual([{ property: "receivedAt", isAscending: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverses a date level without losing the tiebreak", () => {
|
||||||
|
const out = comparatorsFor("custom", [{ field: "sent", descending: false }]);
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ property: "sentAt", isAscending: true },
|
||||||
|
{ property: "receivedAt", isAscending: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("optional sorts, which a server is allowed to refuse", () => {
|
||||||
|
it("recognises the keyword properties", () => {
|
||||||
|
expect(isOptionalSort({ property: "hasKeyword", keyword: "$seen" })).toBe(true);
|
||||||
|
expect(isOptionalSort({ property: "someInThreadHaveKeyword", keyword: "$flagged" })).toBe(true);
|
||||||
|
expect(isOptionalSort({ property: "receivedAt" })).toBe(false);
|
||||||
|
expect(isOptionalSort({ property: "size" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips them, leaving something the server must accept", () => {
|
||||||
|
const out = withoutOptionalSorts(comparatorsFor("custom", [
|
||||||
|
{ field: "unread", descending: true },
|
||||||
|
{ field: "size", descending: true },
|
||||||
|
]));
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ property: "size", isAscending: false },
|
||||||
|
{ property: "receivedAt", isAscending: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still ends on the tiebreak when stripping removed everything else", () => {
|
||||||
|
expect(withoutOptionalSorts(comparatorsFor("unreadFirst"))).toEqual([{ property: "receivedAt", isAscending: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a sort that was never optional alone", () => {
|
||||||
|
const plain = comparatorsFor("largest");
|
||||||
|
expect(withoutOptionalSorts(plain)).toEqual(plain);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("appliesTo", () => {
|
||||||
|
it("covers only the inbox on the narrow scope", () => {
|
||||||
|
// Unread-first is what people want where they triage, and confusing in
|
||||||
|
// Sent, where everything is read.
|
||||||
|
expect(appliesTo("inbox", "inbox")).toBe(true);
|
||||||
|
expect(appliesTo("inbox", "sent")).toBe(false);
|
||||||
|
expect(appliesTo("inbox", null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers everything on the wide one", () => {
|
||||||
|
expect(appliesTo("all", "sent")).toBe(true);
|
||||||
|
expect(appliesTo("all", null)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* What order the message list is in, expressed as a JMAP sort.
|
||||||
|
*
|
||||||
|
* The ordering is done by the **server**, over the whole folder, for the same
|
||||||
|
* reason search is: a list sorted in the browser is only sorted as far as the
|
||||||
|
* browser has loaded, which on a folder of ten thousand is the first fifty and
|
||||||
|
* a lie about the rest.
|
||||||
|
*
|
||||||
|
* That has a cost worth stating. `hasKeyword` is an optional sort property in
|
||||||
|
* RFC 8621, so a server may refuse "unread first" outright — and a refusal
|
||||||
|
* fails the whole query rather than degrading it. `isSupportedSort` and the
|
||||||
|
* fallback in the store exist for that, because a mailbox that will not open
|
||||||
|
* is a worse outcome than one in the wrong order.
|
||||||
|
*/
|
||||||
|
import type { Comparator } from "@/jmap/types";
|
||||||
|
|
||||||
|
export type SortField = "date" | "sent" | "from" | "to" | "subject" | "size" | "unread" | "starred";
|
||||||
|
|
||||||
|
export interface SortLevel {
|
||||||
|
field: SortField;
|
||||||
|
/**
|
||||||
|
* "Biggest first" for a quantity, "newest first" for a date, and for the two
|
||||||
|
* keyword fields the state people actually want at the top: unread first,
|
||||||
|
* starred first.
|
||||||
|
*/
|
||||||
|
descending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SortPreset = "newest" | "oldest" | "unreadFirst" | "starredFirst" | "largest" | "sender" | "subject" | "custom";
|
||||||
|
|
||||||
|
/** The last word in every sort, so rows inside a tie do not shuffle between loads. */
|
||||||
|
const TIEBREAK: Comparator = { property: "receivedAt", isAscending: false };
|
||||||
|
|
||||||
|
/** At most three: past that nobody can predict the order they asked for. */
|
||||||
|
export const MAX_LEVELS = 3;
|
||||||
|
|
||||||
|
const PRESETS: Record<Exclude<SortPreset, "custom">, SortLevel[]> = {
|
||||||
|
newest: [{ field: "date", descending: true }],
|
||||||
|
oldest: [{ field: "date", descending: false }],
|
||||||
|
unreadFirst: [{ field: "unread", descending: true }],
|
||||||
|
starredFirst: [{ field: "starred", descending: true }],
|
||||||
|
largest: [{ field: "size", descending: true }],
|
||||||
|
sender: [{ field: "from", descending: false }],
|
||||||
|
subject: [{ field: "subject", descending: false }],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One level as JMAP says it.
|
||||||
|
*
|
||||||
|
* The two keyword fields need care. `hasKeyword` sorts a boolean, and false
|
||||||
|
* comes before true ascending — so "unread first" is `$seen` *ascending*
|
||||||
|
* (not-seen first) while "starred first" is `$flagged` *descending*. Getting
|
||||||
|
* this backwards puts exactly the mail you were looking for at the bottom,
|
||||||
|
* which is why it is spelled out rather than inferred.
|
||||||
|
*/
|
||||||
|
function comparator(level: SortLevel): Comparator {
|
||||||
|
switch (level.field) {
|
||||||
|
case "date":
|
||||||
|
return { property: "receivedAt", isAscending: !level.descending };
|
||||||
|
case "sent":
|
||||||
|
return { property: "sentAt", isAscending: !level.descending };
|
||||||
|
case "from":
|
||||||
|
return { property: "from", isAscending: !level.descending };
|
||||||
|
case "to":
|
||||||
|
return { property: "to", isAscending: !level.descending };
|
||||||
|
case "subject":
|
||||||
|
return { property: "subject", isAscending: !level.descending };
|
||||||
|
case "size":
|
||||||
|
return { property: "size", isAscending: !level.descending };
|
||||||
|
case "unread":
|
||||||
|
return { property: "hasKeyword", keyword: "$seen", isAscending: level.descending };
|
||||||
|
case "starred":
|
||||||
|
return { property: "hasKeyword", keyword: "$flagged", isAscending: !level.descending };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a comparator asks for something a server is allowed to refuse. */
|
||||||
|
export function isOptionalSort(c: Comparator): boolean {
|
||||||
|
return c.property === "hasKeyword" || c.property === "allInThreadHaveKeyword" || c.property === "someInThreadHaveKeyword";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sort for a preset, or for the levels behind "custom".
|
||||||
|
*
|
||||||
|
* Always ends with newest-first. A sort whose last level is a keyword or a
|
||||||
|
* subject leaves every tie undefined, and an undefined order is one that
|
||||||
|
* changes between two loads of the same folder for no reason the reader can
|
||||||
|
* see.
|
||||||
|
*/
|
||||||
|
export function comparatorsFor(preset: SortPreset, levels: SortLevel[] = []): Comparator[] {
|
||||||
|
const chosen = preset === "custom" ? levels.slice(0, MAX_LEVELS) : (PRESETS[preset] ?? PRESETS.newest);
|
||||||
|
const out = chosen.filter((l, i) => chosen.findIndex((o) => o.field === l.field) === i).map(comparator);
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (!last || last.property !== "receivedAt") out.push(TIEBREAK);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sort with anything optional stripped, for a server that refused the first attempt. */
|
||||||
|
export function withoutOptionalSorts(sort: Comparator[]): Comparator[] {
|
||||||
|
const kept = sort.filter((c) => !isOptionalSort(c));
|
||||||
|
const last = kept[kept.length - 1];
|
||||||
|
if (!last || last.property !== "receivedAt") kept.push(TIEBREAK);
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this folder should use the configured order at all.
|
||||||
|
*
|
||||||
|
* "Inbox only" is the useful scope rather than a timid one: unread-first is
|
||||||
|
* what people want in the folder they triage, and confusing in Sent, where
|
||||||
|
* everything is read and the order that matters is when it went.
|
||||||
|
*/
|
||||||
|
export function appliesTo(scope: "inbox" | "all", role: string | null | undefined): boolean {
|
||||||
|
return scope === "all" || role === "inbox";
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
|||||||
import type { FolderRef } from "@/lib/sieveFolders";
|
import type { FolderRef } from "@/lib/sieveFolders";
|
||||||
import { SPAM_HEADER_PROPS } from "@/lib/spamScore";
|
import { SPAM_HEADER_PROPS } from "@/lib/spamScore";
|
||||||
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
|
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
|
||||||
|
import { isOptionalSort, withoutOptionalSorts } from "@/lib/listSort";
|
||||||
import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client";
|
import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client";
|
||||||
import type {
|
import type {
|
||||||
Comparator,
|
Comparator,
|
||||||
@@ -1129,7 +1130,45 @@ function sortIdentities(list: Identity[], accountId: Id): Identity[] {
|
|||||||
return [...list].sort((a, b) => (a.id === pref ? -1 : b.id === pref ? 1 : a.email.localeCompare(b.email)));
|
return [...list].sort((a, b) => (a.id === pref ? -1 : b.id === pref ? 1 : a.email.localeCompare(b.email)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort properties a server has already refused, so it is asked once and not
|
||||||
|
* once per folder for the rest of the session.
|
||||||
|
*
|
||||||
|
* Keyed by nothing: a refusal is about the server, and there is only one.
|
||||||
|
*/
|
||||||
|
let sortRefused = false;
|
||||||
|
|
||||||
async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) {
|
async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) {
|
||||||
|
/*
|
||||||
|
* `hasKeyword` is an optional sort in RFC 8621, and a server that will not
|
||||||
|
* do it fails the whole query rather than degrading it -- so "unread first"
|
||||||
|
* on such a server means a folder that does not open at all, which is a
|
||||||
|
* worse outcome than one in the wrong order.
|
||||||
|
*
|
||||||
|
* The refusal is caught once, the optional levels dropped, and the query
|
||||||
|
* retried. Nothing is said the first time: the reader asked for an order and
|
||||||
|
* got the closest the server can give, and a toast on every folder change
|
||||||
|
* would be the app complaining about its own request.
|
||||||
|
*/
|
||||||
|
const query = sortRefused ? { ...q, sort: withoutOptionalSorts(q.sort) } : q;
|
||||||
|
try {
|
||||||
|
return await runQueryOnce(accountId, query, position, limit);
|
||||||
|
} catch (err) {
|
||||||
|
const optional = query.sort.some(isOptionalSort);
|
||||||
|
if (!optional || !isUnsupportedSort(err)) throw err;
|
||||||
|
sortRefused = true;
|
||||||
|
return await runQueryOnce(accountId, { ...q, sort: withoutOptionalSorts(q.sort) }, position, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The error a server raises for a sort property it does not implement. */
|
||||||
|
function isUnsupportedSort(err: unknown): boolean {
|
||||||
|
const type = (err as { type?: string } | null)?.type;
|
||||||
|
const message = String((err as Error | null)?.message ?? "");
|
||||||
|
return type === "unsupportedSort" || /unsupportedSort/i.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runQueryOnce(accountId: Id, q: ListQuery, position: number, limit: number) {
|
||||||
const calls: Array<[string, Record<string, unknown>, string]> = [
|
const calls: Array<[string, Record<string, unknown>, string]> = [
|
||||||
["Email/query", { accountId, filter: q.filter, sort: q.sort, collapseThreads: q.collapseThreads, position, limit, calculateTotal: true }, "q"],
|
["Email/query", { accountId, filter: q.filter, sort: q.sort, collapseThreads: q.collapseThreads, position, limit, calculateTotal: true }, "q"],
|
||||||
["Email/get", { accountId, "#ids": { resultOf: "q", name: "Email/query", path: "/ids" }, properties: LIST_PROPS }, "e"],
|
["Email/get", { accountId, "#ids": { resultOf: "q", name: "Email/query", path: "/ids" }, properties: LIST_PROPS }, "e"],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { hasCachedJson, loadJson, saveJson } from "@/lib/storage";
|
import { hasCachedJson, loadJson, saveJson } from "@/lib/storage";
|
||||||
import { effectiveMode, legacyTheme, migrateTheme, type Mode, type PaletteId } from "@/lib/palette";
|
import { effectiveMode, legacyTheme, migrateTheme, type Mode, type PaletteId } from "@/lib/palette";
|
||||||
|
import type { SortLevel, SortPreset } from "@/lib/listSort";
|
||||||
import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync";
|
import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync";
|
||||||
import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
||||||
import type { SwipeAction } from "@/lib/swipe";
|
import type { SwipeAction } from "@/lib/swipe";
|
||||||
@@ -151,6 +152,15 @@ export interface Settings {
|
|||||||
* dates nobody put there is a surprise rather than a feature.
|
* dates nobody put there is a surprise rather than a feature.
|
||||||
*/
|
*/
|
||||||
birthdayCalendar: boolean;
|
birthdayCalendar: boolean;
|
||||||
|
/** What order the message list is in. See lib/listSort.ts. */
|
||||||
|
listSortPreset: SortPreset;
|
||||||
|
listSortLevels: SortLevel[];
|
||||||
|
/**
|
||||||
|
* Which folders it covers. Inbox-only is the useful default rather than a
|
||||||
|
* timid one: unread-first is what people want where they triage, and
|
||||||
|
* confusing in Sent, where everything is read.
|
||||||
|
*/
|
||||||
|
listSortScope: "inbox" | "all";
|
||||||
fontSize: "small" | "medium" | "large";
|
fontSize: "small" | "medium" | "large";
|
||||||
templates: Template[];
|
templates: Template[];
|
||||||
labels: Label[];
|
labels: Label[];
|
||||||
@@ -257,6 +267,9 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
timeZone: null,
|
timeZone: null,
|
||||||
labelsSidebar: true,
|
labelsSidebar: true,
|
||||||
birthdayCalendar: false,
|
birthdayCalendar: false,
|
||||||
|
listSortPreset: "newest",
|
||||||
|
listSortLevels: [],
|
||||||
|
listSortScope: "inbox",
|
||||||
fontSize: "medium",
|
fontSize: "medium",
|
||||||
templates: [],
|
templates: [],
|
||||||
labels: [],
|
labels: [],
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useLocation, useSearch } from "wouter";
|
import { useLocation, useSearch } from "wouter";
|
||||||
import { DEFAULT_SORT, useMail, type ListQuery } from "@/store/mail";
|
import { DEFAULT_SORT, useMail, type ListQuery } from "@/store/mail";
|
||||||
|
import { appliesTo, comparatorsFor } from "@/lib/listSort";
|
||||||
|
import type { Comparator } from "@/jmap/types";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { withBase } from "@/lib/basePath";
|
import { withBase } from "@/lib/basePath";
|
||||||
import { useCompose } from "@/store/compose";
|
import { useCompose } from "@/store/compose";
|
||||||
@@ -63,6 +65,20 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
navigate(`/mail/${inboxId}`, { replace: true });
|
navigate(`/mail/${inboxId}`, { replace: true });
|
||||||
}, [search, mailboxId, mailboxesLoaded, mailboxes, inboxId, navigate]);
|
}, [search, mailboxId, mailboxesLoaded, mailboxes, inboxId, navigate]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Search keeps newest-first whatever the setting says. A result list is
|
||||||
|
* already ordered by the question that was asked, and putting unread at the
|
||||||
|
* top of it answers a different one.
|
||||||
|
*/
|
||||||
|
const sortForFolder = useCallback(
|
||||||
|
(mailboxId: Id | null): Comparator[] => {
|
||||||
|
const role = mailboxId ? useMail.getState().mailboxes[mailboxId]?.role : null;
|
||||||
|
if (!appliesTo(settings.listSortScope, role)) return DEFAULT_SORT;
|
||||||
|
return comparatorsFor(settings.listSortPreset, settings.listSortLevels);
|
||||||
|
},
|
||||||
|
[settings.listSortScope, settings.listSortPreset, settings.listSortLevels],
|
||||||
|
);
|
||||||
|
|
||||||
// Build & run the list query
|
// Build & run the list query
|
||||||
const listQuery = useMemo<ListQuery | null>(() => {
|
const listQuery = useMemo<ListQuery | null>(() => {
|
||||||
if (search) {
|
if (search) {
|
||||||
@@ -77,7 +93,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
// Scheduled joins Drafts and Sent as a folder of individual messages: they
|
// Scheduled joins Drafts and Sent as a folder of individual messages: they
|
||||||
// are outgoing, and collapsing them into their threads hides them.
|
// are outgoing, and collapsing them into their threads hides them.
|
||||||
const isDraftsOrSent = mb?.role === "drafts" || mb?.role === "sent" || mailboxId === scheduledId;
|
const isDraftsOrSent = mb?.role === "drafts" || mb?.role === "sent" || mailboxId === scheduledId;
|
||||||
return { key: "", filter: { inMailbox: mailboxId }, sort: DEFAULT_SORT, collapseThreads: settings.conversationMode && !isDraftsOrSent, mailboxId };
|
return { key: "", filter: { inMailbox: mailboxId }, sort: sortForFolder(mailboxId), collapseThreads: settings.conversationMode && !isDraftsOrSent, mailboxId };
|
||||||
}, [search, q, mailboxId, mailboxes, settings.conversationMode, scheduledId]);
|
}, [search, q, mailboxId, mailboxes, settings.conversationMode, scheduledId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
|||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { t, tNode } from "@/lib/i18n";
|
import { t, tNode } from "@/lib/i18n";
|
||||||
|
import { MAX_LEVELS, type SortField, type SortPreset } from "@/lib/listSort";
|
||||||
import {
|
import {
|
||||||
canUnregisterMailtoHandler,
|
canUnregisterMailtoHandler,
|
||||||
isInstalledApp,
|
isInstalledApp,
|
||||||
@@ -35,6 +36,21 @@ const DATE_FORMATS: Array<{ value: DateFormat; label: string }> = [
|
|||||||
{ value: "ymd-dash", label: "Year-Month-Day (ISO 8601)" },
|
{ value: "ymd-dash", label: "Year-Month-Day (ISO 8601)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What each direction means in the reader's terms. "Descending" is meaningless
|
||||||
|
* for a field like Unread, where the question is which state belongs at the top.
|
||||||
|
*/
|
||||||
|
const DIRECTION_LABELS: Record<SortField, [string, string]> = {
|
||||||
|
unread: ["Unread first", "Read first"],
|
||||||
|
starred: ["Starred first", "Unstarred first"],
|
||||||
|
date: ["Newest first", "Oldest first"],
|
||||||
|
sent: ["Newest first", "Oldest first"],
|
||||||
|
from: ["Z to A", "A to Z"],
|
||||||
|
to: ["Z to A", "A to Z"],
|
||||||
|
subject: ["Z to A", "A to Z"],
|
||||||
|
size: ["Largest first", "Smallest first"],
|
||||||
|
};
|
||||||
|
|
||||||
export function GeneralSettings() {
|
export function GeneralSettings() {
|
||||||
const s = useSettings((st) => st.settings);
|
const s = useSettings((st) => st.settings);
|
||||||
const update = useSettings((st) => st.update);
|
const update = useSettings((st) => st.update);
|
||||||
@@ -81,6 +97,77 @@ export function GeneralSettings() {
|
|||||||
<Switch checked={s.showPreview} onChange={(v) => update({ showPreview: v })} label={t("Show message snippets")} hint={t("Preview the first line of each message in the list.")} />
|
<Switch checked={s.showPreview} onChange={(v) => update({ showPreview: v })} label={t("Show message snippets")} hint={t("Preview the first line of each message in the list.")} />
|
||||||
<Switch checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label={t("Show sender avatars")} />
|
<Switch checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label={t("Show sender avatars")} />
|
||||||
|
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label>{t("Message order")}</label>
|
||||||
|
<select className="select" value={s.listSortPreset} onChange={(e) => update({ listSortPreset: e.target.value as SortPreset })}>
|
||||||
|
<option value="newest">{t("Newest first")}</option>
|
||||||
|
<option value="oldest">{t("Oldest first")}</option>
|
||||||
|
<option value="unreadFirst">{t("Unread first")}</option>
|
||||||
|
<option value="starredFirst">{t("Starred first")}</option>
|
||||||
|
<option value="largest">{t("Largest first")}</option>
|
||||||
|
<option value="sender">{t("By sender")}</option>
|
||||||
|
<option value="subject">{t("By subject")}</option>
|
||||||
|
<option value="custom">{t("Custom…")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>{t("Applies to")}</label>
|
||||||
|
<select className="select" value={s.listSortScope} onChange={(e) => update({ listSortScope: e.target.value as "inbox" | "all" })}>
|
||||||
|
<option value="inbox">{t("The Inbox only")}</option>
|
||||||
|
<option value="all">{t("Every folder")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{s.listSortPreset === "custom" && (
|
||||||
|
<div className="field">
|
||||||
|
<label>{t("Sort by, in order")}</label>
|
||||||
|
{Array.from({ length: MAX_LEVELS }, (_, i) => {
|
||||||
|
const level = s.listSortLevels[i];
|
||||||
|
return (
|
||||||
|
<div className="field-row" key={i} style={{ marginBottom: 6 }}>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={level?.field ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...s.listSortLevels];
|
||||||
|
if (!e.target.value) next.splice(i);
|
||||||
|
else next[i] = { field: e.target.value as SortField, descending: level?.descending ?? true };
|
||||||
|
update({ listSortLevels: next.filter(Boolean).slice(0, MAX_LEVELS) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{i === 0 ? t("Choose…") : t("Then nothing")}</option>
|
||||||
|
<option value="unread">{t("Unread")}</option>
|
||||||
|
<option value="starred">{t("Starred")}</option>
|
||||||
|
<option value="date">{t("Date received")}</option>
|
||||||
|
<option value="sent">{t("Date sent")}</option>
|
||||||
|
<option value="from">{t("Sender")}</option>
|
||||||
|
<option value="subject">{t("Subject")}</option>
|
||||||
|
<option value="size">{t("Size")}</option>
|
||||||
|
</select>
|
||||||
|
{level && (
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={level.descending ? "desc" : "asc"}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...s.listSortLevels];
|
||||||
|
next[i] = { ...level, descending: e.target.value === "desc" };
|
||||||
|
update({ listSortLevels: next });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="desc">{DIRECTION_LABELS[level.field]![0]}</option>
|
||||||
|
<option value="asc">{DIRECTION_LABELS[level.field]![1]}</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<p className="hint">
|
||||||
|
{t("Ordered by the server over the whole folder, not just the messages loaded so far. Ties always fall back to newest first, so the order never shuffles between two looks at the same folder.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<h2>{t("Composing")}</h2>
|
<h2>{t("Composing")}</h2>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
|
|||||||
Reference in New Issue
Block a user