Newest-first was the only order, so the mail you had not read yet was wherever it happened to fall. Seven presets and up to three levels of your own. It covers the Inbox alone by default: unread-first is what people want in the folder they triage and confusing in Sent, where everything is read and the order that matters is when it went. Search keeps newest-first whatever the setting says, since a result list is already ordered by the question that was asked. The server does the sorting, over the whole folder, for the same reason search runs there: a list sorted in the browser is sorted only as far as the browser has loaded, which on a folder of ten thousand is the first fifty and a lie about the rest. Two details that are easy to get wrong and were worth pinning in tests. hasKeyword sorts a boolean and false comes before true, so "unread first" is $seen ASCENDING while "starred first" is $flagged DESCENDING -- the other way round. Getting either backwards puts exactly the mail you were looking for at the bottom. And every order ends with newest-first as a tiebreak, because a sort whose last level is a keyword or a subject leaves every tie undefined, and an undefined order changes between two looks at the same folder for no reason the reader can see. Sorting on a keyword is optional in RFC 8621, and a server that will not do it fails the whole query rather than degrading it -- so this setting could turn a folder into one that does not open. The refusal is caught once, the keyword levels dropped and the query retried, and nothing is said: the reader asked for an order and got the closest the server can give, and a toast on every folder change would be the app complaining about its own request. The mock now honours the sort instead of always answering newest-first, which had it reproducing a server that silently returns a different order from the one asked for -- the one shape of wrongness a client cannot detect. MOCK_NO_KEYWORD_SORT=1 reproduces a server that refuses the keyword sorts, so the fallback can be developed against.
545 lines
21 KiB
TypeScript
545 lines
21 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { create } from "zustand";
|
|
import { hasCachedJson, loadJson, saveJson } from "@/lib/storage";
|
|
import type { SortLevel, SortPreset } from "@/lib/listSort";
|
|
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 {
|
|
theme: Theme;
|
|
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;
|
|
/** 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 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[];
|
|
/**
|
|
* The theme the top-bar toggle goes back to from light. Remembered rather
|
|
* than assumed, so flipping to light and back returns you to the theme you
|
|
* were on — "ihasmail", "system" or plain "dark" — instead of dropping
|
|
* everyone onto the same one. Never "light": that is the side being
|
|
* toggled away from.
|
|
*/
|
|
lastDarkTheme: Exclude<Theme, "light">;
|
|
}
|
|
|
|
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",
|
|
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,
|
|
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,
|
|
eventCategories: [
|
|
{ name: "Important", color: "#dc2626" },
|
|
{ name: "Work", color: "#2563eb" },
|
|
{ name: "Personal", color: "#16a34a" },
|
|
{ name: "Travel", color: "#ea580c" },
|
|
{ name: "Family", color: "#9333ea" },
|
|
],
|
|
defaultIdentityByAccount: {},
|
|
hiddenIdentities: [],
|
|
lastDarkTheme: "ihasmail",
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
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) {
|
|
// Picking a theme anywhere — the toggle, Appearance, an imported file —
|
|
// is what teaches the toggle where to come back to. Doing it here rather
|
|
// than at the call sites means a fourth way to set a theme cannot forget.
|
|
const next = patch.theme && patch.theme !== "light" ? { ...patch, lastDarkTheme: patch.theme } : patch;
|
|
const settings = { ...get().settings, ...next };
|
|
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(next).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 = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
|
const dark = isDarkTheme(s.theme, prefersDark);
|
|
// ihasmail keeps data-theme="dark" and adds a palette on top, so every
|
|
// dark-only rule in the stylesheet applies to it without being repeated.
|
|
root.dataset.theme = dark ? "dark" : "light";
|
|
if (s.theme === "ihasmail") root.dataset.palette = "ihasmail";
|
|
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 = s.theme === "ihasmail" ? THEME_COLOR.ihasmail : dark ? THEME_COLOR.dark : THEME_COLOR.light;
|
|
}
|
|
|
|
/**
|
|
* Where the top-bar toggle goes next. Away from dark is always light; back
|
|
* from light is wherever you last were, which is the whole point of
|
|
* remembering it.
|
|
*/
|
|
export function toggleTarget(effective: "light" | "dark", lastDarkTheme: Settings["lastDarkTheme"]): Theme {
|
|
return effective === "dark" ? "light" : lastDarkTheme;
|
|
}
|
|
|
|
/** 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 theme = useSettings((s) => s.settings.theme);
|
|
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 isDarkTheme(theme, systemDark) ? "dark" : "light";
|
|
}
|
|
|
|
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}`;
|