Files
cinny/src/app/state/settings.ts
T
jared 4a87588435 feat(calls): selectable incoming-call ringtone (#4)
Adds a ringtoneId setting (classic | chime | soft | retro | none) so the
incoming-call ring is no longer hardcoded to call.ogg. The three synth
styles are generated in-browser via a new utils/ringtones.ts module
(mirroring the existing callSounds.ts WebAudio pattern), so no new binary
assets are bundled; 'classic' keeps the existing call.ogg clip and 'none'
is a silent, visual-only incoming-call UI.

- ringtones.ts: startRingtone() loops until stopped; previewRingtone()
  plays a single non-looping preview and auto-cancels the prior preview.
- IncomingCall: ring driven by the setting; <audio> element removed.
- Settings > Calls: Ringtone selector with on-select preview, beside the
  existing Ringtone Volume slider.
- settings.ts: persisted value whitelisted back to a known id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:25:32 -04:00

318 lines
8.0 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';
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 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,
}
export interface ComposerToolbarSettings {
showFormat: boolean;
showEmoji: boolean;
showSticker: boolean;
showGif: boolean;
showLocation: boolean;
showPoll: boolean;
showVoice: boolean;
showSchedule: boolean;
}
export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = {
showFormat: true,
showEmoji: true,
showSticker: true,
showGif: true,
showLocation: true,
showPoll: true,
showVoice: true,
showSchedule: true,
};
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;
showHiddenEvents: 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;
fontFamily: 'system' | 'inter' | 'jetbrains-mono' | 'fira-code';
afkAutoMute: boolean;
afkTimeoutMinutes: number;
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
ringtoneId: RingtoneId;
ringtoneVolume: number; // 0100
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,
showHiddenEvents: 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,
callNoiseSuppression: 'browser',
callDenoiseModel: 'rnnoise',
callDenoiseNativeNS: true,
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: '',
fontFamily: 'inter',
afkAutoMute: false,
afkTimeoutMinutes: 10,
callJoinLeaveSound: 'chime',
ringtoneId: 'classic',
ringtoneVolume: 70,
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<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,
composerToolbarButtons: {
...DEFAULT_COMPOSER_TOOLBAR,
...(saved.composerToolbarButtons ?? {}),
},
};
} catch {
localStorage.removeItem(STORAGE_KEY);
return defaultSettings;
}
};
export const setSettings = (settings: Settings) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {
/* quota */
}
};
const baseSettings = atom<Settings>(getSettings());
export const settingsAtom = atom<Settings, [Settings], undefined>(
(get) => get(baseSettings),
(get, set, update) => {
set(baseSettings, update);
setSettings(update);
},
);