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:
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
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 { withBase } from "@/lib/basePath";
|
||||
import { useCompose } from "@/store/compose";
|
||||
@@ -63,6 +65,20 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
||||
navigate(`/mail/${inboxId}`, { replace: true });
|
||||
}, [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
|
||||
const listQuery = useMemo<ListQuery | null>(() => {
|
||||
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
|
||||
// are outgoing, and collapsing them into their threads hides them.
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { useState } from "react";
|
||||
import { t, tNode } from "@/lib/i18n";
|
||||
import { MAX_LEVELS, type SortField, type SortPreset } from "@/lib/listSort";
|
||||
import {
|
||||
canUnregisterMailtoHandler,
|
||||
isInstalledApp,
|
||||
@@ -35,6 +36,21 @@ const DATE_FORMATS: Array<{ value: DateFormat; label: string }> = [
|
||||
{ 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() {
|
||||
const s = useSettings((st) => st.settings);
|
||||
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.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>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
|
||||
Reference in New Issue
Block a user