Let the message list be sorted by something other than the date
Newest-first was the only order, so the mail you had not read yet was wherever it happened to fall. Seven presets and up to three levels of your own. It covers the Inbox alone by default: 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. Search keeps newest-first whatever the setting says, since a result list is already ordered by the question that was asked. The server does the sorting, over the whole folder, for the same reason search runs there: 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. Two details that are easy to get wrong and were worth pinning in tests. hasKeyword sorts a boolean and false comes before true, so "unread first" is $seen ASCENDING while "starred first" is $flagged DESCENDING -- the other way round. Getting either backwards puts exactly the mail you were looking for at the bottom. And every order ends with newest-first as a tiebreak, because a sort whose last level is a keyword or a subject leaves every tie undefined, and an undefined order changes between two looks at the same folder for no reason the reader can see. 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 this setting could turn a folder into one that does not open. The refusal is caught once, the keyword levels dropped and the query retried, and nothing is said: 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. The mock now honours the sort instead of always answering newest-first, which had it reproducing a server that silently returns a different order from the one asked for -- the one shape of wrongness a client cannot detect. MOCK_NO_KEYWORD_SORT=1 reproduces a server that refuses the keyword sorts, so the fallback can be developed against.
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