Merge branch 'main' into feat/palettes
# Conflicts: # web/src/store/settings.ts
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