Files
cinny/src/app/state/settings.ts
T
jaredandClaude Opus 5 1ff28820f3
CI / Build & Quality Checks (push) Successful in 1m27s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 1m36s
feat(settings): sync preferences across devices via io.lotus.settings account data (#104)
Every Lotus setting was localStorage-only, so a user on web + desktop + phone
configured theme, composer toolbar, quiet hours, call keys… three times.

- utils/settingsSync.ts (pure, 7 tests): DEVICE_LOCAL_KEYS denylist (zoom,
  media auto-load, animation pause, glassmorphism, denoise tier/model,
  bitrates, volumes, notification permission, developer tools, PTT mode,
  camera-on-join, drawer state, and the sync toggle itself), pickSyncable,
  mergeRemoteSettings (unknown keys, device-local keys and wrong-shaped
  values are skipped), buildSyncedContent, shouldApplyRemote (LWW on
  updatedAt; equal stamp = our own echo).
- hooks/useSettingsSync.ts: on start applies a newer remote snapshot or
  pushes local if it differs; debounced push on any settingsAtom write,
  skipped when the syncable subset equals the last pushed/applied snapshot
  so a remote apply never echoes back; AccountData listener for live
  updates; stamps forced monotonic per device; per-account lastSyncedAt
  marker so another user on the same device can't inherit it; failed pushes
  roll the marker back so the next change retries. Remote values are re-read
  through getSettings() so enum coercion applies.
- Settings → General → Sync: toggle (device-local), "Push now", "Clear
  synced copy". AccountDataEvent.LotusSettings registered.
- ClientNonUIFeatures: the #103 tracking-param subscriber moves out of
  PageZoomFeature into its own TrackingParamsFeature next to
  SettingsSyncFeature.
- Docs: LOTUS_FEATURES entries for #103/#104; LOTUS_TODO links the new
  Features 2026-Q4 milestone and #108.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-17 01:56:06 -04:00

535 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { atom } from 'jotai';
import { isSupportedTargetLang } from '../utils/translation/langUtils';
const STORAGE_KEY = 'settings';
export type DateFormat =
| 'D MMM YYYY'
| 'DD/MM/YYYY'
| 'MM/DD/YYYY'
| 'YYYY/MM/DD'
| 'YYYY-MM-DD'
| '';
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
// Call mic noise suppression tier:
// - 'off' : no suppression
// - 'browser' : WebRTC built-in suppression (Element Call noiseSuppression param)
// - 'ml' : client-side RNNoise ML suppression (Lotus denoise shim)
export type NoiseSuppressionMode = 'off' | 'browser' | 'ml';
// Self-hostable, build-bundled ML models. DeepFilterNet 3 is included via
// deepfilternet3-noise-filter with its df_bg.wasm + ONNX model VENDORED and
// self-hosted (its cdnUrl is overridden), so it no longer depends on an external
// CDN. Its wasm is single-threaded, so no COOP/COEP cross-origin isolation is
// required (see LOTUS_DENOISE_ENGINEERING_REVIEW.md).
export type DenoiseModelId = 'rnnoise' | 'speex' | 'dtln' | 'deepfilternet';
// Incoming-call ringtone. 'classic' is the bundled call.ogg clip; 'chime' /
// 'soft' / 'retro' are synthesized in-browser (see utils/ringtones.ts);
// 'none' is silent (visual-only incoming-call UI).
export type RingtoneId = 'classic' | 'chime' | 'soft' | 'retro' | 'none';
export type SeasonalThemeOverride =
| 'auto'
| 'off'
| 'halloween'
| 'christmas'
| 'newyear'
| 'autumn'
| 'aprilfools'
| 'lunar'
| 'valentines'
| 'stpatricks'
| 'earthday'
| 'deepspace'
| 'arcade';
// Allow-list used to validate persisted values below.
const SEASONAL_THEME_OVERRIDES: SeasonalThemeOverride[] = [
'auto',
'off',
'halloween',
'christmas',
'newyear',
'autumn',
'aprilfools',
'lunar',
'valentines',
'stpatricks',
'earthday',
'deepspace',
'arcade',
];
// [P5-31] Granular call quality caps. 'auto' = don't cap (the EC fork keeps its
// default encoding). Numbers are kbps (audio/screenshare bitrate) or fps
// (screenshare framerate); converted to the fork's bits/sec + fps payload in
// utils/callQuality.ts and applied via the io.lotus.set_quality widget action.
export type CallAudioBitrate = 'auto' | '32' | '64' | '96' | '128' | '256';
export type ScreenshareBitrate = 'auto' | '500' | '1500' | '3000' | '8000';
export type ScreenshareFramerate = 'auto' | '15' | '30' | '60';
export type ChatBackground =
| 'none'
| 'blueprint'
| 'carbon'
| 'stars'
| 'topographic'
| 'herringbone'
| 'crosshatch'
| 'chevron'
| 'polka'
| 'triangles'
| 'plaid'
| 'tactical'
| 'circuit'
| 'hexgrid'
| 'waves'
| 'neon'
| 'aurora'
| 'anim-rain'
| 'anim-stars'
| 'anim-pulse'
| 'anim-aurora'
| 'anim-fireflies';
export enum MessageLayout {
Modern = 0,
Compact = 1,
Bubble = 2,
}
/**
* Keys of the toggleable composer toolbar buttons. Also used as the identity
* of each button when persisting/restoring a custom drag-and-drop order.
*/
export const COMPOSER_TOOLBAR_BUTTON_KEYS = [
'showFormat',
'showEmoji',
'showSticker',
'showGif',
'showLocation',
'showPoll',
'showVoice',
'showSchedule',
] as const;
export type ComposerToolbarButtonKey = (typeof COMPOSER_TOOLBAR_BUTTON_KEYS)[number];
/**
* The fixed order the composer toolbar rendered before reordering existed.
* Used as the fallback for users without a saved order, and to append any
* new/unknown button keys, so existing users see no change.
*/
export const DEFAULT_COMPOSER_TOOLBAR_ORDER: ComposerToolbarButtonKey[] = [
'showFormat',
'showSticker',
'showEmoji',
'showGif',
'showLocation',
'showPoll',
'showVoice',
'showSchedule',
];
export interface ComposerToolbarSettings {
showFormat: boolean;
showEmoji: boolean;
showSticker: boolean;
showGif: boolean;
showLocation: boolean;
showPoll: boolean;
showVoice: boolean;
showSchedule: boolean;
order: ComposerToolbarButtonKey[];
}
export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = {
showFormat: true,
showEmoji: true,
showSticker: true,
showGif: true,
showLocation: true,
showPoll: true,
showVoice: true,
showSchedule: true,
order: DEFAULT_COMPOSER_TOOLBAR_ORDER,
};
/**
* Returns a complete, de-duplicated composer toolbar order:
* - drops unknown/duplicate keys from the saved order
* - appends any missing keys (new buttons or existing users with no saved
* order) at the end in their canonical default position
* so a button can never disappear from the toolbar.
*/
export const normalizeComposerToolbarOrder = (
order: ComposerToolbarButtonKey[] | undefined,
): ComposerToolbarButtonKey[] => {
const known = new Set<ComposerToolbarButtonKey>(COMPOSER_TOOLBAR_BUTTON_KEYS);
const seen = new Set<ComposerToolbarButtonKey>();
const result: ComposerToolbarButtonKey[] = [];
(order ?? []).forEach((key) => {
if (known.has(key) && !seen.has(key)) {
seen.add(key);
result.push(key);
}
});
// Append missing keys in their canonical default position…
DEFAULT_COMPOSER_TOOLBAR_ORDER.forEach((key) => {
if (!seen.has(key)) {
seen.add(key);
result.push(key);
}
});
// …then any known key not covered by the default order (safety net so a new
// button added to COMPOSER_TOOLBAR_BUTTON_KEYS but forgotten in the default
// order can still render/reorder rather than being permanently dropped).
COMPOSER_TOOLBAR_BUTTON_KEYS.forEach((key) => {
if (!seen.has(key)) {
seen.add(key);
result.push(key);
}
});
return result;
};
export interface Settings {
themeId?: string;
useSystemTheme: boolean;
lightThemeId?: string;
darkThemeId?: string;
monochromeMode?: boolean;
isMarkdown: boolean;
editorToolbar: boolean;
twitterEmoji: boolean;
pageZoom: number;
hideActivity: boolean;
hidePresence: boolean;
privateReadReceipts: boolean;
presenceStatus: 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
isPeopleDrawer: boolean;
memberSortFilterIndex: number;
enterForNewline: boolean;
messageLayout: MessageLayout;
messageSpacing: MessageSpacing;
hideMembershipEvents: boolean;
hideNickAvatarEvents: boolean;
mediaAutoLoad: boolean;
urlPreview: boolean;
encUrlPreview: boolean;
inlineMediaEmbeds: boolean;
showHiddenEvents: boolean;
// [MSC1763] Opt-in: permanently redact your OWN messages once a room's
// retention window passes (default off — nothing auto-deletes by surprise).
enforceRetentionLocally: boolean;
legacyUsernameColor: boolean;
showNotifications: boolean;
isNotificationSounds: boolean;
messageSoundId: 'notification' | 'invite' | 'call' | 'none';
inviteSoundId: 'notification' | 'invite' | 'call' | 'none';
quietHoursEnabled: boolean;
quietHoursStart: string; // "HH:MM" 24h
quietHoursEnd: string; // "HH:MM" 24h
homeRoomSort: 'recent' | 'alpha' | 'unread';
hour24Clock: boolean;
dateFormatString: string;
developerTools: boolean;
lotusTerminal: boolean;
chatBackground: ChatBackground;
perMessageProfiles: boolean;
cameraOnJoin: boolean;
callNoiseSuppression: NoiseSuppressionMode;
callDenoiseModel: DenoiseModelId;
callDenoiseNativeNS: boolean;
callDenoiseGate: boolean;
callDenoiseGateThreshold: number;
pttMode: boolean;
pttKey: string;
nightLightEnabled: boolean;
nightLightOpacity: number;
nightLightSchedule: boolean;
nightLightStart: string;
nightLightEnd: string;
glassmorphismSidebar: boolean;
deafenKey: string;
warnOnUnverifiedDevices: boolean;
// [Gitea #103] Remove utm_/fbclid/… tracking params from links you paste or
// send, and from links rendered in the timeline. Local only.
stripTrackingParams: boolean;
// [Gitea #104] Mirror user preferences to `io.lotus.settings` account data
// so other devices pick them up. Device-local itself (utils/settingsSync).
settingsSync: boolean;
pauseAnimations: boolean;
composerToolbarButtons: ComposerToolbarSettings;
mentionHighlightColor: string;
customAccentColor: string;
fontFamily: 'system' | 'inter' | 'jetbrains-mono' | 'fira-code';
afkAutoMute: boolean;
afkTimeoutMinutes: number;
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
ringtoneId: RingtoneId;
ringtoneVolume: number; // 0–100
// [P5-31] Call quality controls
callAudioBitrate: CallAudioBitrate;
screenshareBitrate: ScreenshareBitrate;
screenshareFramerate: ScreenshareFramerate;
// [P5-15] In-call soundboard
soundboardEnabled: boolean;
soundboardVolume: number; // 0–100
seasonalThemeOverride: SeasonalThemeOverride;
// On-device message translation
translateTargetLang: string; // BCP-47 base code, default 'en'
autoTranslate: boolean; // auto-translate incoming foreign messages (opt-in)
// GIF picker sends every search term (and the user's IP) directly to Giphy,
// so it's opt-in and off by default.
gifPickerEnabled: boolean;
}
const defaultSettings: Settings = {
themeId: undefined,
useSystemTheme: true,
lightThemeId: undefined,
darkThemeId: undefined,
monochromeMode: false,
isMarkdown: true,
editorToolbar: false,
twitterEmoji: false,
pageZoom: 100,
hideActivity: false,
hidePresence: false,
privateReadReceipts: false,
presenceStatus: 'auto',
isPeopleDrawer: true,
memberSortFilterIndex: 0,
enterForNewline: false,
messageLayout: 0,
messageSpacing: '400',
hideMembershipEvents: false,
hideNickAvatarEvents: true,
mediaAutoLoad: true,
urlPreview: true,
encUrlPreview: true,
inlineMediaEmbeds: true,
showHiddenEvents: false,
enforceRetentionLocally: false,
legacyUsernameColor: false,
showNotifications: true,
isNotificationSounds: true,
messageSoundId: 'notification',
inviteSoundId: 'invite',
quietHoursEnabled: false,
quietHoursStart: '23:00',
quietHoursEnd: '08:00',
homeRoomSort: 'recent',
hour24Clock: false,
dateFormatString: 'D MMM YYYY',
developerTools: false,
lotusTerminal: false,
chatBackground: 'none',
perMessageProfiles: false,
cameraOnJoin: false,
// Tier default stays browser-native (known-good; best-perceived in testing so
// far). If a user opts into the ML tier, default to the highest-quality model.
callNoiseSuppression: 'browser',
callDenoiseModel: 'deepfilternet',
// "Series suppression" (stack the browser's native NS before the ML model) is
// off by default — best practice is a single NS stage; it's an opt-in test aid.
callDenoiseNativeNS: false,
callDenoiseGate: false,
callDenoiseGateThreshold: -45,
pttMode: false,
pttKey: 'Space',
nightLightSchedule: false,
nightLightStart: '21:00',
nightLightEnd: '07:00',
nightLightEnabled: false,
nightLightOpacity: 30,
glassmorphismSidebar: false,
deafenKey: 'KeyM',
warnOnUnverifiedDevices: false,
stripTrackingParams: true,
settingsSync: true,
pauseAnimations: false,
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
mentionHighlightColor: '',
customAccentColor: '',
fontFamily: 'inter',
afkAutoMute: false,
afkTimeoutMinutes: 10,
callJoinLeaveSound: 'chime',
ringtoneId: 'classic',
ringtoneVolume: 70,
callAudioBitrate: 'auto',
screenshareBitrate: 'auto',
screenshareFramerate: 'auto',
soundboardEnabled: true,
soundboardVolume: 80,
seasonalThemeOverride: 'auto',
translateTargetLang: 'en',
autoTranslate: false,
gifPickerEnabled: false,
};
export const getSettings = (): Settings => {
try {
const settings = localStorage.getItem(STORAGE_KEY);
if (settings === null) return defaultSettings;
const saved = JSON.parse(settings) as Partial<Settings>;
return {
...defaultSettings,
...saved,
// Migrate legacy boolean callNoiseSuppression -> 3-way mode:
// true => browser-native, false => off. New string values pass through.
callNoiseSuppression:
typeof saved.callNoiseSuppression === 'boolean'
? saved.callNoiseSuppression
? 'browser'
: 'off'
: (saved.callNoiseSuppression ?? defaultSettings.callNoiseSuppression),
// Coerce any retired/unknown persisted model back to the default working
// model; only whitelisted ids pass through.
callDenoiseModel:
saved.callDenoiseModel === 'rnnoise' ||
saved.callDenoiseModel === 'speex' ||
saved.callDenoiseModel === 'dtln' ||
saved.callDenoiseModel === 'deepfilternet'
? saved.callDenoiseModel
: defaultSettings.callDenoiseModel,
// Coerce any unknown persisted ringtone id back to the default.
ringtoneId:
saved.ringtoneId === 'classic' ||
saved.ringtoneId === 'chime' ||
saved.ringtoneId === 'soft' ||
saved.ringtoneId === 'retro' ||
saved.ringtoneId === 'none'
? saved.ringtoneId
: defaultSettings.ringtoneId,
// Coerce persisted target language to a curated, supported code; anything
// else (missing/wrong type/unknown) falls back to the default so the hook
// never targets a language the engine can't produce.
translateTargetLang:
typeof saved.translateTargetLang === 'string' &&
isSupportedTargetLang(saved.translateTargetLang)
? saved.translateTargetLang
: defaultSettings.translateTargetLang,
autoTranslate:
typeof saved.autoTranslate === 'boolean'
? saved.autoTranslate
: defaultSettings.autoTranslate,
// Coerce any unknown/retired persisted seasonal theme id back to 'auto'.
seasonalThemeOverride: SEASONAL_THEME_OVERRIDES.includes(
saved.seasonalThemeOverride as SeasonalThemeOverride,
)
? (saved.seasonalThemeOverride as SeasonalThemeOverride)
: defaultSettings.seasonalThemeOverride,
composerToolbarButtons: {
...DEFAULT_COMPOSER_TOOLBAR,
...(saved.composerToolbarButtons ?? {}),
order: normalizeComposerToolbarOrder(saved.composerToolbarButtons?.order),
},
};
} catch {
// We may be here precisely because localStorage access throws (blocked
// storage / private mode / sandboxed context). Removing the key must not be
// allowed to re-throw — getSettings() runs at module load, so an uncaught
// error here would crash the whole app on startup.
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* localStorage unavailable — nothing to clean up */
}
return defaultSettings;
}
};
// Gitea #42 — merge-on-write. `next` is always this tab's whole-object view
// (a shallow copy of what it last read, with one key changed — see
// useSetSetting), which is stale the moment another tab has written since. Re-
// reading the stored blob and reapplying only the keys that actually changed
// relative to `previous` (this tab's own pre-update snapshot) means a change
// this tab didn't make — e.g. a different setting toggled in another tab —
// survives instead of being clobbered by this tab's stale snapshot of it.
export const setSettings = (previous: Settings, next: Settings) => {
try {
const stored = getSettings();
const merged: Settings = { ...stored };
(Object.keys(next) as (keyof Settings)[]).forEach((key) => {
if (next[key] !== previous[key]) {
(merged as Record<keyof Settings, unknown>)[key] = next[key];
}
});
localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
} catch {
/* quota */
}
};
const baseSettings = atom<Settings>(getSettings());
// Gitea #42 — settingsAtom used to be a one-shot snapshot with no cross-tab
// sync at all (unlike atomWithLocalStorage.ts, which this mirrors). Without
// this, a tab left open never sees settings changed in another tab until it
// reloads, and (before the merge-on-write above) its next save would revert
// them.
baseSettings.onMount = (setAtom) => {
const handleStorageChange = (evt: StorageEvent) => {
if (evt.key !== STORAGE_KEY) return;
setAtom(getSettings());
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
};
export const settingsAtom = atom<Settings, [Settings], undefined>(
(get) => get(baseSettings),
(get, set, update) => {
const previous = get(baseSettings);
set(baseSettings, update);
setSettings(previous, update);
},
);