Files
cinny/src/app/state/settings.ts
T
jaredandClaude Opus 5 dc0d524989 feat(calls): rejoin the voice room after a crash, update or reload (#118)
While joined, a device-local record {roomId, deviceId, joinedAt,
lastSeen, mic, video} is written and refreshed every 30 s; a deliberate
hangup (HangupCall/Close) and logout clear it. On the next start, once
the first sync is in and the room's MatrixRTC session has reported its
members (waited for up to 10 s — it fills a moment after sync), a fresh
record (< 10 min) for this device with someone still in the call yields
either a sticky "Rejoin voice?" toast (tap to rejoin) or an automatic
rejoin, per the new Settings → Calls → After a Restart (Ask / Rejoin
automatically / Do nothing; default Ask). Skipped when our own membership
is already live from another device, or only our stale one is left.
Mic/camera state comes from the record (camera still gated by
cameraOnJoin); PTT is applied by startCall as usual.

Pure decision in utils/callRejoin.ts with tests. Verified headless:
reload mid-call → toast → tap → back in the call; hangup → reload → no
toast; auto mode → back in without a prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:50:57 -04:00

554 lines
17 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 #109] Drop EXIF/XMP/IPTC (GPS, camera, timestamp) from JPEG/PNG/WebP
// uploads without re-encoding. Default on.
stripImageMetadata: boolean;
// [Gitea #118] After a crash/update/reload while in a voice room: ask, rejoin, or nothing.
callRejoinAfterRestart: 'ask' | 'auto' | 'off';
// [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;
/** [Gitea #117] 'You're muted' nudge when talking into a muted mic. */
mutedTalkWarning: boolean;
afkTimeoutMinutes: number;
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
ringtoneId: RingtoneId;
ringtoneVolume: number; // 0100
// [P5-31] Call quality controls
callAudioBitrate: CallAudioBitrate;
screenshareBitrate: ScreenshareBitrate;
screenshareFramerate: ScreenshareFramerate;
// [P5-15] In-call soundboard
soundboardEnabled: boolean;
soundboardVolume: number; // 0100
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,
stripImageMetadata: true,
callRejoinAfterRestart: 'ask',
settingsSync: true,
globalCallHotkeys: true,
pauseAnimations: false,
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
mentionHighlightColor: '',
customAccentColor: '',
fontFamily: 'inter',
afkAutoMute: false,
mutedTalkWarning: true,
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);
},
);