Files
ihasmail-inbuxa/web/src/views/settings/GeneralSettings.tsx
T
jcoffey-dev b83de657d7 Gather the privacy settings into a section of their own
General had grown five unrelated headings and was where anything without
an obvious home ended up. Remote images were filed under "Reading", the
read-receipt policy under "Composing", the undo-send window beside the
default message format. They are the same kind of decision -- what reaches
a sender, and what asks before something happens -- and they were the
hardest settings in the app to find.

Privacy & safety now holds all six, in three groups: remote content, read
receipts, and the things that ask before it is too late. General keeps
what it is actually about and is thirty lines shorter.

The line against Security & sessions is worth stating, because two similar
words next to each other in a nav is how a menu becomes something people
hunt through. Security & sessions is credentials and access: password,
two-factor state, app passwords, live sessions. Privacy & safety is how
the app behaves towards the reader and towards senders.

Nothing moved in storage. Settings are a flat object in settings.json and
sections are only how they are grouped on screen, so this is a UI change
with no migration and no key renames.

Two things beyond the move.

The senders trusted with remote images are now listed and can be withdrawn
one at a time. A sender was added from a message and could then only be
removed by finding another message from that same sender, which is not a
way to review a list you cannot see.

And General's lead said settings are stored in this browser, which is only
true when the server has no FileNode support. They normally live in the
account's own Files and follow it between devices, so the sentence
contradicted the feature it sat above.
2026-09-01 22:24:23 -07:00

213 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useSettings } from "@/store/settings";
import { Switch } from "@/ui/misc";
import { browserTimeZone, listTimeZones } from "@/lib/dates";
import { toast } from "@/ui/toast";
import { useState } from "react";
import { t, tNode } from "@/lib/i18n";
import {
canUnregisterMailtoHandler,
isInstalledApp,
mailtoHandlerRequested,
mailtoHandlerSupport,
registerMailtoHandler,
unregisterMailtoHandler,
} from "@/lib/mailhandler";
import {
browserLocale,
formatClock,
formatDate,
formatFullDateTime,
getServerLocale,
localeLabel,
localeOptions,
withPrefs,
type DateFormat,
} from "@/lib/datetime";
/** Illustrative instant used for the format previews: 22 Nov 2025, 18:23. */
const SAMPLE = new Date(2025, 10, 22, 18, 23);
const DATE_FORMATS: Array<{ value: DateFormat; label: string }> = [
{ value: "auto", label: "Automatic" },
{ value: "dmy-dot", label: "Day.Month.Year" },
{ value: "dmy-slash", label: "Day/Month/Year" },
{ value: "mdy-slash", label: "Month/Day/Year" },
{ value: "ymd-dash", label: "Year-Month-Day (ISO 8601)" },
];
export function GeneralSettings() {
const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update);
const reset = useSettings((st) => st.reset);
const exportJson = useSettings((st) => st.exportJson);
const importJson = useSettings((st) => st.importJson);
const serverLocale = getServerLocale();
const autoLocale = serverLocale ?? browserLocale();
return (
<div>
<h1>{t("General")}</h1>
<p className="lead">{t("Reading, sending, and how dates and times are shown. What reaches a sender lives in Privacy & safety.")}</p>
<h2>{t("Reading")}</h2>
<div className="field-row">
<div className="field">
<label>{t("Reading pane")}</label>
<select className="select" value={s.readingPane} onChange={(e) => update({ readingPane: e.target.value as typeof s.readingPane })}>
<option value="right">{t("Right of the list")}</option>
<option value="bottom">{t("Below the list")}</option>
<option value="off">{t("Off (open messages full width)")}</option>
</select>
</div>
<div className="field">
<label>{t("Mark as read")}</label>
<select className="select" value={String(s.markReadDelay)} onChange={(e) => update({ markReadDelay: Number(e.target.value) })}>
<option value="0">{t("Immediately when opened")}</option>
<option value="2">{t("After 2 seconds")}</option>
<option value="5">{t("After 5 seconds")}</option>
<option value="-1">{t("Never automatically")}</option>
</select>
</div>
<div className="field">
<label>{t("After archiving or deleting")}</label>
<select className="select" value={s.autoAdvance} onChange={(e) => update({ autoAdvance: e.target.value as typeof s.autoAdvance })}>
<option value="list">{t("Go back to the list")}</option>
<option value="older">{t("Open the next (older) conversation")}</option>
<option value="newer">{t("Open the previous (newer) conversation")}</option>
</select>
</div>
</div>
<Switch checked={s.conversationMode} onChange={(v) => update({ conversationMode: v })} label={t("Conversation view")} hint={t("Group messages from the same thread together.")} />
<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")} />
<h2>{t("Composing")}</h2>
<div className="field-row">
<div className="field">
<label>{t("Default format")}</label>
<select className="select" value={s.composeFormat} onChange={(e) => update({ composeFormat: e.target.value as typeof s.composeFormat })}>
<option value="html">{t("Rich text (HTML)")}</option>
<option value="text">{t("Plain text")}</option>
</select>
</div>
</div>
<Switch checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label={t("Quote original message in replies")} />
<Switch checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label={t("Place signature above quoted text")} />
<Switch checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} />
<h2>{t("Locale")}</h2>
<div className="field-row">
<div className="field">
<label>{t("Time zone")}</label>
<select className="select" value={s.timeZone ?? ""} onChange={(e) => update({ timeZone: e.target.value || null })}>
<option value="">{t("Browser default ({zone})", { zone: browserTimeZone })}</option>
{listTimeZones().map((tz) => <option key={tz} value={tz}>{tz}</option>)}
</select>
</div>
<div className="field">
<label>{t("Week starts on")}</label>
<select className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
<option value="1">{t("Monday")}</option>
<option value="0">{t("Sunday")}</option>
<option value="6">{t("Saturday")}</option>
</select>
</div>
</div>
<div className="field-row">
<div className="field">
<label>{t("Language & region")}</label>
<select className="select" value={s.locale} onChange={(e) => update({ locale: e.target.value })}>
<option value="">{t("Automatic ({locale})", { locale: localeLabel(autoLocale) })}</option>
{localeOptions().map((o) => <option key={o.tag} value={o.tag}>{o.label} {o.tag}</option>)}
</select>
<p className="hint">{`${serverLocale ? t("Your mail server reports {name} ({tag}).", { name: localeLabel(serverLocale), tag: serverLocale }) : t("Your mail server does not report a locale, so the browser's is used.")} ${t("Dates, times and month names follow this choice.")}`}</p>
</div>
<div className="field">
<label>{t("Date format")}</label>
<select className="select" value={s.dateFormat} onChange={(e) => update({ dateFormat: e.target.value as DateFormat })}>
{DATE_FORMATS.map((f) => (
<option key={f.value} value={f.value}>
{t(f.label)} ({withPrefs({ locale: s.locale, dateFormat: f.value }, () => formatDate(SAMPLE))})
</option>
))}
</select>
</div>
<div className="field">
<label>{t("Time format")}</label>
<select className="select" value={s.timeFormat} onChange={(e) => update({ timeFormat: e.target.value as typeof s.timeFormat })}>
<option value="auto">{t("Automatic ({example})", { example: withPrefs({ locale: s.locale, timeFormat: "auto" }, () => formatClock(SAMPLE)) })}</option>
<option value="24">{t("24-hour clock (18:23)")}</option>
<option value="12">{t("12-hour clock (6:23 PM)")}</option>
</select>
</div>
</div>
<p className="hint">{t("Preview: {example}", { example: formatFullDateTime(SAMPLE) })}</p>
<h2>{t("Default mail app")}</h2>
<MailHandlerSettings />
<h2>{t("Backup")}</h2>
<div className="row wrap">
<button className="btn" onClick={() => { const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>{t("Export settings")}</button>
<label className="btn">
{t("Import settings")}
<input type="file" accept="application/json" hidden onChange={async (e) => { const f = e.target.files?.[0]; if (!f) return; const ok = importJson(await f.text()); toast[ok ? "success" : "error"](ok ? t("Settings imported") : t("Invalid settings file")); e.target.value = ""; }} />
</label>
<button className="btn btn-ghost" onClick={() => { reset(); toast.show(t("Settings reset to defaults")); }}>{t("Reset to defaults")}</button>
</div>
</div>
);
}
/**
* Offer ihasmail as the browser's handler for `mailto:` links. The browser
* owns the decision, and nothing can read the answer back, so this states what
* it can and points at the browser's own settings for the rest.
*/
function MailHandlerSettings() {
const support = mailtoHandlerSupport();
const [requested, setRequested] = useState(mailtoHandlerRequested);
const ask = () => {
try {
registerMailtoHandler();
setRequested(true);
toast.success(t("Your browser will ask whether to open mail links in ihasmail"));
} catch (err) {
toast.error(t("Your browser refused the request: {error}", { error: (err as Error).message }));
}
};
const remove = () => {
unregisterMailtoHandler();
setRequested(false);
toast.show(t("Removed. Mail links will open in whatever your browser falls back to."));
};
if (support === "unsupported") {
return <p className="hint">{tNode("This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.", { scheme: <code>mailto:</code> })}</p>;
}
if (support === "insecure") {
return <p className="hint">{tNode("Registering for {scheme} links requires a secure (HTTPS) connection.", { scheme: <code>mailto:</code> })}</p>;
}
return (
<>
<p className="hint">
{tNode("Open {scheme} links — in web pages, documents and other apps — in ihasmail instead of a desktop mail client. Your browser will ask you to confirm, and you can change it later in its own settings (Chrome: Settings Privacy and security Site settings Protocol handlers; Firefox: Settings General Applications).", { scheme: <code>mailto:</code> })}
</p>
<div className="row wrap">
<button className="btn btn-primary" onClick={ask}>{requested ? "Ask again" : "Make ihasmail the default mail app"}</button>
{requested && canUnregisterMailtoHandler() && <button className="btn btn-ghost" onClick={remove}>{t("Remove")}</button>}
</div>
{requested && <p className="hint mt-8">{t("Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.")}</p>}
{!isInstalledApp() && (
<p className="hint mt-8">
{t("For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.")}
</p>
)}
</>
);
}