Merge branch 'main' into feat/palettes
# Conflicts: # web/src/store/settings.ts
This commit is contained in:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user