Messages render on a white card in every theme. That is deliberate for mail that styles itself, but #4 points out the case it gets wrong: a message with no styling of its own has nothing worth preserving, and flashing white at someone reading in the dark is a real cost. Appearance gains a switch under the theme cards, off by default so the current behaviour is unchanged. With it on, HTML mail that declares no colours follows the app theme; mail that sets a background or text colour still gets the light card it was designed for, because half-darkening someone else's design is worse than leaving it alone. Plain-text mail already followed the theme and is untouched by the switch. The themed palette is expressed in the app's own custom properties, which cross the shadow boundary, so switching theme repaints open messages without re-rendering them, and the accent-coloured link stays consistent. The host element takes color-scheme: inherit so form controls and scrollbars inside a message match too. htmlDeclaresColors covers bgcolor attributes, <font color>, and colour or background declarations in style attributes and <style> blocks, while ignoring near-misses like border-color and ?color= in a URL. Closes #4
196 lines
5.9 KiB
TypeScript
196 lines
5.9 KiB
TypeScript
import { create } from "zustand";
|
|
import { loadJson, saveJson } from "@/lib/storage";
|
|
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
|
|
|
export type Theme = "system" | "light" | "dark";
|
|
export type Density = "comfortable" | "cozy" | "compact";
|
|
export type ReadingPane = "right" | "bottom" | "off";
|
|
export type ImagePolicy = "ask" | "always" | "contacts";
|
|
export type ComposeFormat = "html" | "text";
|
|
|
|
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
|
|
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;
|
|
confirmDelete: boolean;
|
|
desktopNotifications: boolean;
|
|
notificationSound: boolean;
|
|
attachmentReminder: boolean;
|
|
weekStart: 0 | 1 | 6;
|
|
/** "" = follow the mail server's locale, then the browser's. */
|
|
locale: 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;
|
|
fontSize: "small" | "medium" | "large";
|
|
templates: Template[];
|
|
labels: Array<{ keyword: string; name: string; color: string }>;
|
|
sidebarCollapsed: boolean;
|
|
showHiddenFolders: boolean;
|
|
trustedImageSenders: 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>;
|
|
}
|
|
|
|
export const DEFAULT_SETTINGS: Settings = {
|
|
theme: "system",
|
|
accent: "teal",
|
|
density: "cozy",
|
|
readingPane: "right",
|
|
conversationMode: true,
|
|
showPreview: true,
|
|
showAvatars: true,
|
|
pageSize: 50,
|
|
markReadDelay: 0,
|
|
imagePolicy: "ask",
|
|
themeMessageBody: false,
|
|
undoSendSeconds: 8,
|
|
composeFormat: "html",
|
|
replyAllDefault: false,
|
|
signatureAboveQuote: true,
|
|
includeQuote: true,
|
|
requestReadReceipt: false,
|
|
confirmDelete: false,
|
|
desktopNotifications: false,
|
|
notificationSound: false,
|
|
attachmentReminder: true,
|
|
weekStart: 1,
|
|
locale: "",
|
|
dateFormat: "auto",
|
|
timeFormat: "auto",
|
|
calendarDefaultView: "week",
|
|
workDayStart: 8,
|
|
workDayEnd: 18,
|
|
defaultEventDuration: 60,
|
|
defaultAlertMinutes: 10,
|
|
timeZone: null,
|
|
labelsSidebar: true,
|
|
fontSize: "medium",
|
|
templates: [],
|
|
labels: [],
|
|
sidebarCollapsed: false,
|
|
showHiddenFolders: false,
|
|
trustedImageSenders: [],
|
|
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: {},
|
|
};
|
|
|
|
interface SettingsState {
|
|
settings: Settings;
|
|
update(patch: Partial<Settings>): void;
|
|
reset(): void;
|
|
exportJson(): string;
|
|
importJson(json: string): boolean;
|
|
}
|
|
|
|
const initialSettings = loadJson<Settings>("settings", DEFAULT_SETTINGS);
|
|
applyDateTimePrefs(initialSettings);
|
|
|
|
export const useSettings = create<SettingsState>((set, get) => ({
|
|
settings: initialSettings,
|
|
update(patch) {
|
|
const settings = { ...get().settings, ...patch };
|
|
saveJson("settings", settings);
|
|
set({ settings });
|
|
applyTheme(settings);
|
|
applyDateTimePrefs(settings);
|
|
},
|
|
reset() {
|
|
saveJson("settings", DEFAULT_SETTINGS);
|
|
set({ settings: DEFAULT_SETTINGS });
|
|
applyTheme(DEFAULT_SETTINGS);
|
|
applyDateTimePrefs(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;
|
|
}
|
|
},
|
|
}));
|
|
|
|
function applyDateTimePrefs(s: Settings): void {
|
|
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
|
|
}
|
|
|
|
export function applyTheme(s: Settings = useSettings.getState().settings): void {
|
|
const root = document.documentElement;
|
|
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
|
const dark = s.theme === "dark" || (s.theme === "system" && prefersDark);
|
|
root.dataset.theme = dark ? "dark" : "light";
|
|
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 = dark ? "#0b1220" : "#ffffff";
|
|
}
|
|
|
|
if (typeof window !== "undefined") {
|
|
applyTheme();
|
|
window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener("change", () => applyTheme());
|
|
}
|
|
|
|
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}`;
|