Search: - Meilisearch backend (app/search/meili.py) + factory wiring; pinned SDK 2.7 - Search-as-you-type: debounced query + Postgres prefix matching, built from the DB tokenizer so full email addresses match (email stays one lexeme) - Saved searches API + sidebar virtual mailboxes; save affordance in the box Account sync windows (SPEC §12 M2): - Choose at setup: All (recent 30d first, then remainder), Last 3 months, New only - Per-account sync_window + "Sync remaining mail" from settings (idempotent re-run) - Provider windowed backfill (SINCE/BEFORE), message_count, newest_uid - migration 0012: accounts.sync_window + backfill_total/backfill_done Backfill progress UX: - Floating progress card (auto-dissolves after 10s) + spinning sidebar ring that fills with the account colour; driven by sync.progress over WS Thread list pagination: - Server-side offset + accurate grand total, with filter/order in SQL; working prev/next across all synced mail Reading panel: - "Load images" one-off now actually renders images (?images=1) without trusting the sender; envelope carries images_allowed so a trusted sender shows no prompt Threading: - Subject fallback no longer merges messages that declare an explicit (different) parent; true siblings still group via a shared-parent reference match. Fixes unrelated auto-replies with the same subject landing in one thread Backend 257 tests green, ruff + mypy --strict clean; frontend tsc/eslint/vitest clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
594 lines
21 KiB
TypeScript
594 lines
21 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { QRCodeSVG } from "qrcode.react";
|
|
import { useEffect, useState } from "react";
|
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
|
|
import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
|
import { useT } from "../../i18n";
|
|
import { api, ApiError } from "../../lib/api";
|
|
import { useMe } from "../../lib/auth";
|
|
import { useTheme } from "../../lib/theme";
|
|
|
|
const SECTIONS = ["profile", "security", "accounts", "appearance"] as const;
|
|
type Section = (typeof SECTIONS)[number];
|
|
|
|
export function Settings() {
|
|
const t = useT();
|
|
const params = useParams();
|
|
const section = (SECTIONS.includes(params.section as Section) ? params.section : "profile") as Section;
|
|
|
|
return (
|
|
<div className="flex h-screen flex-col">
|
|
<header className="flex items-center gap-3 border-b border-app-border px-4 py-2">
|
|
<Link to="/mail" className="text-sm text-app-accent">
|
|
← {t("nav.mail")}
|
|
</Link>
|
|
<span className="font-semibold">{t("settings.title")}</span>
|
|
</header>
|
|
<div className="flex min-h-0 flex-1">
|
|
<nav className="w-52 shrink-0 border-r border-app-border p-2">
|
|
{SECTIONS.map((s) => (
|
|
<Link
|
|
key={s}
|
|
to={`/settings/${s}`}
|
|
className={`block rounded px-3 py-2 text-sm ${
|
|
section === s ? "bg-app-active font-medium text-app-fg" : "hover:bg-app-hover"
|
|
}`}
|
|
>
|
|
{t(`settings.${s}` as const)}
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
<main className="flex-1 overflow-y-auto p-6">
|
|
{section === "profile" && <ProfileSection />}
|
|
{section === "security" && <SecuritySection />}
|
|
{section === "accounts" && <AccountsSection />}
|
|
{section === "appearance" && <AppearanceSection />}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProfileSection() {
|
|
const t = useT();
|
|
const { data } = useMe();
|
|
return (
|
|
<section className="max-w-4xl space-y-3">
|
|
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
|
|
<Row label={t("settings.handle")} value={data?.handle ?? ""} />
|
|
<Row label={t("settings.recoveryEmail")} value={data?.recovery_email ?? "—"} />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Row({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="flex justify-between border-b border-app-border py-2 text-sm">
|
|
<span className="text-app-muted">{label}</span>
|
|
<span>{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SecuritySection() {
|
|
const t = useT();
|
|
const queryClient = useQueryClient();
|
|
const { data: me } = useMe();
|
|
const [setup, setSetup] = useState<{ secret: string; uri: string } | null>(null);
|
|
const [code, setCode] = useState("");
|
|
const [disabling, setDisabling] = useState(false);
|
|
const [disablePw, setDisablePw] = useState("");
|
|
const [error, setError] = useState("");
|
|
const sessions = useQuery({ queryKey: ["sessions"], queryFn: api.sessions });
|
|
|
|
const enabled = Boolean(me?.totp_enabled);
|
|
|
|
const startSetup = async () => {
|
|
const res = await api.totpSetup();
|
|
setSetup({ secret: res.secret, uri: res.provisioning_uri });
|
|
};
|
|
const confirm = async () => {
|
|
setError("");
|
|
try {
|
|
await api.totpEnable(code);
|
|
setSetup(null);
|
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
|
} catch (e) {
|
|
setError(e instanceof ApiError ? e.message : String(e));
|
|
}
|
|
};
|
|
const disable = async () => {
|
|
setError("");
|
|
try {
|
|
await api.totpDisable(disablePw);
|
|
setDisabling(false);
|
|
setDisablePw("");
|
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
|
} catch (e) {
|
|
setError(e instanceof ApiError ? e.message : String(e));
|
|
}
|
|
};
|
|
const revoke = useMutation({
|
|
mutationFn: (sid: string) => api.revokeSession(sid),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["sessions"] }),
|
|
});
|
|
|
|
return (
|
|
<section className="max-w-4xl space-y-6">
|
|
<div className="space-y-2">
|
|
<h2 className="text-lg font-semibold">{t("settings.twoFactor")}</h2>
|
|
{enabled ? (
|
|
<div className="space-y-2">
|
|
<p className="text-sm text-emerald-500">{t("settings.twoFactorOn")}</p>
|
|
{disabling ? (
|
|
<div className="space-y-2 rounded-lg border border-app-border p-3">
|
|
<p className="text-sm text-app-muted">{t("settings.disable2faPrompt")}</p>
|
|
<div className="flex gap-2">
|
|
<input
|
|
className="flex-1 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
|
|
type="password"
|
|
value={disablePw}
|
|
onChange={(e) => setDisablePw(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
<button className="rounded bg-red-600 px-3 py-1 text-sm text-white" onClick={disable}>
|
|
{t("settings.disable")}
|
|
</button>
|
|
<button className="px-2 text-sm text-app-muted" onClick={() => setDisabling(false)}>
|
|
{t("common.cancel")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
className="rounded border border-app-border px-3 py-1.5 text-sm text-app-fg hover:bg-app-hover"
|
|
onClick={() => setDisabling(true)}
|
|
>
|
|
{t("settings.disable2fa")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : setup ? (
|
|
<div className="space-y-3 rounded-lg border border-app-border p-3">
|
|
<p className="text-sm text-app-muted">{t("settings.scanQr")}</p>
|
|
<div className="flex gap-4">
|
|
<div className="shrink-0 rounded bg-white p-2 ring-1 ring-gray-200">
|
|
<QRCodeSVG value={setup.uri} size={160} level="M" />
|
|
</div>
|
|
<div className="min-w-0 space-y-1">
|
|
<p className="text-xs text-app-muted">
|
|
{t("settings.secret")}:
|
|
<br />
|
|
<code className="break-all">{setup.secret}</code>
|
|
</p>
|
|
<details className="text-xs text-app-faint">
|
|
<summary className="cursor-pointer">otpauth URI</summary>
|
|
<div className="mt-1 break-all rounded bg-app-surface px-2 py-1">{setup.uri}</div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<input
|
|
className="w-32 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
|
|
placeholder="123456"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
/>
|
|
<button className="rounded bg-app-accent px-3 py-1 text-sm text-app-accent-fg" onClick={confirm}>
|
|
{t("settings.confirm")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button className="rounded bg-app-accent px-3 py-1.5 text-sm text-app-accent-fg" onClick={startSetup}>
|
|
{t("settings.enable2fa")}
|
|
</button>
|
|
)}
|
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<h2 className="text-lg font-semibold">{t("settings.sessions")}</h2>
|
|
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
|
|
{(sessions.data ?? []).map((s) => (
|
|
<li key={s.sid} className="flex items-center justify-between px-3 py-2 text-sm">
|
|
<span>
|
|
{s.ua || "session"}{" "}
|
|
{s.current && <span className="text-xs text-emerald-500">({t("settings.thisDevice")})</span>}
|
|
<span className="block text-xs text-app-faint">
|
|
{new Date(s.created).toLocaleString()}
|
|
</span>
|
|
</span>
|
|
{!s.current && (
|
|
<button className="text-xs text-red-500" onClick={() => revoke.mutate(s.sid)}>
|
|
{t("settings.revoke")}
|
|
</button>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function AccountsSection() {
|
|
const t = useT();
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
|
const [editing, setEditing] = useState<{ id: string; mode: "account" | "signature" } | null>(
|
|
null,
|
|
);
|
|
const remove = useMutation({
|
|
mutationFn: (id: string) => api.deleteAccount(id),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
|
|
});
|
|
const downloadAll = useMutation({
|
|
mutationFn: (id: string) => api.updateAccount(id, { sync_window: "all" }),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
|
|
});
|
|
|
|
const close = () => {
|
|
setEditing(null);
|
|
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
|
};
|
|
|
|
return (
|
|
<section className="max-w-5xl space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold">{t("settings.accounts")}</h2>
|
|
<button
|
|
className="rounded bg-app-accent px-3 py-1.5 text-sm text-app-accent-fg"
|
|
onClick={() => navigate("/settings/accounts/new")}
|
|
>
|
|
{t("settings.addAccount")}
|
|
</button>
|
|
</div>
|
|
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
|
|
{(accounts.data ?? []).map((a) => (
|
|
<li key={a.id} className="px-3 py-2 text-sm">
|
|
{editing?.id === a.id && editing.mode === "account" ? (
|
|
<AccountEditor accountId={a.id} onCancel={() => setEditing(null)} onSaved={close} />
|
|
) : editing?.id === a.id && editing.mode === "signature" ? (
|
|
<SignatureEditor accountId={a.id} onCancel={() => setEditing(null)} onSaved={close} />
|
|
) : (
|
|
<div className="flex items-center justify-between">
|
|
<span className="flex items-center gap-2">
|
|
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: a.color }} />
|
|
<span>{a.email}</span>
|
|
<span className="text-xs text-app-faint">
|
|
{t("settings.syncState", { state: a.state })}
|
|
</span>
|
|
{a.sync_window !== "all" && (
|
|
<>
|
|
<span className="text-xs text-app-faint">·</span>
|
|
<span className="text-xs text-app-faint">
|
|
{t("settings.syncedPartially")}
|
|
</span>
|
|
</>
|
|
)}
|
|
</span>
|
|
<span className="flex items-center gap-3">
|
|
{a.sync_window !== "all" && (
|
|
<button
|
|
className="text-xs text-app-accent disabled:opacity-50"
|
|
disabled={downloadAll.isPending || a.state === "backfilling"}
|
|
onClick={() => downloadAll.mutate(a.id)}
|
|
>
|
|
{t("settings.downloadAllMail")}
|
|
</button>
|
|
)}
|
|
<button
|
|
className="text-xs text-app-accent"
|
|
onClick={() => setEditing({ id: a.id, mode: "signature" })}
|
|
>
|
|
{t("settings.editSignature")}
|
|
</button>
|
|
<button
|
|
className="text-xs text-app-accent"
|
|
onClick={() => setEditing({ id: a.id, mode: "account" })}
|
|
>
|
|
{t("settings.configureAccount")}
|
|
</button>
|
|
<button
|
|
className="text-xs text-red-500"
|
|
onClick={() => {
|
|
if (confirm(t("settings.confirmRemoveAccount"))) remove.mutate(a.id);
|
|
}}
|
|
>
|
|
{t("common.remove")}
|
|
</button>
|
|
</span>
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function AccountEditor({
|
|
accountId,
|
|
onCancel,
|
|
onSaved,
|
|
}: {
|
|
accountId: string;
|
|
onCancel: () => void;
|
|
onSaved: () => void;
|
|
}) {
|
|
const t = useT();
|
|
const detail = useQuery({ queryKey: ["account", accountId], queryFn: () => api.accountDetail(accountId) });
|
|
const [displayName, setDisplayName] = useState("");
|
|
const [color, setColor] = useState("#888888");
|
|
const [showServer, setShowServer] = useState(false);
|
|
const [imapHost, setImapHost] = useState("");
|
|
const [imapPort, setImapPort] = useState("993");
|
|
const [imapTls, setImapTls] = useState("ssl");
|
|
const [smtpHost, setSmtpHost] = useState("");
|
|
const [smtpPort, setSmtpPort] = useState("465");
|
|
const [smtpTls, setSmtpTls] = useState("ssl");
|
|
const [password, setPassword] = useState("");
|
|
const [error, setError] = useState("");
|
|
|
|
useEffect(() => {
|
|
const d = detail.data;
|
|
if (!d) return;
|
|
setDisplayName(d.display_name);
|
|
setColor(d.color);
|
|
setImapHost(String(d.imap_config.host ?? ""));
|
|
setImapPort(String(d.imap_config.port ?? "993"));
|
|
setImapTls(String(d.imap_config.tls ?? "ssl"));
|
|
setSmtpHost(String(d.smtp_config.host ?? ""));
|
|
setSmtpPort(String(d.smtp_config.port ?? "465"));
|
|
setSmtpTls(String(d.smtp_config.tls ?? "ssl"));
|
|
}, [detail.data]);
|
|
|
|
const save = useMutation({
|
|
mutationFn: () => {
|
|
const patch: Parameters<typeof api.updateAccount>[1] = {
|
|
display_name: displayName,
|
|
color,
|
|
};
|
|
if (showServer) {
|
|
patch.imap = { host: imapHost, port: Number(imapPort), tls: imapTls, auth: "password" };
|
|
patch.smtp = { host: smtpHost, port: Number(smtpPort), tls: smtpTls, auth: "password" };
|
|
patch.password = password;
|
|
}
|
|
return api.updateAccount(accountId, patch);
|
|
},
|
|
onSuccess: onSaved,
|
|
onError: (e) => setError(e instanceof ApiError ? e.message : "Save failed"),
|
|
});
|
|
|
|
if (!detail.data) return <div className="py-1 text-app-faint">{t("common.loading")}</div>;
|
|
|
|
const canSave = !save.isPending && (!showServer || password.length > 0);
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
className="h-8 w-8 shrink-0 cursor-pointer rounded border border-app-border bg-transparent p-0.5"
|
|
type="color"
|
|
value={color}
|
|
onChange={(e) => setColor(e.target.value)}
|
|
aria-label={t("settings.color")}
|
|
/>
|
|
<input
|
|
className="flex-1 rounded border border-app-border bg-app-surface px-2 py-1"
|
|
placeholder={t("wizard.displayName")}
|
|
value={displayName}
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{!showServer ? (
|
|
<button className="text-xs text-app-accent" onClick={() => setShowServer(true)}>
|
|
{t("settings.serverSettings")}
|
|
</button>
|
|
) : (
|
|
<div className="space-y-2 rounded border border-app-border p-2">
|
|
<p className="text-xs text-app-faint">{t("settings.serverSettingsHint")}</p>
|
|
<ServerRow
|
|
label={t("wizard.imapServer")}
|
|
host={imapHost}
|
|
port={imapPort}
|
|
tls={imapTls}
|
|
onHost={setImapHost}
|
|
onPort={setImapPort}
|
|
onTls={setImapTls}
|
|
/>
|
|
<ServerRow
|
|
label={t("wizard.smtpServer")}
|
|
host={smtpHost}
|
|
port={smtpPort}
|
|
tls={smtpTls}
|
|
onHost={setSmtpHost}
|
|
onPort={setSmtpPort}
|
|
onTls={setSmtpTls}
|
|
/>
|
|
<input
|
|
className="w-full rounded border border-app-border bg-app-surface px-2 py-1"
|
|
type="password"
|
|
placeholder={t("wizard.accountPassword")}
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
|
<div className="flex gap-2">
|
|
<button
|
|
className="rounded bg-app-accent px-3 py-1 text-sm text-app-accent-fg disabled:opacity-50"
|
|
disabled={!canSave}
|
|
onClick={() => {
|
|
setError("");
|
|
save.mutate();
|
|
}}
|
|
>
|
|
{save.isPending ? t("common.saving") : t("common.save")}
|
|
</button>
|
|
<button className="px-3 py-1 text-sm text-app-muted hover:text-app-fg" onClick={onCancel}>
|
|
{t("common.cancel")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SignatureEditor({
|
|
accountId,
|
|
onCancel,
|
|
onSaved,
|
|
}: {
|
|
accountId: string;
|
|
onCancel: () => void;
|
|
onSaved: () => void;
|
|
}) {
|
|
const t = useT();
|
|
const detail = useQuery({
|
|
queryKey: ["account", accountId],
|
|
queryFn: () => api.accountDetail(accountId),
|
|
});
|
|
const [signature, setSignature] = useState("");
|
|
const [error, setError] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (detail.data) setSignature(detail.data.signature);
|
|
}, [detail.data]);
|
|
|
|
const save = useMutation({
|
|
mutationFn: () => api.updateAccount(accountId, { signature }),
|
|
onSuccess: onSaved,
|
|
onError: (e) => setError(e instanceof ApiError ? e.message : "Save failed"),
|
|
});
|
|
|
|
if (!detail.data) return <div className="py-1 text-app-faint">{t("common.loading")}</div>;
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="text-xs text-app-muted">
|
|
{t("settings.signature")} — {detail.data.email}
|
|
</div>
|
|
<div className="flex flex-col overflow-hidden rounded border border-app-border">
|
|
<RichTextEditor
|
|
initialHtml={detail.data.signature}
|
|
onInput={setSignature}
|
|
minHeight="min-h-[160px]"
|
|
/>
|
|
</div>
|
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
|
<div className="flex gap-2">
|
|
<button
|
|
className="rounded bg-app-accent px-3 py-1 text-sm text-app-accent-fg disabled:opacity-50"
|
|
disabled={save.isPending}
|
|
onClick={() => {
|
|
setError("");
|
|
save.mutate();
|
|
}}
|
|
>
|
|
{save.isPending ? t("common.saving") : t("common.save")}
|
|
</button>
|
|
<button className="px-3 py-1 text-sm text-app-muted hover:text-app-fg" onClick={onCancel}>
|
|
{t("common.cancel")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ServerRow({
|
|
label,
|
|
host,
|
|
port,
|
|
tls,
|
|
onHost,
|
|
onPort,
|
|
onTls,
|
|
}: {
|
|
label: string;
|
|
host: string;
|
|
port: string;
|
|
tls: string;
|
|
onHost: (v: string) => void;
|
|
onPort: (v: string) => void;
|
|
onTls: (v: string) => void;
|
|
}) {
|
|
const t = useT();
|
|
return (
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="w-24 shrink-0 text-xs text-app-muted">{label}</span>
|
|
<input
|
|
className="min-w-0 flex-1 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
|
|
placeholder={t("wizard.host")}
|
|
value={host}
|
|
onChange={(e) => onHost(e.target.value)}
|
|
/>
|
|
<input
|
|
className="w-16 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
|
|
placeholder={t("wizard.port")}
|
|
value={port}
|
|
onChange={(e) => onPort(e.target.value)}
|
|
/>
|
|
<select
|
|
className="rounded border border-app-border bg-app-surface px-1 py-1 text-sm"
|
|
value={tls}
|
|
onChange={(e) => onTls(e.target.value)}
|
|
aria-label={t("wizard.security")}
|
|
>
|
|
<option value="ssl">SSL</option>
|
|
<option value="starttls">STARTTLS</option>
|
|
</select>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AppearanceSection() {
|
|
const t = useT();
|
|
const queryClient = useQueryClient();
|
|
const { theme, setTheme } = useTheme();
|
|
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
|
const patch = useMutation({
|
|
mutationFn: api.patchSettings,
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
|
|
});
|
|
|
|
return (
|
|
<section className="max-w-4xl space-y-3">
|
|
<h2 className="text-lg font-semibold">{t("settings.appearance")}</h2>
|
|
<div className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
|
<span className="text-app-muted">{t("settings.theme")}</span>
|
|
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
|
|
{(["light", "dark", "system"] as const).map((value) => (
|
|
<button
|
|
key={value}
|
|
onClick={() => setTheme(value)}
|
|
className={`rounded px-3 py-1 ${
|
|
theme === value ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover"
|
|
}`}
|
|
>
|
|
{t(`theme.${value}` as const)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
|
<span className="text-app-muted">{t("settings.threadOrder")}</span>
|
|
<select
|
|
className="rounded border border-app-border bg-app-surface px-2 py-1"
|
|
value={settings.data?.thread_order ?? "asc"}
|
|
onChange={(e) => patch.mutate({ thread_order: e.target.value as "asc" | "desc" })}
|
|
>
|
|
<option value="asc">{t("mail.orderOldest")}</option>
|
|
<option value="desc">{t("mail.orderNewest")}</option>
|
|
</select>
|
|
</label>
|
|
<Row label={t("settings.timezone")} value={settings.data?.tz ?? "UTC"} />
|
|
</section>
|
|
);
|
|
}
|