utils/haptics.ts: tick('ptt-on' | 'ptt-off' | 'reaction') → 10/10/8 ms
navigator.vibrate, a no-op without the API (iOS), when the system prefers
reduced motion, or when the new Settings → Calls "Haptic Feedback" switch
(default on, only rendered where the API exists) is off. PTT is observed
once through pttActiveAtom so the keyboard, global-hotkey and on-screen
paths all tick; reactions tick where the reaction event is sent in the
room and thread timelines (quick bar, hover bar, sheet and emoji board
all funnel there).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
557 lines
17 KiB
TypeScript
557 lines
17 KiB
TypeScript
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 #125] Vibration ticks on PTT press/release and reactions (Android only).
|
||
hapticFeedback: 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;
|
||
/** [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; // 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,
|
||
stripImageMetadata: true,
|
||
callRejoinAfterRestart: 'ask',
|
||
hapticFeedback: true,
|
||
|
||
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);
|
||
},
|
||
);
|