Store the theme with the account, in the webmail's settings.json

The palette and light/dark choice now live in the account's JMAP Files
(ihasmail/settings.json), the same file and keys INBUXA webmail uses, so
the theme follows the user across devices and both apps. The admin
reads it at sign-in and writes only palette, mode and the derived
legacy theme, after a fresh read, keeping every other key. A palette
change keeps the stored mode. localStorage stays as the first-paint
cache.
This commit is contained in:
2026-09-19 01:05:56 -07:00
parent 91531b4784
commit 2e02532279
4 changed files with 251 additions and 2 deletions
+217
View File
@@ -0,0 +1,217 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* The theme follows the account, the same way INBUXA webmail's does: from
* `settings.json` in the account's own JMAP Files, inside the `ihasmail`
* folder. The two apps read and write the same keys, so a palette picked in
* either is the one both open with, on any device.
*
* The admin touches only the theme's keys: `palette`, `mode`, and the derived
* `theme` that older webmail builds read. Everything else in the file is the
* webmail's and is written back exactly as it was read, from a fresh read
* made just before each write. localStorage stays as the cache that paints
* the first frame, as in the webmail.
*/
import { apiFetch } from '@/services/api';
import { jmapRequest } from '@/services/jmap/client';
import { isPaletteId, type PaletteId } from '@/lib/palettes';
const FILENODE = 'urn:ietf:params:jmap:filenode';
const APP_FOLDER = 'ihasmail';
const FILE = 'settings.json';
const TYPE = 'application/json';
const DEBOUNCE_MS = 1500;
type Mode = 'system' | 'light' | 'dark';
interface Target {
accountId: string;
uploadUrl: string;
downloadUrl: string;
}
let target: Target | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
/** `mode` is set only when the user picked light or dark; a palette change keeps the stored mode. */
interface Choice {
palette: PaletteId;
mode: Mode | null;
fallbackMode: Mode;
}
let pending: Choice | null = null;
let chain: Promise<void> = Promise.resolve();
/** A server URL, as a path for apiFetch (which adds the API base). */
function pathOf(url: string): string {
try {
const u = new URL(url, 'http://placeholder');
return u.pathname + u.search;
} catch {
return url;
}
}
/** Remember where the account's Files are, from the JMAP session. Null turns syncing off. */
export function setAccountSettingsTarget(session: Record<string, unknown> | null): void {
const primary = (session?.primaryAccounts ?? {}) as Record<string, string>;
const accountId = primary[FILENODE];
const uploadUrl = session?.uploadUrl;
const downloadUrl = session?.downloadUrl;
target =
accountId && typeof uploadUrl === 'string' && typeof downloadUrl === 'string'
? { accountId, uploadUrl, downloadUrl }
: null;
if (!target) {
pending = null;
if (timer) clearTimeout(timer);
timer = null;
}
}
async function call<T>(method: string, args: Record<string, unknown>): Promise<T> {
const [res] = await jmapRequest([[method, args, '0']], undefined, [FILENODE]);
if (!res || res[0] === 'error') throw new Error(`${method} failed`);
return res[1] as T;
}
interface Node {
id: string;
name: string;
parentId?: string | null;
nodeType?: string;
blobId?: string | null;
}
async function children(t: Target, parentId: string | null): Promise<Node[]> {
const filter = parentId ? { parentId } : { isTopLevel: true };
const responses = await jmapRequest(
[
['FileNode/query', { accountId: t.accountId, filter, limit: 1000 }, 'q'],
[
'FileNode/get',
{
accountId: t.accountId,
'#ids': { resultOf: 'q', name: 'FileNode/query', path: '/ids' },
properties: ['id', 'name', 'parentId', 'nodeType', 'blobId'],
},
'g',
],
],
undefined,
[FILENODE],
);
const get = responses.find((r) => r[2] === 'g');
if (!get || get[0] === 'error') throw new Error('FileNode/get failed');
return ((get[1] as { list?: Node[] }).list ?? []) as Node[];
}
async function findFolder(t: Target): Promise<string | null> {
const top = await children(t, null);
return top.find((n) => n.name === APP_FOLDER && !n.parentId && n.nodeType === 'directory')?.id ?? null;
}
async function readFile(
t: Target,
folderId: string | null,
): Promise<{ node: Node | null; body: Record<string, unknown> }> {
if (!folderId) return { node: null, body: {} };
const node = (await children(t, folderId)).find((n) => n.name === FILE && n.parentId === folderId) ?? null;
if (!node?.blobId) return { node, body: {} };
const url = t.downloadUrl
.replace('{accountId}', encodeURIComponent(t.accountId))
.replace('{blobId}', encodeURIComponent(node.blobId))
.replace('{name}', FILE)
.replace('{type}', encodeURIComponent(TYPE));
const res = await apiFetch(pathOf(url));
if (!res.ok) throw new Error(`settings download failed (${res.status})`);
const parsed = (await res.json()) as unknown;
return {
node,
body: parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {},
};
}
/** The account's theme, or null when there is none to read (no file, no Files, a failure). */
export async function loadAccountTheme(): Promise<{ palette: PaletteId | null; mode: Mode | null } | null> {
const t = target;
if (!t) return null;
try {
const { body } = await readFile(t, await findFolder(t));
const palette = isPaletteId(body.palette) ? body.palette : null;
const mode = body.mode === 'light' || body.mode === 'dark' || body.mode === 'system' ? body.mode : null;
if (!palette && !mode) return null;
return { palette, mode };
} catch {
// A settings file we can't read must never cost anyone the admin; the cache stands.
return null;
}
}
/** The old single-value `theme`, derived exactly as the webmail does (its lib/palette.ts). */
function legacyTheme(palette: PaletteId, mode: Mode, prefersDark: boolean): 'system' | 'light' | 'dark' | 'ihasmail' {
const effective = mode === 'system' ? (prefersDark ? 'dark' : 'light') : mode;
if (palette === 'ihasmail' && effective === 'dark') return 'ihasmail';
if (palette === 'default' && mode === 'system') return 'system';
return effective;
}
async function write(t: Target, choice: Choice): Promise<void> {
let folderId = await findFolder(t);
if (!folderId) {
const created = await call<{ created?: Record<string, { id: string }> }>('FileNode/set', {
accountId: t.accountId,
create: { d: { parentId: null, name: APP_FOLDER, nodeType: 'directory' } },
});
folderId = created.created?.d?.id ?? null;
if (!folderId) throw new Error('could not create the settings folder');
}
const { node, body } = await readFile(t, folderId);
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
const stored = body.mode === 'light' || body.mode === 'dark' || body.mode === 'system' ? body.mode : null;
const mode = choice.mode ?? stored ?? choice.fallbackMode;
const next = { ...body, palette: choice.palette, mode, theme: legacyTheme(choice.palette, mode, prefersDark) };
const json = JSON.stringify(next, null, 2);
const blob = new Blob([json], { type: TYPE });
const up = await apiFetch(pathOf(t.uploadUrl.replace('{accountId}', encodeURIComponent(t.accountId))), {
method: 'POST',
headers: { 'Content-Type': TYPE },
body: blob,
});
if (!up.ok) throw new Error(`settings upload failed (${up.status})`);
const { blobId } = (await up.json()) as { blobId: string };
if (node) {
await call('FileNode/set', {
accountId: t.accountId,
update: { [node.id]: { blobId, type: TYPE, size: blob.size } },
});
} else {
await call('FileNode/set', {
accountId: t.accountId,
create: { s: { parentId: folderId, name: FILE, blobId, type: TYPE, nodeType: 'file' } },
});
}
}
/**
* Queue the theme for the account. `mode` is null for a palette-only change, which keeps the
* account's stored mode (a webmail "system" stays "system"); `fallbackMode` is used only when
* nothing is stored. Coalesces: the newest choice wins, one write after changes stop.
*/
export function queueAccountTheme(palette: PaletteId, mode: Mode | null, fallbackMode: Mode): void {
if (!target) return;
pending = { palette, mode: mode ?? pending?.mode ?? null, fallbackMode };
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const t = target;
const choice = pending;
pending = null;
if (!t || !choice) return;
// One write at a time, in order.
chain = chain.then(() => write(t, choice)).catch(() => undefined);
}, DEBOUNCE_MS);
}
+6
View File
@@ -13,6 +13,7 @@ import { useSchemaStore } from '@/stores/schemaStore';
import { useAccountStore } from '@/stores/accountStore';
import { useUIStore } from '@/stores/uiStore';
import { fetchSession, fetchSchema, fetchAccountInfo } from '@/services/jmap/client';
import { loadAccountTheme, setAccountSettingsTarget } from '@/lib/accountSettings';
import { setLocale } from '@/i18n';
import { TopBar } from '@/components/layout/TopBar';
import { Sidebar } from '@/components/layout/Sidebar';
@@ -144,6 +145,11 @@ export default function AdminPanel() {
if (cancelled) return;
setSession(accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet);
useAuthStore.getState().setUsername(typeof session.username === 'string' ? session.username : null);
// INBUXA: the theme lives with the account, shared with the webmail.
setAccountSettingsTarget(session);
void loadAccountTheme().then((stored) => {
if (stored && !cancelled) useUIStore.getState().applyAccountTheme(stored.palette, stored.mode);
});
const [schemaData, accountData] = await Promise.all([fetchSchema(), fetchAccountInfo()]);
+7 -2
View File
@@ -1,5 +1,6 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
@@ -32,7 +33,11 @@ export function getAccountId(objectType: string): string {
return activeAccountId;
}
export async function jmapRequest(methodCalls: JmapMethodCall[], signal?: AbortSignal): Promise<JmapMethodResponse[]> {
export async function jmapRequest(
methodCalls: JmapMethodCall[],
signal?: AbortSignal,
extraUsing: string[] = [],
): Promise<JmapMethodResponse[]> {
const { apiUrl } = useAuthStore.getState();
let path = apiUrl || '/jmap';
if (path.startsWith('http://') || path.startsWith('https://')) {
@@ -48,7 +53,7 @@ export async function jmapRequest(methodCalls: JmapMethodCall[], signal?: AbortS
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
using: JMAP_USING,
using: [...JMAP_USING, ...extraUsing],
methodCalls,
}),
signal,
+21
View File
@@ -5,6 +5,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { queueAccountTheme } from '@/lib/accountSettings';
import { applyPalette, DEFAULT_PALETTE, isPaletteId, type PaletteId } from '@/lib/palettes';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
@@ -23,6 +24,8 @@ interface UIState {
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
setPalette: (palette: PaletteId) => void;
/** INBUXA: take the theme stored with the account, without writing it back. */
applyAccountTheme: (palette: PaletteId | null, mode: 'system' | 'light' | 'dark' | null) => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
toggleSidebarCollapsed: () => void;
@@ -51,11 +54,13 @@ export const useUIStore = create<UIState>()(
const next = get().theme === 'light' ? 'dark' : 'light';
applyThemeClass(next);
set({ theme: next });
queueAccountTheme(get().palette, next, next);
},
setTheme: (theme) => {
applyThemeClass(theme);
set({ theme });
queueAccountTheme(get().palette, theme, theme);
},
toggleSidebar: () => {
@@ -69,6 +74,22 @@ export const useUIStore = create<UIState>()(
setPalette: (palette) => {
applyPalette(palette);
set({ palette });
queueAccountTheme(palette, null, get().theme);
},
applyAccountTheme: (palette, mode) => {
const next: Partial<UIState> = {};
if (palette) {
applyPalette(palette);
next.palette = palette;
}
if (mode) {
const theme: Theme =
mode === 'system' ? (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : mode;
applyThemeClass(theme);
next.theme = theme;
}
set(next);
},
toggleSidebarCollapsed: () => {