Files
margin-mail/src/screens/Settings.tsx
T
2026-10-03 23:24:24 +05:30

2625 lines
91 KiB
TypeScript

import { useEffect, useMemo, useState, type ReactNode } from "react";
import { getVersion } from "@tauri-apps/api/app";
import { openPath, revealItemInDir } from "@tauri-apps/plugin-opener";
import { relaunch } from "@tauri-apps/plugin-process";
import { check, type Update } from "@tauri-apps/plugin-updater";
import {
BUNDLED_FONTS,
decodeRef,
encodeRef,
fontLabel,
refsEqual,
} from "margin-shared/fonts";
import { Avatar, Button, Confirm, Icon, icons, NO_AUTOFILL, Segment, Sheet, Toggle } from "../ui";
import type { SegmentOption } from "../ui";
import { accountRemove, accountSetColor, accountSetName } from "../api/accounts";
import { backupConfigure, backupNow, backupPhrase, backupRestore } from "../api/backup";
import { contactUpdate, contactsList } from "../api/contacts";
import { imapServers } from "../api/imap";
import { otpAutofillEnable, otpAutofillStatus, type OtpAutofillStatus } from "../api/otpAutofill";
import {
askForNotifications,
notifyPermission,
notifyTest,
openNotificationSettings,
} from "../api/notifications";
import {
exportMbox,
exportState,
importState,
keymapPath,
keymapReset,
mirrorClear,
packagedBy,
} from "../api/settings";
import { threadsList } from "../api/threads";
import { applyFonts, applyTextSize, forgetThemeChoice, systemTheme } from "../appearance";
import { useEscapeLayer } from "../escape";
import { registerCommands } from "../keys/commands";
import {
CALENDAR_SCOPE,
DRIVE_SCOPE,
REQUIRED_SCOPE,
SCOPES,
isDesktop,
isMacDesktop,
isTauri,
type Account,
type BackupSettings,
type ContactCard,
type FontRef,
type MailConfig,
type NotifyPermission,
type Place,
type Settings as SettingsDto,
type ThreadSummary,
type SyncStatus,
} from "../ipc";
import { useAccounts } from "../store/useAccounts";
import { useImapConnect } from "../store/useImapConnect";
import { useMail } from "../store/useMail";
import { useSettings } from "../store/useSettings";
import { useSync } from "../store/useSync";
import { useTheme } from "../store/useTheme";
import { notify } from "../store/useToast";
import { version as builtVersion } from "../../package.json";
import {
ConnectMail,
ConnectMailServers,
connectMailTitle,
serverLine,
} from "./ConnectMail";
import { accountHue, displayName, messageTime } from "./format";
import { NOTIFY_PLACES, withPlace } from "./notifyPlaces";
import "./settings.css";
/**
* Settings is a place, not a panel: the whole stage, a rail of section names down the left and one
* section on the right. Twelve sections is too much for an overlay that steals the window and too
* much for a palette that shows one row at a time, and both shapes make somebody looking for the
* storage window read the list twice.
*
* Every section here is real controls over real commands. Where docs/settings.md asks for
* something no command answers, the screen says what it can stand behind and offers no button,
* because a control that does nothing is worse than a sentence admitting the gap.
*/
const SECTIONS = [
"Accounts",
"Appearance",
"Mail",
"Privacy",
"Screener",
"Piles and snooze",
"Writing",
"Notifications",
"Keyboard",
"Backup",
"Data",
"About",
] as const;
type Section = (typeof SECTIONS)[number];
/**
* The section on the right. Function declarations, so the map may sit beside the list it answers
* rather than at the foot of the file behind everything it names.
*/
const VIEWS: Record<Section, () => ReactNode> = {
Accounts: AccountsSection,
Appearance: AppearanceSection,
Mail: MailSection,
Privacy: PrivacySection,
Screener: ScreenerSection,
"Piles and snooze": PilesSection,
Writing: WritingSection,
Notifications: NotificationsSection,
Keyboard: KeyboardSection,
Backup: BackupSection,
Data: DataSection,
About: AboutSection,
};
// -------------------------------------------------------------------------------------------
// Permissions
// -------------------------------------------------------------------------------------------
interface Permission {
scope: string;
/** What it lets the app do, in the words a person would use. Never the name of the scope. */
label: string;
/** What is lost without it, which is the only useful thing to say about a missing one. */
cost: string;
}
/**
* The six capabilities, in the order the consent screen lists them.
*
* `openid` and `email` are not here. They say who you are rather than what the app may do, and a
* line reading "Know who you are: Granted" beside five real permissions is noise.
*/
const PERMISSIONS: Permission[] = [
{
scope: REQUIRED_SCOPE,
label: "Read and change your mail",
cost: "Without this there is no account: reading, archiving and sending are all this one permission.",
},
{
scope: "https://www.googleapis.com/auth/gmail.settings.basic",
label: "Read your signature and aliases",
cost: "Signatures and verified aliases are not connected, so replies go out from the main address with nothing after them.",
},
{
scope: "https://www.googleapis.com/auth/contacts.readonly",
label: "Read your contacts",
cost: "Contacts are not connected, so addressing a message only autocompletes people you have already written to here.",
},
{
scope: "https://www.googleapis.com/auth/contacts.other.readonly",
label: "Read the people you have written to",
cost: "The Screener has less to go on when it screens people in, so more first messages wait than need to.",
},
{
scope: DRIVE_SCOPE,
label: "Keep a backup in your Drive",
cost: "Backup to Drive is not connected, so your decisions live only on this machine.",
},
{
scope: CALENDAR_SCOPE,
label: "Answer calendar invitations",
cost: "Calendar is not connected, so an invitation renders but Accept, Maybe and Decline do nothing. Granting reopens the Google consent page.",
},
];
/**
* Google normalises the two short scopes into their long forms in what it grants back, so a grant
* is compared on the canonical spelling rather than on the string that was asked for.
*/
const CANONICAL: Record<string, string> = {
email: "https://www.googleapis.com/auth/userinfo.email",
profile: "https://www.googleapis.com/auth/userinfo.profile",
};
const canonical = (scope: string): string => CANONICAL[scope] ?? scope;
const holds = (granted: string[], scope: string): boolean =>
granted.some((one) => canonical(one) === canonical(scope));
/** What a scope is called in plain English, for the refused screen as well as this one. */
export function permissionName(scope: string): string {
return PERMISSIONS.find((p) => p.scope === scope)?.label ?? scope;
}
const BASE: readonly string[] = SCOPES;
/**
* The extras a re-consent has to carry.
*
* Google has no incremental authorization for installed apps, so granting one permission re-runs
* the whole consent and replaces the token. Asking only for the missing one would hand back a grant
* without the optional scopes this account already had, which is a silent revocation.
*/
function extrasFor(account: Account, wanted: string): string[] {
const optional = [DRIVE_SCOPE, CALENDAR_SCOPE];
const keep = optional.filter((scope) => holds(account.grantedScopes, scope));
const extras = new Set([...keep, ...(BASE.includes(wanted) ? [] : [wanted])]);
return [...extras];
}
// -------------------------------------------------------------------------------------------
// The place
// -------------------------------------------------------------------------------------------
export function Settings() {
const close = useSettings((s) => s.close);
const load = useSettings((s) => s.load);
const linked = useAccounts((s) => s.accounts.length);
const [section, setSection] = useState<Section>("Accounts");
const View = VIEWS[section];
useEscapeLayer(true, close);
// Again when an account is added or removed, because the settings file grows and loses a row with
// the account list and nothing else would tell this screen that its copy is a row short.
useEffect(() => {
void load();
}, [load, linked]);
// The rail is the list while settings has the stage, so it answers to the same two keys. The
// list column is not mounted, so its handlers are not in the way.
useEffect(() => {
const step = (delta: number) =>
setSection((was) => {
const at = SECTIONS.indexOf(was) + delta;
return SECTIONS[Math.min(Math.max(at, 0), SECTIONS.length - 1)];
});
return registerCommands({
"select-next": () => step(1),
"select-prev": () => step(-1),
});
}, []);
return (
<>
<main className="stage">
<section className="settings">
<nav className="settings-rail" aria-label="Settings">
<h1>Settings</h1>
{SECTIONS.map((name) => (
<button
key={name}
type="button"
className="settings-tab"
data-active={name === section ? "" : undefined}
aria-current={name === section ? "page" : undefined}
onClick={() => setSection(name)}
>
{name}
</button>
))}
</nav>
<div className="settings-panel">
<div className="settings-inner">
<View />
</div>
</div>
</section>
</main>
{/* The servers and the certificate question take the window rather than a place in the
section that opened them, so they hang off the stage the way they hang off the welcome
screen. The panel above is its own scroller, and an overlay mounted inside a thing that
scrolls is an overlay that can be scrolled away from. */}
<ConnectMailServers />
</>
);
}
// -------------------------------------------------------------------------------------------
// Accounts
// -------------------------------------------------------------------------------------------
const COUNTS = ["No", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight"];
const HUES = [1, 2, 3, 4, 5, 6, 7, 8];
function AccountsSection() {
const accounts = useAccounts((s) => s.accounts);
const phase = useAccounts((s) => s.phase);
const authUrl = useAccounts((s) => s.authUrl);
const addAccount = useAccounts((s) => s.addAccount);
const grant = useAccounts((s) => s.grant);
const openAuthUrl = useAccounts((s) => s.openAuthUrl);
const copyAuthUrl = useAccounts((s) => s.copyAuthUrl);
const cancelConnect = useAccounts((s) => s.cancelConnect);
const mailPhase = useImapConnect((s) => s.phase);
const mailConfig = useImapConnect((s) => s.config);
const mailEmail = useImapConnect((s) => s.email);
const mailBlocked = useImapConnect((s) => s.blocked);
const startMail = useImapConnect((s) => s.start);
const backMail = useImapConnect((s) => s.back);
const leaveMail = useImapConnect((s) => s.leave);
const settings = useSettings((s) => s.settings);
const [removing, setRemoving] = useState(false);
const waiting = phase === "connecting";
const rowFor = (accountId: string) =>
settings?.accounts.find((row) => row.accountId === accountId) ?? null;
// The same field as the one in Writing, because this is where somebody looking for it looks.
const setSignature = (accountId: string, signature: string) => {
if (!settings) return;
void useSettings.getState().save({
accounts: settings.accounts.map((row) =>
row.accountId === accountId ? { ...row, signature } : row,
),
});
};
const many = accounts.length;
const said = many < COUNTS.length ? COUNTS[many] : String(many);
return (
<>
<h2 className="settings-title">Accounts</h2>
<p className="settings-note">
{said} {many === 1 ? "account" : "accounts"}, each with its own places, rules and piles. The
colour is the edge on a row in All accounts.
</p>
{accounts.map((account) => {
const row = rowFor(account.id);
// The only thing on this screen that reads the kind, and it reads it three times: what sits
// under the card, what the aliases line can honestly say, and whether there is a Google
// account here at all for the footnote at the foot to be about.
const google = account.kind === "google";
const missing = google ? PERMISSIONS.filter((p) => !holds(account.grantedScopes, p.scope)) : [];
return (
<div className="acct" key={account.id}>
<div className="acct-head">
<Avatar
name={account.name}
address={account.email}
hue={accountHue(account.color)}
/>
<div>
<Draft
className="settings-input acct-name"
label={`Name for ${account.email}`}
value={account.name}
placeholder={account.email}
onCommit={(name) => {
// Written to the list first, or the field flips back to the old name for
// the frame between the command and the refresh that follows it.
const was = account.name;
useAccounts.getState().patch(account.id, { name });
void accountSetName(account.id, name)
.then(() => useAccounts.getState().refresh())
.catch((e) => {
useAccounts.getState().patch(account.id, { name: was });
notify(`Could not change that name: ${e}`);
});
}}
/>
<div className="acct-addr">{account.email}</div>
</div>
<div className="swatches">
{HUES.map((hue) => (
<button
key={hue}
type="button"
className="swatch"
data-hue={hue}
data-on={account.color === `hue-${hue}` ? "" : undefined}
title={`Colour ${hue}`}
aria-label={`Colour ${hue}`}
onClick={() => {
void accountSetColor(account.id, `hue-${hue}`)
.then(() => useAccounts.getState().refresh())
.catch((e) => notify(`Could not change that colour: ${e}`));
}}
/>
))}
</div>
</div>
<div className="set-rows">
<div className="set-row">
<span className="lab">Signature</span>
<Draft
className="settings-input val"
label={`Signature for ${account.email}`}
value={row?.signature ?? ""}
placeholder="Not set"
onCommit={(signature) => setSignature(account.id, signature)}
/>
</div>
{/* Aliases are the provider's answer, so an account with no provider to ask has to
say that rather than report a count of nothing. There is no command in IMAP or in
SMTP for "which addresses may I send as", and inventing one is not on offer, so a
second address on a mailbox like this is a second account. The signature above is
not the same case: it is Margin's own field, held here and journalled to the other
devices, and it works the same on either kind. */}
<div className="set-row" data-wrap={google ? undefined : ""}>
<span className="lab">Aliases</span>
<span className="val">
{google
? row && row.aliases.length > 0
? row.aliases.join(", ")
: "None on the provider"
: "Neither IMAP nor SMTP has a way to publish them, so this account only ever sends as its own address."}
</span>
</div>
{google && missing.length === 0 ? (
<div className="set-row">
<span className="lab">Permissions</span>
<span className="val">All granted</span>
</div>
) : null}
</div>
{!google ? <Servers account={account} /> : missing.length > 0 ? (
<div className="scopes">
<div className="scopes-head">Permissions</div>
{PERMISSIONS.map((permission) => {
const granted = holds(account.grantedScopes, permission.scope);
return (
<div key={permission.scope}>
<div className="scope" data-missing={granted ? undefined : ""}>
{/* There is no minus in the icon set and a screen does not invent one, so
an ungranted line carries a rule rather than a glyph. */}
{granted ? <Icon d={icons.CHECK} size={13} /> : <span className="scope-dash" aria-hidden="true" />}
<span className="what">{permission.label}</span>
{granted ? (
<span className="state">Granted</span>
) : (
<Button
size="sm"
disabled={waiting}
onClick={() =>
void grant(account.id, extrasFor(account, permission.scope))
}
>
{waiting ? "Waiting" : "Grant"}
</Button>
)}
</div>
{granted ? null : <p className="scope-why">{permission.cost}</p>}
</div>
);
})}
</div>
) : null}
</div>
);
})}
<div className="acct-foot">
{/* Not a Google button wearing a general name. This one asks which mailbox it is, because
the answer decides whether the next thing that happens is a browser or a password
field, and half the addresses this app was built for are not Google's. */}
<Button icon={icons.PLUS} disabled={waiting} onClick={startMail}>
Add account
</Button>
{accounts.length > 0 ? (
<Button variant="ghost" onClick={() => setRemoving(true)}>
Remove an account
</Button>
) : null}
</div>
{waiting ? (
<div className="settings-waiting">
<span>Waiting for Google in your browser.</span>
<Button size="sm" disabled={!authUrl} onClick={openAuthUrl}>
Open link again
</Button>
<Button size="sm" disabled={!authUrl} onClick={() => void copyAuthUrl()}>
Copy link
</Button>
<Button size="sm" variant="ghost" onClick={cancelConnect}>
Cancel
</Button>
</div>
) : null}
{/* True of a Google account and of nothing else. On a machine where every account is an IMAP
one there is no Google account page for it to be about, and a footnote explaining the
consequences of revoking a grant nobody made is a paragraph that reads as though the app
has quietly signed you in to something. */}
{accounts.some((account) => account.kind === "google") ? (
<p className="settings-quiet">
Margin Mail, Margin and Margin Calendar share one Google client, so your Google account
lists them once, as Margin, and revoking it revokes all three.
</p>
) : null}
<AddSheet
open={mailPhase === "address" || mailPhase === "password" || mailPhase === "unsupported"}
title={connectMailTitle(mailPhase, mailConfig, mailEmail, mailBlocked)}
onBack={mailPhase === "address" ? undefined : backMail}
onClose={leaveMail}
>
<ConnectMail
host="sheet"
onGoogle={(email) => {
// The browser has the flow from here, and the strip above the account list is what
// waits for it: a sheet left open over that would be two things waiting for one answer.
leaveMail();
void addAccount(email);
}}
/>
</AddSheet>
<RemoveSheet open={removing} accounts={accounts} onClose={() => setRemoving(false)} />
</>
);
}
/**
* Add account, which is the welcome screen's flow in a panel.
*
* The same `ConnectMail` renders here with its head left to this panel, so a sentence reworded on
* one screen is reworded on both, and the panel's own back control retraces the flow's steps. The
* servers and the certificate hang off the stage rather than off this sheet, because they are a
* wider panel than this one and going back from them comes back here: the sheet gives the window
* up entirely while those are open, which is why `open` names the phases it draws rather than
* every phase that is not off.
*/
function AddSheet({
open,
title,
onBack,
onClose,
children,
}: {
open: boolean;
title: string;
onBack?: () => void;
onClose: () => void;
children: ReactNode;
}) {
return (
<Sheet open={open} title={title} onBack={onBack} backLabel="the address" onClose={onClose}>
{children}
</Sheet>
);
}
/**
* What an IMAP account has where a Google account lists its permissions.
*
* Nothing was granted here and there is no consent page to send anybody back to, so a list of six
* capabilities with Grant buttons beside them would be six offers to open a Google page for an
* account that has no Google. What this kind of account is instead is two servers, a login and a
* password, and those are the facts somebody opens this card to check when the mail stops arriving.
*
* `imap_servers` hands back what the account was added with rather than what discovery suggested at
* the time, so what is on screen is what the next connection will actually use.
*
* No IMAP session in this app has yet run against a real server, so all three of the answers this
* can come back with are said out loud, and the two that are not a configuration are said
* differently. A call that threw and an account with no servers stored are not the same trouble:
* one is a card that cannot report, and the other is an account that cannot connect. Undefined is
* neither of them, it is the question still being asked.
*/
function Servers({ account }: { account: Account }) {
const [config, setConfig] = useState<MailConfig | null | undefined>(undefined);
const [refused, setRefused] = useState<string | null>(null);
useEffect(() => {
let live = true;
setConfig(undefined);
setRefused(null);
imapServers(account.id)
.then((found) => {
if (live) setConfig(found);
})
.catch((e) => {
if (live) setRefused(String(e));
});
return () => {
live = false;
};
}, [account.id]);
return (
<div className="acct-servers">
<div className="scopes-head">Servers</div>
{refused !== null ? (
<p className="scope-why">
The servers for this account could not be read back: {refused}. The account itself is
untouched, but this card cannot say what it connects to until that call answers.
</p>
) : config === undefined ? (
<p className="scope-why">Reading the servers this account was added with.</p>
) : config === null ? (
<p className="scope-why">
No servers are stored for this account, so nothing can reach its mailbox. Connecting the
same address again from Add account is what puts them back.
</p>
) : (
<>
<div className="set-row">
<span className="lab">Incoming</span>
<span className="val">{serverLine(config.imap)}</span>
</div>
<div className="set-row">
<span className="lab">Outgoing</span>
<span className="val">{serverLine(config.smtp)}</span>
</div>
<div className="set-row">
<span className="lab">Username</span>
<span className="val">{loginLine(config)}</span>
</div>
<div className="set-row" data-wrap="">
<span className="lab">Password</span>
<span className="val">
Sealed on this device beside the app's other keys. It goes to these two servers and to
nobody else, and it is replaced by connecting the account again.
</span>
</div>
</>
)}
</div>
);
}
/**
* Who logs in, said twice only when the two halves genuinely differ. A gateway wanting its own
* credentials is common enough that the connect sheet does not even insist on a username for it,
* and an account set up that way has two logins rather than one.
*/
function loginLine(config: MailConfig): string {
const incoming = config.imap.username;
const outgoing = config.smtp.username;
if (!outgoing || outgoing === incoming) return incoming;
return `${incoming} incoming, ${outgoing} outgoing`;
}
/**
* One button and one question. Removing revokes Margin's access at Google and forgets the account
* here; it used to be two buttons with two reaches, and read as a choice nobody could make. The
* only choice left is whether the mail and the decisions stay on this computer, and it is a box
* ticked to delete them, because the person pressed Remove.
*/
function RemoveSheet({
open,
accounts,
onClose,
}: {
open: boolean;
accounts: Account[];
onClose: () => void;
}) {
const [asking, setAsking] = useState<Account | null>(null);
const [deleteData, setDeleteData] = useState(true);
const [phase, setPhase] = useState<"idle" | "removing">("idle");
const busy = phase !== "idle";
const ask = (account: Account) => {
setDeleteData(true);
setAsking(account);
};
const run = async (account: Account) => {
setPhase("removing");
try {
await accountRemove(account.id, !deleteData);
await useAccounts.getState().refresh();
notify(removedNote(account, deleteData));
setAsking(null);
onClose();
} catch (e) {
// A revoke that could not reach Google still removes the account here, and says so in its
// own words. The list is the only thing that can tell that apart from a removal that failed.
await useAccounts.getState().refresh();
const gone = !useAccounts.getState().accounts.some((one) => one.id === account.id);
notify(gone ? String(e) : `Could not remove that account: ${e}`);
if (gone) {
setAsking(null);
onClose();
}
} finally {
setPhase("idle");
}
};
return (
<Sheet open={open} title="Remove an account" busy={busy} onClose={onClose}>
{asking ? (
<Confirm
title={`Remove ${asking.email}?`}
body={
<>
{asking.kind === "google" ? (
<p>
This hands Margin's grant back to Google for Margin Mail, Margin and Margin
Calendar, on every machine you have signed in on, because all three share one
client. Your mail is untouched on Gmail.
</p>
) : (
<p>
This forgets the account's password on this computer. Your mail is untouched on
the server.
</p>
)}
<label className="confirm-option">
<input
type="checkbox"
checked={deleteData}
disabled={busy}
onChange={(e) => setDeleteData(e.target.checked)}
/>
<span>
Also delete this account's mail and decisions from this computer
<span className="confirm-option-note">
Left unticked, they stay on disk and come back if you add the account again.
</span>
</span>
</label>
</>
}
confirmLabel="Remove"
busy={busy}
busyLabel="Removing"
onConfirm={() => void run(asking)}
onCancel={() => setAsking(null)}
/>
) : (
accounts.map((account) => (
<div className="remove-row" key={account.id}>
<Avatar
name={account.name}
address={account.email}
hue={accountHue(account.color)}
size="sm"
/>
<span className="remove-who">{account.email}</span>
<Button size="sm" variant="danger" onClick={() => ask(account)}>
Remove
</Button>
</div>
))
)}
</Sheet>
);
}
/** What the toast says once the account has gone, which depends on what there was to revoke. */
function removedNote(account: Account, deleted: boolean): string {
const gone =
account.kind === "google"
? `${account.email} was removed and Margin's access at Google revoked`
: `${account.email} was removed from this device`;
return deleted ? gone : `${gone}. Its mail and decisions stay on this computer`;
}
// -------------------------------------------------------------------------------------------
// Appearance
// -------------------------------------------------------------------------------------------
const THEMES = [
{ id: "light", label: "Light" },
{ id: "dark", label: "Dark" },
{ id: "system", label: "System" },
];
/** The reading size, in the steps a person can actually tell apart. */
const SIZES = [13, 14, 15, 16, 17, 18];
function AppearanceSection() {
const settings = useSettings((s) => s.settings);
const fonts = useSettings((s) => s.fonts);
const loadFonts = useSettings((s) => s.loadFonts);
const save = useSettings((s) => s.save);
const pane = useMail((s) => s.pane);
const togglePane = useMail((s) => s.togglePane);
useEffect(() => {
void loadFonts();
}, [loadFonts]);
if (!settings) return <h2 className="settings-title">Appearance</h2>;
const setTheme = (choice: string) => {
useTheme.getState().set(choice === "system" ? systemTheme() : (choice as "light" | "dark"));
if (choice === "system") forgetThemeChoice();
void save({ theme: choice as SettingsDto["theme"] });
};
const setFont = (slot: "ui" | "text", ref: FontRef) => {
applyFonts(slot === "ui" ? ref : settings.fontUi, slot === "text" ? ref : settings.fontText);
void save(slot === "ui" ? { fontUi: ref } : { fontText: ref });
};
return (
<>
<h2 className="settings-title">Appearance</h2>
<p className="settings-note">
How the app is set and how much of the window the mail gets. All of it is this machine's
rather than this account's.
</p>
<SettingRow label="Theme" note="System follows whatever the machine is doing at the time.">
<Segment options={THEMES} value={settings.theme} label="Theme" onChange={setTheme} />
</SettingRow>
<SettingRow label="Interface font" note="The chrome: rows, buttons, the palette.">
<FontPicker
label="Interface font"
value={settings.fontUi}
families={fonts}
onChange={(ref) => setFont("ui", ref)}
/>
</SettingRow>
<SettingRow label="Text font" note="Message bodies, subjects and every heading.">
<FontPicker
label="Text font"
value={settings.fontText}
families={fonts}
onChange={(ref) => setFont("text", ref)}
/>
</SettingRow>
<SettingRow label="Text size" note="Message bodies only. The chrome is already the size it wants to be.">
<Segment
options={SIZES.map((size) => ({ id: String(size), label: String(size) }))}
value={String(settings.textSize)}
label="Text size"
onChange={(id) => {
applyTextSize(Number(id));
void save({ textSize: Number(id) });
}}
/>
</SettingRow>
{/* The one row whose label belongs to the control rather than beside it: the switch is a
real label and htmlFor, and the primitive already lays the row out the same way. */}
<div className="set-field">
<Toggle
checked={pane}
label="Reading pane"
note="The same switch as ⌘\. With it off, a thread opens in place of the list."
onChange={(next) => {
if (next !== pane) togglePane();
void save({ readingPane: next });
}}
/>
</div>
</>
);
}
function SettingRow({
label,
note,
children,
}: {
label: string;
note?: string;
children: ReactNode;
}) {
return (
<div className="set-field">
<div className="set-field-text">
<span className="set-field-label">{label}</span>
{note ? <span className="set-field-note">{note}</span> : null}
</div>
<div className="set-field-control">{children}</div>
</div>
);
}
function FontPicker({
label,
value,
families,
onChange,
}: {
label: string;
value: FontRef;
families: string[];
onChange: (ref: FontRef) => void;
}) {
const current = encodeRef(value);
const known = useMemo(
() =>
BUNDLED_FONTS.some((font) => refsEqual({ kind: "bundled", id: font.id }, value)) ||
families.some((family) => refsEqual({ kind: "system", family }, value)),
[families, value],
);
return (
<select
className="settings-select"
aria-label={label}
value={current}
onChange={(e) => onChange(decodeRef(e.target.value))}
>
{/* A face chosen on another machine, or one uninstalled since, is still what this setting
says. Naming it keeps the picker from showing a face nobody chose. */}
{known ? null : <option value={current}>{fontLabel(value)}</option>}
<optgroup label="Bundled">
{BUNDLED_FONTS.map((font) => (
<option key={font.id} value={encodeRef({ kind: "bundled", id: font.id })}>
{font.label}
</option>
))}
</optgroup>
{families.length > 0 ? (
<optgroup label="On this machine">
{families.map((family) => (
<option key={family} value={encodeRef({ kind: "system", family })}>
{family}
</option>
))}
</optgroup>
) : null}
</select>
);
}
// -------------------------------------------------------------------------------------------
// What every section below shares
// -------------------------------------------------------------------------------------------
const DAY_MS = 86_400_000;
const dayMonthYear = new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "long",
year: "numeric",
});
const count = (n: number): string => n.toLocaleString();
/** Disk, in the units a settings screen talks about. `fileSize` stops at MB and a mirror does not. */
function space(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${Math.round(kb)} KB`;
const mb = kb / 1024;
if (mb < 1024) return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB`;
return `${(mb / 1024).toFixed(1)} GB`;
}
/** A time of day, which `SnoozeTimes` carries as minutes from midnight. */
const clock = (minutes: number): string =>
`${String(Math.floor(minutes / 60) % 24).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
const numbered = (values: number[], label: (n: number) => string): SegmentOption[] =>
values.map((value) => ({ id: String(value), label: label(value) }));
interface Asking {
/** The panel's head. The question itself is the confirmation's, one line further in. */
title: string;
question: string;
body: ReactNode;
confirmLabel: string;
/** What the button says while `run` is on its way: "Clearing". */
busyLabel?: string;
run: () => Promise<void>;
}
/**
* The one confirmation shape the sections share. Every destructive thing on this screen goes
* through it, so what a person is asked and how they are asked it are written once.
*/
function Ask({ asking, onClose }: { asking: Asking | null; onClose: () => void }) {
const [phase, setPhase] = useState<"idle" | "running">("idle");
if (!asking) return null;
const running = phase === "running";
return (
<Sheet open title={asking.title} size="mini" busy={running} onClose={onClose}>
<Confirm
title={asking.question}
body={asking.body}
confirmLabel={asking.confirmLabel}
busy={running}
busyLabel={asking.busyLabel}
onConfirm={() => {
setPhase("running");
void asking.run().finally(() => {
setPhase("idle");
onClose();
});
}}
onCancel={onClose}
/>
</Sheet>
);
}
/**
* A field that writes when it is left rather than on every keystroke, which is the shape the
* contact card's note uses and the only one that does not send a command per letter.
*/
function Draft({
className,
label,
value,
placeholder,
rows,
onCommit,
}: {
className: string;
label: string;
value: string;
placeholder?: string;
rows?: number;
onCommit: (value: string) => void;
}) {
const [draft, setDraft] = useState(value);
const [editing, setEditing] = useState(false);
// What another device wrote arrives while nobody is typing, and overwrites nothing once somebody
// is.
useEffect(() => {
if (!editing) setDraft(value);
}, [value, editing]);
const commit = () => {
setEditing(false);
if (draft !== value) onCommit(draft);
};
const type = (next: string) => {
setEditing(true);
setDraft(next);
};
if (rows) {
return (
<textarea
className={className}
rows={rows}
aria-label={label}
value={draft}
placeholder={placeholder}
onChange={(e) => type(e.target.value)}
onBlur={commit}
/>
);
}
return (
<input
{...NO_AUTOFILL}
className={className}
type="text"
aria-label={label}
value={draft}
placeholder={placeholder}
onChange={(e) => type(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
/>
);
}
function TextSetting({
label,
/** When the printed label is not enough to tell two of these apart, such as one per account. */
named,
note,
value,
placeholder,
rows,
onCommit,
}: {
label: string;
named?: string;
note?: string;
value: string;
placeholder?: string;
rows?: number;
onCommit: (value: string) => void;
}) {
return (
<div className="set-stack">
<span className="set-field-label">{label}</span>
{note ? <span className="set-field-note">{note}</span> : null}
<Draft
className={rows ? "settings-textarea" : "settings-input"}
label={named ?? label}
value={value}
placeholder={placeholder}
rows={rows}
onCommit={onCommit}
/>
</div>
);
}
/** A time of day, offered as the hours somebody actually chooses between. */
function TimePicker({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (minutes: number) => void;
}) {
const hours = [6, 7, 8, 9, 10, 11].map((hour) => hour * 60);
return (
<select
className="settings-select"
aria-label={label}
value={String(value)}
onChange={(e) => onChange(Number(e.target.value))}
>
{/* A time set on another device, or by hand in the file, is still what this setting says. */}
{hours.includes(value) ? null : <option value={String(value)}>{clock(value)}</option>}
{hours.map((minutes) => (
<option key={minutes} value={String(minutes)}>
{clock(minutes)}
</option>
))}
</select>
);
}
/** The thin bar the first sync draws, reused rather than drawn again, because it is the same wait. */
function Filling({ status }: { status: SyncStatus }) {
const done = status.total > 0 ? Math.min(status.hydrated, status.total) / status.total : 0;
return (
<div className="set-filling">
<div
className="welcome-bar"
role="progressbar"
aria-label="Filling in the older mail"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(done * 100)}
>
<span className="welcome-bar-fill" style={{ transform: `scaleX(${done})` }} />
</div>
<span className="set-field-note">
{status.message ?? "Filling in the older mail, newest first."}
</span>
</div>
);
}
/** The head of a card that is about one account: its colour, its address and nothing else. */
function AccountHead({ account }: { account: Account }) {
return (
<div className="set-card-head">
<Avatar
name={account.name}
address={account.email}
hue={accountHue(account.color)}
size="sm"
/>
<span className="set-card-who">{account.email}</span>
</div>
);
}
// -------------------------------------------------------------------------------------------
// Mail
// -------------------------------------------------------------------------------------------
const WINDOWS: SegmentOption[] = [
{ id: "30", label: "30 days" },
{ id: "90", label: "90 days" },
{ id: "180", label: "180 days" },
{ id: "365", label: "A year" },
{ id: "0", label: "Everything" },
];
/** Zero is the widest window rather than the narrowest, which is the whole of shrink against widen. */
const reach = (days: number): number => (days === 0 ? Number.POSITIVE_INFINITY : days);
const spanOf = (days: number): string =>
days === 0 ? "everything" : days === 365 ? "the last year" : `the last ${days} days`;
const CACHE_CAPS = numbered([256, 512, 1024, 2048, 5120], (mb) =>
mb < 1024 ? `${mb} MB` : `${mb / 1024} GB`,
);
/** A thread somebody decided something about, which is the kind eviction keeps whatever its age. */
const decided = (thread: ThreadSummary): boolean =>
Boolean(thread.pile) ||
thread.snoozedUntil !== null ||
Boolean(thread.note) ||
thread.ignored ||
thread.notify ||
thread.starred ||
thread.hasDraft;
const COUNT_PAGE = 500;
const COUNT_PAGES = 20;
/**
* How many threads a narrower window would take off this device.
*
* Counted from the list rather than asked of Rust, because there is no dry run behind eviction:
* `window_set` evicts and reports the number to nobody. So this is exact for what a person can
* see, which is Everything, and Everything is the whole device but the trash.
*/
async function evictionCount(
accountId: string,
cutoffMs: number,
): Promise<{ threads: number; capped: boolean }> {
let cursor: string | null = null;
let threads = 0;
for (let page = 0; page < COUNT_PAGES; page += 1) {
const got = await threadsList({
accountId,
place: "everything",
limit: COUNT_PAGE,
cursor,
});
for (const thread of got.threads) {
if (thread.dateMs < cutoffMs && !decided(thread)) threads += 1;
}
cursor = got.nextCursor;
if (!cursor) return { threads, capped: false };
}
return { threads, capped: true };
}
function Doomed({ accountId, days }: { accountId: string; days: number }) {
const [phase, setPhase] = useState<"counting" | "counted" | "error">("counting");
const [found, setFound] = useState({ threads: 0, capped: false });
useEffect(() => {
let alive = true;
void evictionCount(accountId, Date.now() - days * DAY_MS)
.then((got) => {
if (!alive) return;
setFound(got);
setPhase("counted");
})
.catch(() => {
if (alive) setPhase("error");
});
return () => {
alive = false;
};
}, [accountId, days]);
return (
<>
<p>
{phase === "counting"
? "Counting what would go."
: phase === "error"
? `Every thread older than ${days} days leaves this device. Counting them first did not work, so this is the shape of it rather than the number.`
: found.threads === 0
? `Nothing on this device is older than ${days} days, so nothing would be removed.`
: `${found.capped ? "At least " : ""}${count(found.threads)} ${
found.threads === 1 ? "thread" : "threads"
} older than ${days} days would be removed from this device.`}
</p>
<p>
Threads carrying a pile, a note, a snooze or any other decision are kept whatever their age,
and mail already in the trash is not in the count above. Nothing is removed from Gmail, and
widening the window again brings it back.
</p>
</>
);
}
function MailSection() {
const settings = useSettings((s) => s.settings);
const storage = useSettings((s) => s.storage);
const loadStorage = useSettings((s) => s.loadStorage);
const save = useSettings((s) => s.save);
const accounts = useAccounts((s) => s.accounts);
const statuses = useSync((s) => s.statuses);
const [asking, setAsking] = useState<Asking | null>(null);
useEffect(() => {
void loadStorage();
}, [loadStorage]);
if (!settings) return <h2 className="settings-title">Mail</h2>;
const rowFor = (accountId: string) =>
settings.accounts.find((row) => row.accountId === accountId) ?? null;
const write = (accountId: string, days: number) =>
void save({
accounts: settings.accounts.map((row) =>
row.accountId === accountId ? { ...row, windowDays: days } : row,
),
}).then(() => loadStorage());
const choose = (account: Account, days: number) => {
const was = rowFor(account.id)?.windowDays ?? 0;
if (days === was) return;
// Widening only ever adds, and Rust queues the backfill off the same write, so it goes through.
if (reach(days) >= reach(was)) {
write(account.id, days);
return;
}
setAsking({
title: "Storage window",
question: `Keep only ${spanOf(days)} of ${account.email} on this device?`,
body: <Doomed accountId={account.id} days={days} />,
confirmLabel: "Narrow the window",
run: async () => write(account.id, days),
});
};
const cached = storage.reduce((sum, used) => sum + used.attachmentsBytes, 0);
return (
<>
<h2 className="settings-title">Mail</h2>
<p className="settings-note">
How much of each mailbox lives on this machine. All of it is this machine's: the window that
suits a laptop with a small disk is not the window that suits a desktop.
</p>
{accounts.map((account) => {
const row = rowFor(account.id);
const used = storage.find((one) => one.accountId === account.id) ?? null;
const status = statuses.find((one) => one.accountId === account.id) ?? null;
const filling =
status && (status.phase === "backfilling" || status.phase === "hydrating") ? status : null;
return (
<div className="set-card" key={account.id}>
<AccountHead account={account} />
<div className="set-field">
<div className="set-field-text">
<span className="set-field-label">Storage window</span>
<span className="set-field-note">
{used
? `${count(used.threads)} threads and ${count(used.messages)} messages${
used.oldestMs ? `, back to ${dayMonthYear.format(used.oldestMs)}` : ""
}.`
: "Measuring what is on this device."}
</span>
</div>
<div className="set-field-control">
<Segment
options={WINDOWS}
value={String(row?.windowDays ?? account.windowDays)}
label={`Storage window for ${account.email}`}
onChange={(id) => choose(account, Number(id))}
/>
</div>
</div>
{filling ? <Filling status={filling} /> : null}
</div>
);
})}
<p className="settings-quiet">
Threads carrying a pile, a note, a snooze or any other decision are kept whatever their age,
so narrowing the window never takes away something you decided about.
</p>
<SettingRow
label="Attachment cache"
note={`Attachments are fetched when you open them and kept until the cap is reached. ${space(cached)} in use.`}
>
<Segment
options={CACHE_CAPS}
value={String(settings.attachmentCacheMb)}
label="Attachment cache"
onChange={(id) => void save({ attachmentCacheMb: Number(id) })}
/>
</SettingRow>
<div className="set-field">
<Toggle
checked={settings.prefetchBodies}
label="Fetch message bodies while idle"
note="Fills in the bodies inside the window when nothing else is happening, so a thread opens without waiting. Disk, not what is in a list."
onChange={(next) => void save({ prefetchBodies: next })}
/>
</div>
<Ask asking={asking} onClose={() => setAsking(null)} />
</>
);
}
// -------------------------------------------------------------------------------------------
// Privacy
// -------------------------------------------------------------------------------------------
const IMAGES: SegmentOption[] = [
{ id: "never", label: "Never" },
{ id: "ask", label: "Ask per message" },
{ id: "always", label: "Always" },
];
function PrivacySection() {
const settings = useSettings((s) => s.settings);
const save = useSettings((s) => s.save);
const [allowed, setAllowed] = useState<ContactCard[]>([]);
const [phase, setPhase] = useState<"loading" | "idle" | "error">("loading");
const [otpStatus, setOtpStatus] = useState<OtpAutofillStatus | null>(null);
const [otpBusy, setOtpBusy] = useState(false);
useEffect(() => {
let alive = true;
void otpAutofillStatus().then((status) => {
if (alive) setOtpStatus(status);
}).catch((error) => {
if (!alive) return;
setOtpStatus({ available: false, enabled: false });
notify(`Could not check Email OTP AutoFill: ${error}`);
});
return () => { alive = false; };
}, []);
useEffect(() => {
let alive = true;
void contactsList(null, "")
.then((cards) => {
if (!alive) return;
setAllowed(cards.filter((card) => card.allowRemoteImages));
setPhase("idle");
})
.catch(() => {
if (alive) setPhase("error");
});
return () => {
alive = false;
};
}, []);
if (!settings) return <h2 className="settings-title">Privacy</h2>;
const changeOtpAutofill = async (enabled: boolean) => {
setOtpBusy(true);
try {
if (enabled && !otpStatus?.enabled) {
const activated = await otpAutofillEnable();
if (!activated) {
notify("Email OTP AutoFill was not enabled in macOS.");
return;
}
setOtpStatus({ available: true, enabled: true });
}
await save({ otpAutofillEnabled: enabled });
} catch (error) {
notify(`Could not enable Email OTP AutoFill: ${error}`);
} finally {
setOtpBusy(false);
}
};
const forget = (card: ContactCard) => {
setAllowed((were) => were.filter((one) => one !== card));
void contactUpdate(card.accountId, card.person.address, { allowRemoteImages: false }).catch(
(e) => {
setAllowed((were) => [...were, card]);
notify(`Could not change that: ${e}`);
},
);
};
return (
<>
<h2 className="settings-title">Privacy</h2>
<p className="settings-note">
What a message is allowed to do when you open it. Nothing here leaves the machine to be
decided somewhere else.
</p>
<div className="set-field">
<Toggle checked={settings.otpAutofillEnabled && (otpStatus?.enabled ?? false)}
label="Email OTP AutoFill"
disabled={!otpStatus?.available || otpBusy}
note="Suggest recent email verification codes in compatible website and app fields through macOS AutoFill. Codes expire after three minutes. Detection stays on this device."
onChange={(enabled) => void changeOtpAutofill(enabled)} />
<p className="settings-note">
{otpStatus?.available
? "Enable Margin Mail under macOS System Settings → General → AutoFill & Passwords when prompted. Keep Margin Mail running and unlocked to receive codes."
: "Requires macOS 15 or later and the installed Margin Mail AutoFill extension."}
</p>
</div>
<div className="set-field">
<Toggle checked={settings.appLockEnabled} label="Require authentication when opening"
disabled={!isMacDesktop}
note="On macOS, unlock with Touch ID or your Mac login password when starting the app."
onChange={(appLockEnabled) => void save({ appLockEnabled })} />
</div>
<SettingRow
label="Remote images"
note="Loading a remote image tells the server that hosts it that you opened the message, from your IP address, at that moment. Nothing else in the app reveals that."
>
<Segment
options={IMAGES}
value={settings.remoteImages}
label="Remote images"
onChange={(id) => void save({ remoteImages: id as SettingsDto["remoteImages"] })}
/>
</SettingRow>
<div className="set-field">
<Toggle
checked={settings.linkCleaning}
label="Clean the tracking out of links"
note="Takes the campaign and click-identifier parameters off a link before it opens, so the page you land on is not told which mail you came from."
onChange={(next) => void save({ linkCleaning: next })}
/>
</div>
<div className="set-stack">
<span className="set-field-label">Senders allowed to load images</span>
<span className="set-field-note">
Added from a contact card, and taken back here. Everyone else is held to the setting above.
</span>
{phase === "loading" ? (
<p className="settings-quiet">Reading your contacts.</p>
) : phase === "error" ? (
<p className="settings-quiet">Could not read your contacts.</p>
) : allowed.length === 0 ? (
<p className="settings-quiet">Nobody yet.</p>
) : (
<ul className="set-list">
{allowed.map((card) => (
<li key={`${card.accountId}|${card.person.address}`}>
<Avatar
name={displayName(card.person)}
address={card.person.address}
size="sm"
/>
<span className="set-list-who">
<span className="set-list-name">{displayName(card.person)}</span>
<span className="set-list-note">{card.person.address}</span>
</span>
<Button
size="sm"
label={`Stop loading images from ${card.person.address}`}
onClick={() => forget(card)}
>
Remove
</Button>
</li>
))}
</ul>
)}
</div>
</>
);
}
// -------------------------------------------------------------------------------------------
// Screener
// -------------------------------------------------------------------------------------------
function ScreenerSection() {
const settings = useSettings((s) => s.settings);
const save = useSettings((s) => s.save);
if (!settings) return <h2 className="settings-title">Screener</h2>;
return (
<>
<h2 className="settings-title">Screener</h2>
<p className="settings-note">
The gate in front of the Inbox. A sender is decided once and every message from them after
that follows the decision.
</p>
<div className="set-field">
<Toggle
checked={settings.screenerEnabled}
label="Screen first contact"
note="With this off, a sender nobody has decided about is routed by the suggestion instead and nothing waits."
onChange={(next) => void save({ screenerEnabled: next })}
/>
</div>
<div className="set-field">
<Toggle
checked={settings.holdReplies}
label="Hold replies to threads you are in"
note="Off, because somebody answering a message you sent is not first contact. On is for wanting absolutely everything screened."
onChange={(next) => void save({ holdReplies: next })}
/>
</div>
<div className="set-field">
<Toggle
checked={settings.suggestions}
label="Suggest where a sender belongs"
note="With this off a Screener card shows the three destinations and no recommendation."
onChange={(next) => void save({ suggestions: next })}
/>
</div>
</>
);
}
// -------------------------------------------------------------------------------------------
// Piles and snooze
// -------------------------------------------------------------------------------------------
const LATER_TODAY = numbered([1, 2, 3, 4], (hours) => (hours === 1 ? "1 hour" : `${hours} hours`));
const FEED_AGES: SegmentOption[] = [
{ id: "0", label: "Off" },
{ id: "7", label: "A week" },
{ id: "14", label: "Two weeks" },
{ id: "30", label: "A month" },
{ id: "90", label: "Three months" },
];
/** The verbs a swipe can be, which is what the contract's two strings are allowed to hold. */
const SWIPES: { id: string; label: string }[] = [
{ id: "reply-later", label: "Reply later" },
{ id: "set-aside", label: "Set aside" },
{ id: "archive", label: "Archive" },
{ id: "trash", label: "Trash" },
{ id: "none", label: "Nothing" },
];
function SwipePicker({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (id: string) => void;
}) {
return (
<select
className="settings-select"
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
>
{SWIPES.some((swipe) => swipe.id === value) ? null : <option value={value}>{value}</option>}
{SWIPES.map((swipe) => (
<option key={swipe.id} value={swipe.id}>
{swipe.label}
</option>
))}
</select>
);
}
function PilesSection() {
const settings = useSettings((s) => s.settings);
const save = useSettings((s) => s.save);
if (!settings) return <h2 className="settings-title">Piles and snooze</h2>;
const times = settings.snoozeTimes;
const setTime = (patch: Partial<typeof times>) =>
void save({ snoozeTimes: { ...times, ...patch } });
return (
<>
<h2 className="settings-title">Piles and snooze</h2>
<p className="settings-note">
What the four snooze choices mean, so they can be set once and never thought about again.
</p>
<SettingRow label="Later today" note="How far ahead Later today puts a thread.">
<Segment
options={LATER_TODAY}
value={String(times.laterTodayHours)}
label="Later today"
onChange={(id) => setTime({ laterTodayHours: Number(id) })}
/>
</SettingRow>
<SettingRow label="Tomorrow morning" note="The hour a thread comes back tomorrow.">
<TimePicker
label="Tomorrow morning"
value={times.tomorrowAt}
onChange={(minutes) => setTime({ tomorrowAt: minutes })}
/>
</SettingRow>
<SettingRow label="The weekend" note="Saturday, at this time.">
<TimePicker
label="The weekend"
value={times.weekendAt}
onChange={(minutes) => setTime({ weekendAt: minutes })}
/>
</SettingRow>
<SettingRow label="Next week" note="Monday, at this time.">
<TimePicker
label="Next week"
value={times.nextWeekAt}
onChange={(minutes) => setTime({ nextWeekAt: minutes })}
/>
</SettingRow>
<SettingRow label="Swipe right" note="What a swipe to the right does to a row on a phone.">
<SwipePicker
label="Swipe right"
value={settings.swipeRight}
onChange={(id) => void save({ swipeRight: id })}
/>
</SettingRow>
<SettingRow label="Swipe left" note="And a swipe to the left.">
<SwipePicker
label="Swipe left"
value={settings.swipeLeft}
onChange={(id) => void save({ swipeLeft: id })}
/>
</SettingRow>
<SettingRow
label="Trash Feed mail after"
note="Off by default. A contact card sets it for one sender; this is every Feed sender at once."
>
<Segment
options={FEED_AGES}
value={String(settings.feedAutoTrashDays)}
label="Trash Feed mail after"
onChange={(id) => void save({ feedAutoTrashDays: Number(id) })}
/>
</SettingRow>
</>
);
}
// -------------------------------------------------------------------------------------------
// Writing
// -------------------------------------------------------------------------------------------
const UNDO_DELAYS = numbered([5, 10, 20, 30], (secs) => `${secs}s`);
const REPLY_DEFAULT: SegmentOption[] = [
{ id: "reply", label: "Reply" },
{ id: "reply-all", label: "Reply all" },
];
function WritingSection() {
const settings = useSettings((s) => s.settings);
const save = useSettings((s) => s.save);
const accounts = useAccounts((s) => s.accounts);
if (!settings) return <h2 className="settings-title">Writing</h2>;
const setSignature = (accountId: string, signature: string) =>
void save({
accounts: settings.accounts.map((row) =>
row.accountId === accountId ? { ...row, signature } : row,
),
});
return (
<>
<h2 className="settings-title">Writing</h2>
<p className="settings-note">
What happens when you send, and what goes out under your name.
</p>
<div className="set-field">
<Toggle checked={settings.spellingEnabled} label="Check spelling"
note="Use local spelling suggestions while writing."
onChange={(spellingEnabled) => void save({ spellingEnabled })} />
</div>
<div className="set-field">
<Toggle checked={settings.grammarEnabled} label="Check grammar"
disabled={!isMacDesktop}
note="Use macOS grammar suggestions while writing."
onChange={(grammarEnabled) => void save({ grammarEnabled })} />
</div>
<div className="set-field">
<Toggle checked={settings.writingToolsEnabled} label="Apple Writing Tools"
disabled={!isMacDesktop}
note="Show Proofread and Rewrite actions. Requires Apple Intelligence enabled on a supported Mac."
onChange={(writingToolsEnabled) => void save({ writingToolsEnabled })} />
</div>
<SettingRow
label="Undo delay"
note="How long a send waits with its toast up before it actually goes. z takes it back."
>
<Segment
options={UNDO_DELAYS}
value={String(settings.undoDelaySecs)}
label="Undo delay"
onChange={(id) => void save({ undoDelaySecs: Number(id) })}
/>
</SettingRow>
<SettingRow label="What r does" note="Reply all is always on ⇧R whichever way this is set.">
<Segment
options={REPLY_DEFAULT}
value={settings.replyAllDefault ? "reply-all" : "reply"}
label="What r does"
onChange={(id) => void save({ replyAllDefault: id === "reply-all" })}
/>
</SettingRow>
<TextSetting
label="Instant intro"
note="⌘⇧I in a reply moves the introducer to Bcc and puts this in. Pressing it again takes both back."
value={settings.instantIntro}
rows={3}
onCommit={(instantIntro) => void save({ instantIntro })}
/>
{accounts.map((account) => {
const row = settings.accounts.find((one) => one.accountId === account.id);
return (
<div className="set-card" key={account.id}>
<AccountHead account={account} />
<TextSetting
label="Signature"
named={`Signature for ${account.email}`}
value={row?.signature ?? ""}
placeholder="Nothing after your messages"
rows={3}
onCommit={(signature) => setSignature(account.id, signature)}
/>
</div>
);
})}
<p className="settings-quiet">
The intro text and the signatures are per account rather than per machine, so they follow you
to another device rather than being typed again.
</p>
</>
);
}
// -------------------------------------------------------------------------------------------
// Notifications
// -------------------------------------------------------------------------------------------
function NotificationsSection() {
const settings = useSettings((s) => s.settings);
const save = useSettings((s) => s.save);
// What the system says, null until it has answered. Read again whenever the window comes back,
// because a refusal is undone in System Settings and not here.
const [permission, setPermission] = useState<NotifyPermission | null>(null);
const [asking, setAsking] = useState(false);
const [testing, setTesting] = useState(false);
useEffect(() => {
let alive = true;
const check = () => {
notifyPermission()
.then((state) => {
if (alive) setPermission(state);
})
.catch(() => {});
};
check();
window.addEventListener("focus", check);
return () => {
alive = false;
window.removeEventListener("focus", check);
};
}, []);
if (!settings) return <h2 className="settings-title">Notifications</h2>;
// One switch: the system's permission and the app's own preference together, because nobody
// cares which of the two is saying no. Off is off whichever it was.
const denied = permission === "denied";
const on = permission === "granted" && settings.notifications;
const places = settings.notifyPlaces;
const allow = async (next: boolean) => {
if (!next) {
await save({ notifications: false });
return;
}
setAsking(true);
try {
const state = await askForNotifications();
setPermission(state);
if (state !== "granted") return;
// A yes with no place chosen would turn on nothing, so the Inbox comes with it the first
// time. The Feed and the Paper Trail stay a choice.
await save({
notifications: true,
...(places.length === 0 ? { notifyPlaces: ["inbox" as Place] } : {}),
});
} catch (e) {
notify(`Could not turn on notifications: ${e}`);
} finally {
setAsking(false);
}
};
const setPlace = (place: Place, next: boolean) =>
void save({ notifyPlaces: withPlace(places, place, next) });
const test = async () => {
setTesting(true);
try {
await notifyTest();
} catch (e) {
notify(`Could not send a test notification: ${e}`);
} finally {
setTesting(false);
}
};
const openSystem = () =>
void openNotificationSettings().catch((e) => notify(`Could not open System Settings: ${e}`));
return (
<>
<h2 className="settings-title">Notifications</h2>
<p className="settings-note">
New mail can arrive as a push notification: a banner from the system with the sender and
the subject. Nothing notifies you until you say so.
</p>
<div className="set-field">
<Toggle
checked={on}
disabled={asking || denied || permission === null}
label={asking ? "Asking the system" : "Allow notifications"}
onChange={(next) => void allow(next)}
/>
</div>
{denied ? (
<div className="set-stack" data-permission="denied">
<span className="set-field-note">
Notifications are turned off for Margin Mail in System Settings. Turn them on there and
this switch comes back.
</span>
<div className="set-actions">
<Button onClick={openSystem}>Open System Settings</Button>
</div>
</div>
) : null}
{on ? (
<>
<div className="set-stack">
<span className="set-field-label">Which mail</span>
<span className="set-field-note">
⇧N turns notifications on for one thread and a contact card turns them on for one
person. These are what everything else falls back to.
</span>
</div>
{NOTIFY_PLACES.map((place) => (
<div className="set-field" key={place.id}>
<Toggle
checked={places.includes(place.id)}
label={`Notify about the ${place.label}`}
note={place.note}
onChange={(next) => setPlace(place.id, next)}
/>
</div>
))}
<div className="set-stack">
<span className="set-field-label">Try it</span>
<span className="set-field-note">Posts one sample notification.</span>
<div className="set-actions">
<Button disabled={testing} onClick={() => void test()}>
{testing ? "Sending" : "Send a test notification"}
</Button>
</div>
</div>
</>
) : null}
<div className="set-field">
<Toggle
checked={settings.badge}
label="Badge the dock icon"
note="Not a notification, so it is on by default and needs no permission. Counts unseen Inbox threads across every account, plus anything a snooze has brought back. Not an unread count: what is in the Screener, the Feed or the Paper Trail is not waiting for you."
onChange={(next) => void save({ badge: next })}
/>
</div>
<p className="settings-quiet">
These are this machine's, so a phone can be quiet while a desktop is not.
</p>
</>
);
}
// -------------------------------------------------------------------------------------------
// Keyboard
// -------------------------------------------------------------------------------------------
function KeyboardSection() {
const [path, setPath] = useState<string | null>(null);
const [phase, setPhase] = useState<"loading" | "idle" | "error">("loading");
const [asking, setAsking] = useState<Asking | null>(null);
useEffect(() => {
let alive = true;
void keymapPath()
.then((found) => {
if (!alive) return;
setPath(found);
setPhase("idle");
})
.catch(() => {
if (alive) setPhase("error");
});
return () => {
alive = false;
};
}, []);
const open = () => {
if (!path) return;
// Opening it is the point; showing it where it lives is what a machine that will not hand a
// file to an editor can still do.
openPath(path).catch(() => revealItemInDir(path).catch(() => notify("Could not open that file")));
};
return (
<>
<h2 className="settings-title">Keyboard</h2>
<p className="settings-note">
The keymap is a file rather than a table of pickers, so it can be read, edited, copied to
another machine and kept in version control.
</p>
<div className="set-stack">
<span className="set-field-label">Where it is</span>
<span className="settings-path">
{phase === "loading" ? "Looking." : phase === "error" ? "Could not be read." : path}
</span>
<div className="set-actions">
<Button disabled={!path} onClick={open}>
Open the file
</Button>
<Button
variant="ghost"
onClick={() =>
setAsking({
title: "Keyboard",
question: "Put the default keys back?",
body: (
<p>
Every change in the keymap file goes, and the keys go back to what
docs/keyboard.md lists. Nothing else on this machine is touched.
</p>
),
confirmLabel: "Reset the keymap",
run: async () => {
try {
await keymapReset();
notify("The keymap is back to the defaults");
} catch (e) {
notify(`Could not reset the keymap: ${e}`);
}
},
})
}
>
Put the defaults back
</Button>
</div>
</div>
<p className="settings-quiet">
A file that will not parse leaves the defaults in force until it is fixed.
</p>
<Ask asking={asking} onClose={() => setAsking(null)} />
</>
);
}
// -------------------------------------------------------------------------------------------
// Backup
// -------------------------------------------------------------------------------------------
const STORES: SegmentOption[] = [
{ id: "none", label: "Off" },
{ id: "drive", label: "Google Drive" },
{ id: "r2", label: "Cloudflare R2" },
];
const STORE_NAMES: Record<string, string> = {
none: "nowhere",
drive: "Google Drive",
r2: "your R2 bucket",
};
/** The four S3 fields, and which of them the settings file is allowed to have kept. */
const R2_FIELDS: {
key: string;
label: string;
placeholder: string;
secret?: boolean;
was: (backup: BackupSettings) => string | null;
}[] = [
{
key: "endpoint",
label: "Endpoint",
placeholder: "https://<account>.r2.cloudflarestorage.com",
was: (backup) => backup.r2Endpoint,
},
{ key: "bucket", label: "Bucket", placeholder: "margin-backup", was: (backup) => backup.r2Bucket },
{ key: "accessKey", label: "Access key id", placeholder: "", secret: true, was: () => null },
{ key: "secret", label: "Secret access key", placeholder: "", secret: true, was: () => null },
];
function BackupSection() {
const settings = useSettings((s) => s.settings);
const applyBackup = useSettings((s) => s.applyBackup);
const [choosing, setChoosing] = useState<string | null>(null);
const [r2, setR2] = useState<Record<string, string>>({});
const [phrase, setPhrase] = useState<string | null>(null);
const [restoring, setRestoring] = useState("");
// Three different waits on the same store, and each has its own button to say so on. One shared
// word for all of them was how Restore went grey while a backup ran.
const [phase, setPhase] = useState<"idle" | "configuring" | "backing-up" | "restoring">("idle");
if (!settings) return <h2 className="settings-title">Backup</h2>;
const backup = settings.backup;
const store = choosing ?? backup.store;
/**
* The phrase is shown once, at setup, so it is asked for as part of turning backup on rather
* than sitting behind a button somebody has to know to press.
*/
const configure = async (which: string, config: Record<string, string>) => {
// The segment moves the moment it is pressed. Deriving the key can take a second, and a switch
// that sits on the old value for that long reads as one that did not take the press.
setChoosing(which);
setPhase("configuring");
try {
const next = await backupConfigure(which, config);
applyBackup(next);
setChoosing(null);
if (which !== "none" && !next.hasPhrase) {
const words = await backupPhrase();
setPhrase(words);
applyBackup({ ...next, hasPhrase: true });
}
} catch (e) {
// The R2 fields stay open so what was typed can be corrected. Any other pick falls back to
// what the settings file still says.
if (which !== "r2") setChoosing(null);
notify(`Could not set that up: ${e}`);
} finally {
setPhase("idle");
}
};
const run = async (
doing: "backing-up" | "restoring",
what: string,
task: () => Promise<void>,
) => {
setPhase(doing);
try {
await task();
} catch (e) {
notify(`Could not ${what}: ${e}`);
} finally {
setPhase("idle");
}
};
return (
<div className="set-section" data-phase={phase}>
<h2 className="settings-title">Backup</h2>
<p className="settings-note">
Every decision the app holds, journalled and encrypted on this machine before it leaves it.
The store holds ciphertext and file names and nothing else.
</p>
<SettingRow label="Where it goes" note="Drive uses the account you are already signed in to.">
<Segment
options={STORES}
value={store}
label="Backup store"
disabled={phase === "configuring"}
onChange={(id) => {
// R2 cannot be turned on by picking it: it needs four fields first, so picking it
// opens them and the button below is what commits.
if (id === "r2") setChoosing("r2");
else void configure(id, {});
}}
/>
</SettingRow>
{store === "r2" ? (
<div className="set-card">
{/* Held here and sent once, rather than a command per field: three quarters of a set of
credentials is not a bucket anybody can be configured against. */}
{R2_FIELDS.map((field) => (
<div className="set-stack" key={field.key}>
<span className="set-field-label">{field.label}</span>
<input
{...NO_AUTOFILL}
className="settings-input"
type={field.secret ? "password" : "text"}
aria-label={field.label}
placeholder={field.placeholder}
value={r2[field.key] ?? field.was(backup) ?? ""}
onChange={(e) => setR2((were) => ({ ...were, [field.key]: e.target.value }))}
/>
</div>
))}
<div className="set-actions">
<Button
variant="primary"
disabled={phase === "configuring"}
onClick={() =>
void configure(
"r2",
Object.fromEntries(
R2_FIELDS.map((field) => [field.key, r2[field.key] ?? field.was(backup) ?? ""]),
),
)
}
>
{phase === "configuring" ? "Setting up" : "Use this bucket"}
</Button>
</div>
<p className="settings-quiet">
The keys are encrypted on this device and never written beside the backup.
</p>
</div>
) : null}
<div className="set-rows">
<div className="set-row">
<span className="lab">Status</span>
<span className="val">
{backup.configured
? `Backing up to ${STORE_NAMES[backup.store] ?? backup.store}.`
: "Not set up, so nothing leaves this machine."}
</span>
</div>
<div className="set-row">
<span className="lab">Last backup</span>
<span className="val">
{backup.lastBackupMs ? messageTime(backup.lastBackupMs) : "Never"}
</span>
</div>
</div>
{backup.configured ? (
<div className="set-actions">
<Button
disabled={phase === "backing-up"}
onClick={() =>
void run("backing-up", "back up now", async () => {
applyBackup(await backupNow());
notify("Backed up");
})
}
>
{phase === "backing-up" ? "Backing up" : "Back up now"}
</Button>
</div>
) : null}
{phrase ? (
<div className="set-card set-phrase">
<span className="set-field-label">Your recovery phrase</span>
<p className="phrase">{phrase}</p>
<p className="set-field-note">
Write these twelve words down now. They are the only way to attach a second device or to
restore after a lost one, and this is the only time they will be on a screen: writing
them down now is the whole of the arrangement.
</p>
</div>
) : backup.hasPhrase ? (
<div className="set-stack">
<span className="set-field-label">Your recovery phrase</span>
<span className="set-field-note">
It was shown once when backup was turned on and cannot be shown again. That is the
mechanism rather than a rule: the phrase was generated, the key it derives was sealed,
and the phrase itself was dropped, so there is nothing left on this device that could
print it a second time. A device that could would be a device that had kept it, and then
the phrase would protect nothing the disk did not already give away. Backup carries on
working without it.
</span>
</div>
) : null}
<div className="set-stack">
<span className="set-field-label">Restore from a phrase</span>
<span className="set-field-note">
Attaches this device to a backup that already exists, which is also how a lost machine is
replaced. Choose where the backup is kept first.
</span>
<input
{...NO_AUTOFILL}
className="settings-input"
type="text"
aria-label="Recovery phrase"
placeholder="Twelve words, separated by spaces"
value={restoring}
onChange={(e) => setRestoring(e.target.value)}
/>
<div className="set-actions">
<Button
disabled={restoring.trim().length === 0 || phase === "restoring"}
onClick={() =>
void run("restoring", "restore from that phrase", async () => {
await backupRestore(restoring.trim());
setRestoring("");
notify("Restored from your backup");
})
}
>
{phase === "restoring" ? "Restoring" : "Restore"}
</Button>
</div>
</div>
</div>
);
}
// -------------------------------------------------------------------------------------------
// Data
// -------------------------------------------------------------------------------------------
function DataSection() {
const storage = useSettings((s) => s.storage);
const loadStorage = useSettings((s) => s.loadStorage);
const accounts = useAccounts((s) => s.accounts);
const [importing, setImporting] = useState("");
const [asking, setAsking] = useState<Asking | null>(null);
// Every one of these holds the database lock for as long as it takes to walk the whole mirror,
// so a second press before the first answers is a second file, not a second chance.
const [phase, setPhase] = useState<"idle" | "exporting" | "importing" | "error">("idle");
/** Which export is being written: an account id for an mbox, "state" for the app state. */
const [writing, setWriting] = useState<string | null>(null);
const busy = phase === "exporting" || phase === "importing";
useEffect(() => {
void loadStorage();
}, [loadStorage]);
const wrote = (path: string) => {
notify(`Written to ${path}`, {
label: "Show it",
run: () => void revealItemInDir(path).catch(() => {}),
});
};
const write = async (what: string, task: () => Promise<string>, failed: string) => {
setPhase("exporting");
setWriting(what);
try {
wrote(await task());
setPhase("idle");
} catch (e) {
setPhase("error");
notify(`${failed}: ${e}`);
} finally {
setWriting(null);
}
};
const bring = async () => {
setPhase("importing");
try {
await importState(importing.trim());
setImporting("");
setPhase("idle");
notify("Your app state was imported");
} catch (e) {
setPhase("error");
notify(`Could not import that file: ${e}`);
}
};
const totals = storage.reduce(
(sum, used) => ({
mirror: sum.mirror + used.mirrorBytes + used.bodiesBytes,
attachments: sum.attachments + used.attachmentsBytes,
state: sum.state + used.stateBytes,
}),
{ mirror: 0, attachments: 0, state: 0 },
);
return (
<div className="set-section" data-phase={phase}>
<h2 className="settings-title">Data</h2>
<p className="settings-note">
Everything here is a file you end up holding. Nothing is uploaded and nothing asks anybody
else first.
</p>
<div className="set-stack">
<span className="set-field-label">Export mail as mbox</span>
<span className="set-field-note">
The window that is on this device, in the format every other mail client reads.
</span>
<div className="set-actions">
{accounts.map((account) => (
<Button
key={account.id}
label={`Export ${account.email} as mbox`}
disabled={busy}
onClick={() =>
void write(
account.id,
() => exportMbox(account.id),
"Could not export that mailbox",
)
}
>
{writing === account.id ? "Writing" : account.email}
</Button>
))}
</div>
</div>
<div className="set-stack">
<span className="set-field-label">App state</span>
<span className="set-field-note">
Every decision the app holds, as JSON: the sender rules, the piles, the snoozes, the notes,
the clips and the renames. It is the portable form of all of it.
</span>
<div className="set-actions">
<Button
disabled={busy}
onClick={() => void write("state", exportState, "Could not export your app state")}
>
{writing === "state" ? "Writing" : "Export"}
</Button>
</div>
<input
{...NO_AUTOFILL}
className="settings-input"
type="text"
aria-label="App state file to import"
placeholder="The path of a file an export wrote"
value={importing}
onChange={(e) => setImporting(e.target.value)}
/>
<div className="set-actions">
<Button disabled={importing.trim().length === 0 || busy} onClick={() => void bring()}>
{phase === "importing" ? "Importing" : "Import"}
</Button>
</div>
</div>
<div className="set-stack">
<span className="set-field-label">Storage used</span>
<div className="set-rows">
<div className="set-row">
<span className="lab">Mirror</span>
<span className="val">{space(totals.mirror)}</span>
</div>
<div className="set-row">
<span className="lab">Attachments</span>
<span className="val">{space(totals.attachments)}</span>
</div>
<div className="set-row">
<span className="lab">Decisions</span>
<span className="val">{space(totals.state)}</span>
</div>
</div>
</div>
<div className="set-stack">
<span className="set-field-label">Clear the mirror</span>
<span className="set-field-note">
Throws away the local copy of the mail and syncs the window again from scratch. It is the
answer to a database that has gone wrong.
</span>
<div className="set-actions">
{accounts.map((account) => (
<Button
key={account.id}
variant="danger"
label={`Clear the mirror for ${account.email}`}
onClick={() =>
setAsking({
title: "Clear the mirror",
question: `Clear the local copy of ${account.email}?`,
body: (
<>
<p>
This deletes the mail on this device and syncs {spanOf(account.windowDays)}{" "}
again from Gmail. Nothing is removed from Gmail.
</p>
<p>
Every decision you have made is kept: the sender rules, the piles, the
snoozes, the notes, the clips, the renames and the merges are in the state
database, and this does not touch it.
</p>
</>
),
confirmLabel: "Clear it",
busyLabel: "Clearing",
run: async () => {
try {
await mirrorClear(account.id);
await loadStorage();
// The pass starts now rather than at the next tick, so the toast's "being
// fetched again" is true as it is read.
void useSync.getState().run(account.id);
notify(`The local copy of ${account.email} is being fetched again`);
} catch (e) {
notify(`Could not clear that mirror: ${e}`);
}
},
})
}
>
{account.email}
</Button>
))}
</div>
</div>
<Ask asking={asking} onClose={() => setAsking(null)} />
</div>
);
}
// -------------------------------------------------------------------------------------------
// About
// -------------------------------------------------------------------------------------------
function AboutSection() {
const [version, setVersion] = useState(builtVersion);
const [packager, setPackager] = useState<string | null>(null);
const [phase, setPhase] = useState<"idle" | "checking" | "current" | "found" | "installing">(
"idle",
);
const [offered, setOffered] = useState<Update | null>(null);
useEffect(() => {
// The binary's version rather than the bundle's, because this is the number a bug report
// carries and the two only agree until somebody ships a hotfix.
if (isTauri) void getVersion().then(setVersion).catch(() => {});
void packagedBy().then(setPackager).catch(() => {});
}, []);
const look = async () => {
setPhase("checking");
try {
const update = await check();
setOffered(update);
setPhase(update ? "found" : "current");
} catch (e) {
setPhase("idle");
notify(`Could not check for updates: ${e}`);
}
};
const install = async (update: Update) => {
setPhase("installing");
try {
await update.downloadAndInstall();
await relaunch();
} catch (e) {
setPhase("found");
notify(`Could not install that update: ${e}`);
}
};
return (
<>
<h2 className="settings-title">About</h2>
<div className="set-rows">
<div className="set-row">
<span className="lab">Version</span>
<span className="val settings-version">Margin Mail {version}</span>
</div>
<div className="set-row">
<span className="lab">Licence</span>
<span className="val">FSL-1.1-MIT</span>
</div>
<div className="set-row">
<span className="lab">Packaged by</span>
<span className="val">{packager ?? "Nobody: this is a build from source."}</span>
</div>
</div>
<p className="settings-quiet">
The Functional Source License: use it for anything except competing with it, and every
release turns MIT two years after it ships.
</p>
{isDesktop ? (
<div className="set-actions">
<Button
disabled={phase === "checking" || phase === "installing"}
onClick={() => void look()}
>
{phase === "checking" ? "Looking" : "Check for updates"}
</Button>
{offered ? (
<Button
variant="primary"
disabled={phase === "installing"}
onClick={() => void install(offered)}
>
{phase === "installing" ? "Installing" : `Install ${offered.version}`}
</Button>
) : null}
{phase === "current" ? (
<span className="set-field-note">This is the newest there is.</span>
) : null}
</div>
) : null}
</>
);
}
export default Settings;