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 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 = { 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
("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 ( <>
{/* 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. */} ); } // ------------------------------------------------------------------------------------------- // 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 ( <>

Accounts

{said} {many === 1 ? "account" : "accounts"}, each with its own places, rules and piles. The colour is the edge on a row in All accounts.

{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 (
{ // 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}`); }); }} />
{account.email}
{HUES.map((hue) => (
Signature setSignature(account.id, signature)} />
{/* 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. */}
Aliases {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."}
{google && missing.length === 0 ? (
Permissions All granted
) : null}
{!google ? : missing.length > 0 ? (
Permissions
{PERMISSIONS.map((permission) => { const granted = holds(account.grantedScopes, permission.scope); return (
{/* 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 ? :
{granted ? null :

{permission.cost}

}
); })}
) : null}
); })}
{/* 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. */} {accounts.length > 0 ? ( ) : null}
{waiting ? (
Waiting for Google in your browser.
) : 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") ? (

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.

) : null} { // 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); }} /> 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 ( {children} ); } /** * 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(undefined); const [refused, setRefused] = useState(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 (
Servers
{refused !== null ? (

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.

) : config === undefined ? (

Reading the servers this account was added with.

) : config === null ? (

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.

) : ( <>
Incoming {serverLine(config.imap)}
Outgoing {serverLine(config.smtp)}
Username {loginLine(config)}
Password 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.
)}
); } /** * 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(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 ( {asking ? ( {asking.kind === "google" ? (

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.

) : (

This forgets the account's password on this computer. Your mail is untouched on the server.

)} } confirmLabel="Remove" busy={busy} busyLabel="Removing" onConfirm={() => void run(asking)} onCancel={() => setAsking(null)} /> ) : ( accounts.map((account) => (
{account.email}
)) )}
); } /** 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

Appearance

; 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 ( <>

Appearance

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.

setFont("ui", ref)} /> setFont("text", ref)} /> ({ id: String(size), label: String(size) }))} value={String(settings.textSize)} label="Text size" onChange={(id) => { applyTextSize(Number(id)); void save({ textSize: Number(id) }); }} /> {/* 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. */}
{ if (next !== pane) togglePane(); void save({ readingPane: next }); }} />
); } function SettingRow({ label, note, children, }: { label: string; note?: string; children: ReactNode; }) { return (
{label} {note ? {note} : null}
{children}
); } 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 ( ); } // ------------------------------------------------------------------------------------------- // 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; } /** * 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 ( { setPhase("running"); void asking.run().finally(() => { setPhase("idle"); onClose(); }); }} onCancel={onClose} /> ); } /** * 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 (