Follow-up hardening from two review passes on the on-device translation feature: - Privacy (HIGH): the translation cache is decrypted message plaintext, but logout did not clear it (unlike the search index), leaving up to 300 cleartext bodies in localStorage on shared devices. Add clearTranslationCache() and call it from both logout paths (logoutClient and the server-forced SessionLoggedOut handler). - Edited messages (MEDIUM): the cache key was eventId:target with no content dependence, so an edit reused the pre-edit translation. Fold a content fingerprint into the key, and re-arm the auto-translate one-shot when the text changes. - Settings (LOW): coerce a persisted translateTargetLang to a supported curated code so the hook never targets a language the engine can't produce (previously only the UI clamped it). - Chinese (LOW): restore canonical BCP-47 case (zh-Hant / zh-Hans) at the Translator API boundary, since normalizeLang lowercases the script subtag for internal keys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
461 lines
13 KiB
TypeScript
461 lines
13 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';
|
||
// [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;
|
||
|
||
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';
|
||
|
||
// On-device message translation
|
||
translateTargetLang: string; // BCP-47 base code, default 'en'
|
||
autoTranslate: boolean; // auto-translate incoming foreign messages (opt-in)
|
||
}
|
||
|
||
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',
|
||
|
||
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',
|
||
|
||
translateTargetLang: 'en',
|
||
autoTranslate: 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,
|
||
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<Settings>(getSettings());
|
||
export const settingsAtom = atom<Settings, [Settings], undefined>(
|
||
(get) => get(baseSettings),
|
||
(get, set, update) => {
|
||
set(baseSettings, update);
|
||
setSettings(update);
|
||
},
|
||
);
|