Files
cinny/src/app/state/settings.ts
T
jaredandClaude Opus 5 30fd22a5c5
CI / Build & Quality Checks (push) Successful in 1m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 2m0s
fix(calls): Push to Deafen can be switched off, and a typable deafen key can no longer fire while typing
Root cause of the 'went deaf while typing' reports: the deafen key (default
M) and Cinny's type-anywhere-to-focus-the-composer both listen on window,
so the first letter of a message typed after clicking the timeline toggled
deafen and was swallowed (reproduced: typing 'mom' → deafened, composer
shows 'om'). Now: (1) Settings → Calls → Push to Deafen has an on/off
switch (deafenHotkey); (2) a letter/digit/Space deafen key only toggles
where no composer is on screen — typing wins; (3) such keys are never
bound system-wide on desktop — only F-keys, numpad and the lock/navigation
cluster qualify (isSafeGlobalToggleKey) — so 'm' typed in another app
can't deafen you. Verified in live calls: M still toggles in the call
view, is ignored on a chat screen, switch off disables it entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 23:15:00 -04:00

544 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;
/** Master switch for the push-to-deafen key (both in-window and system-wide). */
deafenHotkey: boolean;
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;
// [cinny-desktop #2] Desktop only: keep PTT/deafen working while another app
// (a game) has focus, via a non-consuming key poll. Device-local.
globalCallHotkeys: 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',
deafenHotkey: true,
warnOnUnverifiedDevices: false,
stripTrackingParams: true,
settingsSync: true,
globalCallHotkeys: 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);
},
);