import { atom } from 'jotai'; 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'; // [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(COMPOSER_TOOLBAR_BUTTON_KEYS); const seen = new Set(); 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; glassmorphismSidebar: boolean; deafenKey: string; warnOnUnverifiedDevices: 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: | 'auto' | 'off' | 'halloween' | 'christmas' | 'newyear' | 'autumn' | 'aprilfools' | 'lunar' | 'valentines' | 'stpatricks' | 'earthday' | 'deepspace' | 'arcade'; } 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', nightLightEnabled: false, nightLightOpacity: 30, glassmorphismSidebar: false, deafenKey: 'KeyM', warnOnUnverifiedDevices: false, 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', }; export const getSettings = (): Settings => { try { const settings = localStorage.getItem(STORAGE_KEY); if (settings === null) return defaultSettings; const saved = JSON.parse(settings) as Partial; 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, 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; } }; export const setSettings = (settings: Settings) => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } catch { /* quota */ } }; const baseSettings = atom(getSettings()); export const settingsAtom = atom( (get) => get(baseSettings), (get, set, update) => { set(baseSettings, update); setSettings(update); }, );