Files
ihasmail-inbuxa/web/src/store/settings.ts
T
jcoffey-dev bd6a605d61 Group six more clusters out of web/src/lib
Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.

  lib/mailbox/  archiveDate, emptyFolder, folderMove, labelTree,
                mailboxName, mailboxRoute
  lib/sieve/    sieve, sieveApply, sieveFolders
  lib/input/    keyboard, swipe, touch, listSelection, dropUpload
  lib/notify/   notify, webpush, webpushEnable
  lib/sw/       swCache, swFacts, staleBuild
  lib/text/     html, markdown, text, emlName

FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:

  - appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
    *Files*, where the client keeps signature images and synced settings.
    It stays flat.
  - format holds no formatting of text. It re-exports the date and clock
    formatters, so it belongs with dates/datetime, not with text/.
  - preview is the file viewer deciding what it can show without
    downloading, and source is where to point someone asking for this
    instance's AGPL source. Neither is about text.
  - notify is not Web Push. It is the tab title, the favicon badge and
    the new-mail sound -- in-app notification, which is why it sits with
    webpush rather than under sw/ with the service worker's own concerns.

threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.

No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
2026-09-15 23:17:50 -07:00

731 lines
29 KiB
TypeScript

import { useEffect, useState } from "react";
import { create } from "zustand";
import { hasCachedJson, loadJson, saveJson } from "@/lib/storage";
import { effectiveMode, legacyTheme, migrateTheme, type Mode, type PaletteId } from "@/lib/palette";
import type { SortLevel, SortPreset } from "@/lib/listSort";
import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync";
import { policyChanges, policyDefaults, policyEnforced, type PolicyChange } from "@/lib/settingsPolicy";
import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime";
import type { SwipeAction } from "@/lib/input/swipe";
import { resolveUiLanguage } from "@/lib/languages";
import { loadLanguage } from "@/lib/i18n";
/**
* "ihasmail" is a dark theme carrying the palette from ihasmail.org. It is a
* theme rather than an accent because it changes the backgrounds, borders and
* text as well as the highlight color — an accent could not.
*/
export type Theme = "system" | "light" | "dark" | "ihasmail";
export type Density = "comfortable" | "cozy" | "compact";
export type ReadingPane = "right" | "bottom" | "off";
export type ImagePolicy = "ask" | "always" | "contacts";
export type ComposeFormat = "html" | "text";
export type ReadReceiptPolicy = "ask" | "never";
/** How prominent a label is in the sidebar. */
export type LabelVisibility = "always" | "unread" | "hidden";
export interface Label {
/** The IMAP keyword itself, which is what actually rides on the message. */
keyword: string;
name: string;
color: string;
/**
* The keyword of the label this one sits under, if any.
*
* Nesting is display only. The keywords stay flat on the message, which is
* what keeps them readable by every other client -- a label moved under
* another one does not rewrite anything in the mailbox.
*/
parent?: string;
/** Absent means "always", so a settings file written before this parses unchanged. */
visibility?: LabelVisibility;
}
/** A calendar subscribed to by URL, read-only and redrawn on every refresh. */
export interface IcalSubscription {
id: string;
url: string;
name: string;
color: string;
}
export interface Template {
id: string;
name: string;
subject: string;
html: string;
}
/** A signer remembered for an address. See `knownSigners`. */
export interface SignerPin {
/** SHA-256 of the certificate's DER, lowercase hex. */
fingerprint: string;
/** What the certificate called its holder, so a change can be described. */
name: string;
/** When this fingerprint was first pinned, ISO 8601. */
firstSeen: string;
/**
* The message that established the pin.
*
* Without this, the message that *created* a pin reads as corroborated by it
* the next time it is opened — "the same signer as before", where before is
* itself. That is a claim of corroboration from evidence that does not
* exist, and it appears on the very first signed message somebody receives.
*/
messageId?: string;
}
export interface Settings {
/**
* Kept, and kept correct, for a device still running a build that only knows
* this field. It cannot express "Gruvbox", but it can express light or dark,
* which is the half that stops an older device showing a theme nobody chose.
*/
theme: Theme;
/** The colors. */
palette: PaletteId;
/** Light, dark, or whatever the system says. */
mode: Mode;
accent: string;
density: Density;
readingPane: ReadingPane;
conversationMode: boolean;
showPreview: boolean;
showAvatars: boolean;
pageSize: number;
markReadDelay: number; // seconds; -1 = never auto
/**
* Shared calendars and address books the reader has added, as
* `accountId:collectionId`.
*
* JMAP keeps this on the collection itself, in `isSubscribed`, and that is
* still tried first -- a preference the server holds is one every client
* sees. But subscribing writes to the *owner's* account, and Stalwart 0.16.19
* refuses that for an address book shared read-only: "You are not allowed to
* modify this address book." It accepts the same write on a shared calendar,
* which is the inconsistency this list exists to paper over.
*
* So where the server will not remember, ihasmail does, in the settings that
* already follow the reader between devices.
*/
addedShares: string[];
/**
* S/MIME signers pinned on first sight, keyed by lowercased address.
*
* This is the whole trust model for signature checking, and it is a small
* one: a browser has no system trust store, and the certificate that signs a
* message travels inside it, so "this signature verifies" on its own says
* only that the sender held the key they attached. What makes it worth
* anything is remembering — the same signer as last time is reassuring, and a
* different one is worth interrupting somebody over.
*
* It lives in the synced settings rather than in this browser because a pin
* that only one device knows about would greet the same correspondent as new
* on every other one, which trains people to click past exactly the warning
* this exists to raise.
*/
knownSigners: Record<string, SignerPin>;
imagePolicy: ImagePolicy;
/** Let messages follow the app's light/dark theme instead of always sitting on white. */
themeMessageBody: boolean;
/**
* Extend that to mail which brings colors of its own.
*
* Only meaningful with `themeMessageBody` on. Off by default because it
* cannot be done perfectly: see `markKeptSurfaces` in lib/html.ts for the
* bargain it makes, and #290 for why the conservative default alone left
* essentially all HTML mail on a white card.
*/
themeStyledMessages: boolean;
undoSendSeconds: number;
composeFormat: ComposeFormat;
replyAllDefault: boolean;
signatureAboveQuote: boolean;
includeQuote: boolean;
requestReadReceipt: boolean;
/**
* What to do when a sender asks for a read receipt. There is deliberately no
* "always": an automatic receipt confirms to whoever asked that the address
* is live and when it was read, which is exactly what a sender who should
* not have that is fishing for. RFC 8098 asks that a person decide each one.
*/
readReceiptPolicy: ReadReceiptPolicy;
confirmDelete: boolean;
/**
* What dragging a message row sideways does, on a touchscreen.
*
* Two settings rather than one "swipe actions" toggle because the pair is
* the choice: which hand-side gets the destructive one is personal, and the
* usual complaint about swipe gestures is not that they exist but that the
* app picked the wrong ones. "none" turns a direction off; turning both off
* turns the gesture off.
*
* They follow the account rather than the device: someone who has decided
* that a left swipe deletes has decided it for their phone and their tablet
* both, and the setting is meaningless on the desktop that would otherwise
* be the odd one out.
*/
swipeRight: SwipeAction;
swipeLeft: SwipeAction;
desktopNotifications: boolean;
notificationSound: boolean;
attachmentReminder: boolean;
weekStart: 0 | 1 | 6;
/** "" = follow the mail server's locale, then the browser's. */
locale: string;
/**
* The language the interface is written in, and what `<html lang>` says.
*
* Separate from `locale` above, which is a *formatting* choice — what
* calendar, clock and numerals to use. They are genuinely different
* questions: German dates with an English interface is a real preference,
* and so is the reverse. Folding them together would silently rewrite
* everybody's date format the first time they picked a language.
*
* Absent means English, for a new account and for every existing one whose
* settings file predates this. The browser's `Accept-Language` is
* deliberately not consulted as the stored default: a served locale should
* be something the reader chose, not something guessed on their behalf and
* then written down as though they had.
*/
uiLanguage: string;
dateFormat: DateFormat;
timeFormat: TimeFormat;
calendarDefaultView: "month" | "week" | "day" | "agenda";
workDayStart: number;
workDayEnd: number;
defaultEventDuration: number; // minutes
defaultAlertMinutes: number;
timeZone: string | null; // null = browser
labelsSidebar: boolean;
/**
* Birthdays from the address book, shown as a calendar of their own.
* Off by default: it is derived data, and a calendar that fills itself with
* dates nobody put there is a surprise rather than a feature.
*/
birthdayCalendar: boolean;
/**
* Calendars subscribed to by URL. The subscription is the setting; the
* events themselves are fetched on demand and never stored, so this follows
* the account the way every other preference does and costs nothing to sync.
*/
icalSubscriptions: IcalSubscription[];
/** What order the message list is in. See lib/listSort.ts. */
listSortPreset: SortPreset;
listSortLevels: SortLevel[];
/**
* Which folders it covers. Inbox-only is the useful default rather than a
* timid one: unread-first is what people want where they triage, and
* confusing in Sent, where everything is read.
*/
listSortScope: "inbox" | "all";
fontSize: "small" | "medium" | "large";
templates: Template[];
labels: Label[];
/**
* Folder colors, by mailbox id. Local to this browser, like every other
* color here: JMAP has nowhere on a Mailbox to keep one.
*/
folderColors: Record<string, string>;
sidebarCollapsed: boolean;
showHiddenFolders: boolean;
trustedImageSenders: string[];
/**
* The three warnings, each off until switched on. A client that starts by
* interrupting is one people learn to click through, and a warning clicked
* through without reading costs the same attention and buys nothing.
*/
externalSenderBanner: boolean;
externalRecipientConfirm: boolean;
/**
* Domains that count as inside, *in addition to* the account's own identity
* domains, which are always internal and are not configuration.
*/
internalDomains: string[];
/** People on a message before sending asks. 0 is off. */
replyAllThreshold: number;
externalLinkWarning: boolean;
trustedLinkDomains: string[];
archiveOnReply: boolean;
autoAdvance: "newer" | "older" | "list";
spellcheck: boolean;
sendAndArchive: boolean;
/** Width (px) of the message list when the reading pane is on the right. */
listPaneWidth: number;
/** Height (px) of the message list when the reading pane is below. */
listPaneHeight: number;
/** Width (px) of the contact list, beside the contact on show. */
contactsListWidth: number;
/**
* Width (px) of the sidebar, dragged by its edge (#345). Null until someone
* drags it, and null again after a double-click resets it -- which leaves the
* width to the stylesheet's `--sidebar-w`, so a reader who already widens the
* sidebar with their own CSS keeps what they had until they choose otherwise.
*/
sidebarWidth: number | null;
/** Outlook-style color categories for calendar events. */
eventCategories: Array<{ name: string; color: string }>;
/** Default sending identity per account (JMAP has no such flag). */
defaultIdentityByAccount: Record<string, string>;
/**
* Identities kept out of the compose picker, by id.
*
* An account with alias domains can have every address twice over while only
* a handful are ever sent from, which makes the picker useless (#73). This
* hides them from the picker only — the identity still exists on the server,
* still receives, and is still listed and editable in Settings, exactly as an
* unsubscribed folder still exists.
*
* A flat list rather than keyed by account: identity ids are unique, and an
* id belonging to another account simply never matches.
*/
hiddenIdentities: string[];
/**
* Installation policy changes this account has already had applied.
*
* The third power in #207: an admin turns a setting on for everybody who is
* already here, and readers may still turn it back off afterwards. That only
* works if "already applied" is remembered, or the next sign-in would undo
* their decision again and the setting would be enforcement wearing a
* different hat.
*
* Ids, not a high-water mark. The reporter's analogy is a schema migration,
* where each change carries its own version, and remembering the set rather
* than the maximum is what lets an admin add a change dated earlier than one
* already applied without it being silently skipped.
*
* Synced with the rest, so it is per account and not per browser: signing in
* on a phone must not apply everything a second time.
*/
appliedPolicyChanges: string[];
}
export const DEFAULT_SETTINGS: Settings = {
/**
* ihasmail's own palette is what a new account gets, so the app looks like
* itself before anyone has chosen anything. It is only a default: a stored
* theme always wins, so nobody who has picked one — including everyone
* already using ihasmail, whose choice is saved even if they never changed
* it — is moved off it.
*/
theme: "ihasmail",
palette: "ihasmail",
mode: "dark",
accent: "teal",
density: "cozy",
readingPane: "right",
conversationMode: true,
showPreview: true,
showAvatars: true,
pageSize: 50,
markReadDelay: 0,
addedShares: [],
knownSigners: {},
imagePolicy: "ask",
themeMessageBody: false,
themeStyledMessages: false,
undoSendSeconds: 8,
composeFormat: "html",
replyAllDefault: false,
signatureAboveQuote: true,
includeQuote: true,
requestReadReceipt: false,
readReceiptPolicy: "ask",
confirmDelete: false,
/*
* Right archives and left deletes, which is what the mail apps a phone came
* with already do. A default nobody has to learn beats a better one they do.
*/
swipeRight: "archive",
swipeLeft: "delete",
desktopNotifications: false,
notificationSound: false,
attachmentReminder: true,
weekStart: 1,
locale: "",
uiLanguage: "en",
dateFormat: "auto",
timeFormat: "auto",
calendarDefaultView: "week",
workDayStart: 8,
workDayEnd: 18,
defaultEventDuration: 60,
defaultAlertMinutes: 10,
timeZone: null,
labelsSidebar: true,
birthdayCalendar: false,
icalSubscriptions: [],
listSortPreset: "newest",
listSortLevels: [],
listSortScope: "inbox",
fontSize: "medium",
templates: [],
labels: [],
folderColors: {},
sidebarCollapsed: false,
showHiddenFolders: false,
trustedImageSenders: [],
externalSenderBanner: false,
externalRecipientConfirm: false,
internalDomains: [],
replyAllThreshold: 0,
externalLinkWarning: false,
trustedLinkDomains: [],
archiveOnReply: false,
autoAdvance: "list",
spellcheck: true,
sendAndArchive: false,
listPaneWidth: 520,
listPaneHeight: 340,
contactsListWidth: 320,
sidebarWidth: null,
eventCategories: [
{ name: "Important", color: "#dc2626" },
{ name: "Work", color: "#2563eb" },
{ name: "Personal", color: "#16a34a" },
{ name: "Travel", color: "#ea580c" },
{ name: "Family", color: "#9333ea" },
],
defaultIdentityByAccount: {},
hiddenIdentities: [],
appliedPolicyChanges: [],
};
/**
* Settings that describe *this screen or this browser*, and so stay in
* localStorage: a list-pane width picked on a 27" monitor is wrong on a
* laptop, and the notification toggles track a permission the browser grants
* per-device, so syncing them would claim something untrue elsewhere.
*
* Everything else follows the account (issue #54). The list is written as the
* exceptions rather than the rule so that a setting added later syncs by
* default, which is what someone adding one almost always wants.
*/
export const DEVICE_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>([
"density",
"fontSize",
"sidebarCollapsed",
"desktopNotifications",
"notificationSound",
"listPaneWidth",
"listPaneHeight",
"contactsListWidth",
"sidebarWidth",
]);
/** The part of the settings that is written to the account's settings file. */
export function syncedPart(s: Settings): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of Object.keys(s) as Array<keyof Settings>) {
if (!DEVICE_KEYS.has(key)) out[key] = s[key];
}
return out;
}
/**
* What of a settings file we are willing to apply: known keys only, and never
* a device one — an older ihasmail wrote the whole object up, and that file
* should not now drag another machine's pane width across.
*/
export function acceptRemote(remote: Record<string, unknown>): Partial<Settings> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(remote)) {
if (!(key in DEFAULT_SETTINGS)) continue;
if (DEVICE_KEYS.has(key as keyof Settings)) continue;
if (value === undefined) continue;
out[key] = value;
}
/*
* A file written before palettes existed carries `theme` and neither
* `palette` nor `mode`, so it is read through the old enum. Settings live in
* the account's own Files and are opened by whatever version happens to run
* next, so this is not a one-release migration -- it has to keep working.
*
* Only when the new fields are absent: a file that has both is newer, and
* its `theme` is the derived copy rather than the choice.
*/
if (out.palette === undefined && out.mode === undefined && typeof remote.theme === "string") {
const migrated = migrateTheme(remote.theme);
out.palette = migrated.palette;
out.mode = migrated.mode;
}
return out as Partial<Settings>;
}
/**
* The settings file laid over the ones in hand, minus anything still queued.
*
* A change that has not been written up yet is newer than the file by
* definition, so it wins. Picking a language is where this showed: that
* remounts the tree, the remount re-reads the file, and the file still holds
* the language from before the click, so the click came undone. Reported as
* "sometimes it takes several clicks" — the click that stuck was the one made
* after the previous write had landed.
*/
export function mergeRemote(
current: Settings,
remote: Record<string, unknown>,
held: ReadonlySet<string> = new Set(),
): Settings {
const incoming = acceptRemote(remote);
for (const key of held) delete incoming[key as keyof Settings];
return { ...current, ...incoming };
}
interface SettingsState {
settings: Settings;
update(patch: Partial<Settings>): void;
reset(): void;
exportJson(): string;
importJson(json: string): boolean;
/** Apply the account's settings file over the cached ones. */
hydrate(remote: Record<string, unknown>): void;
/**
* Seed an account that has never had settings of its own.
*
* Only for that case, which is why it is not `update`: these are a starting
* point the reader may change, so applying them to somebody who already has
* settings would be overwriting choices rather than defaulting them.
*/
seedFromPolicy(): void;
/**
* Apply the installation's change list, each entry once.
*
* Returns the changes that were applied, so the caller can say what moved --
* a setting changing under somebody without a word is the part of this the
* reporter was uneasy about, and rightly.
*/
applyPolicyChanges(): PolicyChange[];
}
const initialSettings = loadJson<Settings>("settings", DEFAULT_SETTINGS);
/**
* Whether the first frame is this account's settings or merely the defaults.
*
* False after every deploy, because deploys sign everyone out and sign-out
* clears the cache -- and false on any untrusted device, where the cache is
* never read. In that state `uiLanguage` starts as English and only becomes
* the account's choice once the settings file lands, which is why the
* authenticated tree waits for it.
*/
export const PAINTED_FROM_CACHE = hasCachedJson("settings");
applyDateTimePrefs(initialSettings);
export const useSettings = create<SettingsState>((set, get) => ({
settings: initialSettings,
update(patch) {
/*
* `theme` is derived, never chosen: whatever set the palette or the mode --
* the toggle, Appearance, an imported file -- the legacy field is brought
* back into line here rather than at the call sites, so a fourth way to
* change the theme cannot forget to update it and strand an older device
* on a theme nobody picked.
*/
/*
* Enforcement lives here rather than only on the controls. The controls are
* disabled and say why, which is the part a reader sees -- but a setting
* the installation has decided must not be changeable through an imported
* settings file, a keyboard shortcut, or a control somebody adds later and
* forgets to check. There is one door, so the lock is on it. Issue #207.
*/
const merged = { ...get().settings, ...patch, ...policyEnforced() };
const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
const settings = { ...merged, theme: legacyTheme({ palette: merged.palette, mode: merged.mode }, prefersDark) };
saveJson("settings", settings);
set({ settings });
applyTheme(settings);
applyDateTimePrefs(settings);
applyLang(settings);
// Dragging a splitter changes a device key on every frame and must not put
// a request in the air; anything else is queued and coalesced.
if (Object.keys(patch).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) {
queueSettingsPush(syncedPart(settings));
}
},
seedFromPolicy() {
const defaults = policyDefaults();
if (!Object.keys(defaults).length) return;
get().update(defaults);
},
/*
* The third power in #207, and the only one that remembers anything.
*
* A change is applied when this account has not already had it, whatever the
* setting currently says: the point is to reach everybody who is already
* here, so somebody who had turned it off before the admin decided does get
* it turned back on. That is intended and the reporter has confirmed it --
* the difference from `enforced` is that they may turn it off again
* afterwards and it will stay off, because the version is remembered.
*
* Ids rather than a high-water mark, so a change dated earlier than one
* already applied is not silently skipped.
*
* One `update` for the lot, not one per change: each would push a settings
* file, and a policy with four changes on a first sign-in would write four.
*/
applyPolicyChanges() {
const seen = new Set(get().settings.appliedPolicyChanges ?? []);
const pending = policyChanges().filter((c) => !seen.has(c.version));
if (!pending.length) return [];
let patch: Partial<Settings> = {};
for (const c of pending) patch = { ...patch, ...c.settings };
get().update({ ...patch, appliedPolicyChanges: [...seen, ...pending.map((c) => c.version)] });
return pending;
},
reset() {
/* Back to how this installation starts an account, not to how ihasmail
starts one: resetting must not be a way around a policy, and the defaults
an admin chose are the honest meaning of "reset" where there are any. */
const base = { ...DEFAULT_SETTINGS, ...policyDefaults(), ...policyEnforced() };
saveJson("settings", base);
set({ settings: base });
applyTheme(base);
applyDateTimePrefs(base);
applyLang(base);
queueSettingsPush(syncedPart(base));
},
exportJson() {
return JSON.stringify(get().settings, null, 2);
},
importJson(json) {
try {
const parsed = JSON.parse(json) as Partial<Settings>;
get().update(parsed);
return true;
} catch {
return false;
}
},
hydrate(remote) {
/* Enforced values win over what the account's own file says: a policy that
an older sign-in has already written past would otherwise stay written
past for ever. */
const settings = { ...mergeRemote(get().settings, remote, pendingSettingsKeys()), ...policyEnforced() };
// Cache it, so the next first frame on this browser is already right.
saveJson("settings", settings);
set({ settings });
applyTheme(settings);
applyDateTimePrefs(settings);
applyLang(settings);
},
}));
function applyDateTimePrefs(s: Settings): void {
// The interface language feeds the automatic locale, so month and weekday
// names follow the language somebody chose rather than staying English.
setUiLanguageForFormatting(resolveUiLanguage(s.uiLanguage));
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
}
/**
* Put the served language on `<html lang>`.
*
* Chrome offers to translate when the language it detects does not match the
* one the page declares, so a `lang` that is briefly wrong is enough to raise
* the prompt on a page that was already correct — and accepting that prompt is
* what rewrites the DOM under React and crashes the component tree
* (facebook/react#11538).
*
* So this is not done in an effect after mount. It runs where `applyTheme`
* runs: at module load, from the localStorage cache, before `createRoot()` has
* rendered anything and therefore before first paint. `index.html` ships
* `lang="en"` statically, so the very first bytes are already right for the
* default and this only ever corrects a reader who chose otherwise.
*
* There is no server-rendered alternative to reach for. ihasmail serves a
* static shell and keeps no account state; the settings file lives in the
* reader's own JMAP Files on the mail server, so the only way to read it
* before the page existed would be to authenticate to Stalwart on every page
* load, which is the thing the whole design avoids.
*/
export function applyLang(s: Settings = useSettings.getState().settings): void {
const tag = resolveUiLanguage(s.uiLanguage);
document.documentElement.lang = tag;
/*
* The catalog is fetched, so it lands a beat after the attribute. That
* order is deliberate: `lang` is what stops Chrome offering to translate,
* and it should not wait on a network request to say something it already
* knows. English needs no fetch at all and resolves immediately.
*/
void loadLanguage(tag);
}
/** Background of each theme, for the browser chrome (`theme-color`). */
const THEME_COLOR = { light: "#ffffff", dark: "#0b1220", ihasmail: "#0d2430" } as const;
export function applyTheme(s: Settings = useSettings.getState().settings): void {
const root = document.documentElement;
const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
const mode = effectiveMode(s.mode, prefersDark);
/*
* Two attributes, because they answer two questions. `data-theme` is the
* mode, and every dark-only rule in the stylesheet keys off it without
* knowing any palette exists; `data-palette` layers the colors on top. The
* accent variants out-specify both, which is what lets an accent still apply
* over any palette.
*/
root.dataset.theme = mode;
if (s.palette && s.palette !== "default") root.dataset.palette = s.palette;
else delete root.dataset.palette;
root.dataset.density = s.density;
root.dataset.accent = s.accent;
root.dataset.fontsize = s.fontSize;
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]:not([media])');
if (meta) meta.content = paletteThemeColor(s.palette, mode);
}
/**
* The browser chrome color, read from the palette's own background so it does
* not have to be listed twice and cannot drift from it.
*/
function paletteThemeColor(palette: PaletteId, mode: "light" | "dark"): string {
if (typeof getComputedStyle === "function") {
const value = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim();
if (value) return value;
}
return mode === "dark" ? THEME_COLOR.dark : THEME_COLOR.light;
}
/** Whether a theme paints dark, resolving "system" against the OS. */
export function isDarkTheme(theme: Theme, prefersDark = false): boolean {
return theme === "dark" || theme === "ihasmail" || (theme === "system" && prefersDark);
}
if (typeof window !== "undefined") {
applyTheme();
// Before `createRoot().render()` in main.tsx, which imports this module on
// the way in -- so the language is declared before React has produced a
// single node, let alone painted one.
applyLang();
window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener("change", () => applyTheme());
}
/**
* The theme actually on screen, which is not the same as the setting: "system"
* resolves to whatever the OS is doing right now, and follows it as it changes.
*/
export function useEffectiveTheme(): "light" | "dark" {
const mode = useSettings((s) => s.settings.mode);
const [systemDark, setSystemDark] = useState(() => window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false);
useEffect(() => {
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
if (!mq) return;
const onChange = () => setSystemDark(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return effectiveMode(mode, systemDark);
}
export const settings = () => useSettings.getState().settings;
/**
* Primitive that changes whenever a date/time preference does, so memoized
* components that render dates re-render when the format is switched.
*/
export const dateTimeKey = (s: Settings): string => `${s.locale}|${s.dateFormat}|${s.timeFormat}`;