The theme was one enum -- system, light, dark, ihasmail -- where one value carried a whole palette and implied dark. That works for exactly one palette. The two questions now come apart: which palette, and which side. Classic is the plain light and dark this app has always had. ihasmail's own palette gains a day version, so the background of the dark one becomes the text of the light one and the two read as one palette from either end. Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects, used under the MIT licence, and taken from each project's own repository rather than from anyone's reimplementation. What was fetched is recorded in .palette-sources/ and credited in NOTICE. Giving ihasmail's palette a light half removed a whole special case. Nothing is one-sided any more, so a palette can no longer override the mode, the toggle no longer has to set a palette aside on the way to light and remember it, and the greyed-out control that explained all that is gone. The old lastDarkTheme, which existed only for that, is gone with it. The shades between the published colours are derived rather than guessed: these projects publish twelve to twenty values and ihasmail needs about thirty. scripts/build-palettes.py computes the tiers and then measures every text colour against the surface it sits on -- 4.5:1 for prose, 3:1 for borders and marks -- lifting anything short towards white on a dark ground and towards black on a light one, so a lifted tier keeps its hue. It refuses to write a palette that would not pass. Every one of the nine halves needed at least one lift. These palettes are built for code editors, not for prose at this size: Dracula's comment grey is 3.03:1 on its own background and Rosé Pine's gold is 2.7:1 on Dawn. Shipping them as published would have quietly ended the WCAG AA claim. Two things caught while checking rather than while writing. The generated blocks were appended to the end of the stylesheet, which put them after the accent variants at equal specificity -- so choosing an accent over one of the new palettes did nothing at all. They now sit before those rules, where the existing ihasmail block's own comment says they have to. And that block was unqualified, so it would have shadowed the new light half; it is now explicitly the dark one. Settings written before this carry `theme` and no palette, and are read through the old enum. `theme` is still written back, derived, because a device on an older build reads it and would otherwise be stranded on a theme nobody chose.
563 lines
22 KiB
TypeScript
563 lines
22 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 { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync";
|
|
import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
|
import type { SwipeAction } from "@/lib/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 colour — 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;
|
|
}
|
|
|
|
export interface Template {
|
|
id: string;
|
|
name: string;
|
|
subject: string;
|
|
html: 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 colours. */
|
|
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[];
|
|
imagePolicy: ImagePolicy;
|
|
/** Let messages follow the app's light/dark theme instead of always sitting on white. */
|
|
themeMessageBody: 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;
|
|
fontSize: "small" | "medium" | "large";
|
|
templates: Template[];
|
|
labels: Label[];
|
|
/**
|
|
* Folder colours, by mailbox id. Local to this browser, like every other
|
|
* colour 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;
|
|
/** Outlook-style colour 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[];
|
|
}
|
|
|
|
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: [],
|
|
imagePolicy: "ask",
|
|
themeMessageBody: 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,
|
|
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,
|
|
eventCategories: [
|
|
{ name: "Important", color: "#dc2626" },
|
|
{ name: "Work", color: "#2563eb" },
|
|
{ name: "Personal", color: "#16a34a" },
|
|
{ name: "Travel", color: "#ea580c" },
|
|
{ name: "Family", color: "#9333ea" },
|
|
],
|
|
defaultIdentityByAccount: {},
|
|
hiddenIdentities: [],
|
|
};
|
|
|
|
/**
|
|
* 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",
|
|
]);
|
|
|
|
/** 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;
|
|
}
|
|
|
|
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.
|
|
*/
|
|
const merged = { ...get().settings, ...patch };
|
|
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));
|
|
}
|
|
},
|
|
reset() {
|
|
saveJson("settings", DEFAULT_SETTINGS);
|
|
set({ settings: DEFAULT_SETTINGS });
|
|
applyTheme(DEFAULT_SETTINGS);
|
|
applyDateTimePrefs(DEFAULT_SETTINGS);
|
|
applyLang(DEFAULT_SETTINGS);
|
|
queueSettingsPush(syncedPart(DEFAULT_SETTINGS));
|
|
},
|
|
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) {
|
|
const settings = mergeRemote(get().settings, remote, pendingSettingsKeys());
|
|
// 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 catalogue 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 colours 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 colour, 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 memoised
|
|
* components that render dates re-render when the format is switched.
|
|
*/
|
|
export const dateTimeKey = (s: Settings): string => `${s.locale}|${s.dateFormat}|${s.timeFormat}`;
|