Keep settings with the account, not the browser
Every setting lived in localStorage, so none of them travelled between devices. The sharpest edge is the default identity: with none set the address that sorts first wins, so mail goes out from an address the recipient may not recognise -- and someone who sets it at work finds it unset at home, with nothing to say so. Reported in #54. They now live in a settings.json in the account's own JMAP Files, beside the signature images already kept there. ihasmail itself stays stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. localStorage stays as a cache rather than the source of truth, so the first frame is painted from it and the file corrects it a moment later. A private window has no cache and shows defaults for that one frame, which is the trade for not gating the whole app on a network round trip. Not everything should follow the account. 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 claiming it elsewhere would be a lie. Those stay local, written as a list of exceptions so that a setting added later syncs by default -- which is what adding one almost always means. Writes are coalesced: update() fires on every frame of a splitter drag, so a change waits 3s and the newest value wins. A tab going away flushes first, as does signing out, so a setting changed seconds before either is not lost. The ihasmail folder is now hidden from the Files view, contents and all. Hiding the folder alone would have been worse than showing it: the tree attaches a node whose parent is missing to the root, so the signature images would have spilled into the top level as if the user had put them there. Those images have been visible since signatures shipped. Requires 0.16 -- FileNode/query cannot see directories before that. On 0.15 settings stay local exactly as they were. Verified against the mock end to end: folder create, blob upload, node create, read back, update, re-read. Not yet exercised against the live 0.16.19.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { loadJson, saveJson } from "@/lib/storage";
|
||||
import { queueSettingsPush } from "@/lib/settingsSync";
|
||||
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
||||
|
||||
export type Theme = "system" | "light" | "dark";
|
||||
@@ -141,12 +142,59 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
defaultIdentityByAccount: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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>;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -160,12 +208,18 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
||||
set({ settings });
|
||||
applyTheme(settings);
|
||||
applyDateTimePrefs(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);
|
||||
queueSettingsPush(syncedPart(DEFAULT_SETTINGS));
|
||||
},
|
||||
exportJson() {
|
||||
return JSON.stringify(get().settings, null, 2);
|
||||
@@ -179,6 +233,14 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
hydrate(remote) {
|
||||
const settings = { ...get().settings, ...acceptRemote(remote) };
|
||||
// Cache it, so the next first frame on this browser is already right.
|
||||
saveJson("settings", settings);
|
||||
set({ settings });
|
||||
applyTheme(settings);
|
||||
applyDateTimePrefs(settings);
|
||||
},
|
||||
}));
|
||||
|
||||
function applyDateTimePrefs(s: Settings): void {
|
||||
|
||||
Reference in New Issue
Block a user