CI / Build & Quality Checks (push) Successful in 1m37s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 2m20s
PTT and deafen were DOM key handlers and only fired while Lotus (or the EC
iframe) had focus — alt-tab into a fullscreen game and the voice controls
stopped working.
- useCallHotkeys: while a call is joined and the new device-local
`globalCallHotkeys` setting is on, register {ptt, deafen} bindings with
the desktop (`set_global_hotkeys`, cleared on leave) and act on its
`lotus-global-hotkey` press/release events. Events are ignored while
`document.hasFocus()` so the DOM handlers keep owning the in-focus case
(editable-field and interactive-element checks, no double toggles). PTT
press engages the mic exactly like the DOM path (pttActive set before
unmute), release restores; the existing blur/focus release covers a hold
that spans a focus change. Same modifier rules as the DOM path
(`shouldActOnGlobalHotkey`, tested).
- Settings → Calls: "Hotkeys Work Outside the Window" toggle, Tauri only.
- settingsSync: `globalCallHotkeys` is device-local (never synced).
- LOTUS_FEATURES: desktop section entry.
Native side lands in cinny-desktop (src-tauri/src/native/hotkeys.rs).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
139 lines
5.1 KiB
TypeScript
139 lines
5.1 KiB
TypeScript
import { Settings } from '../state/settings';
|
|
|
|
/**
|
|
* [Gitea #104] Settings sync — pure helpers.
|
|
*
|
|
* Lotus settings live in `localStorage` per device. A syncable subset is
|
|
* mirrored to the account-data event `io.lotus.settings` on the user's own
|
|
* homeserver so a second device picks it up. Everything device-bound stays
|
|
* local (see DEVICE_LOCAL_KEYS). Conflicts are last-write-wins on the
|
|
* `updatedAt` stamp inside the event; a device that applied a remote snapshot
|
|
* remembers it so the resulting local change is not echoed straight back.
|
|
*/
|
|
|
|
export const SETTINGS_SYNC_EVENT = 'io.lotus.settings';
|
|
export const SETTINGS_SYNC_VERSION = 1;
|
|
|
|
export type SyncedSettingsContent = {
|
|
version: number;
|
|
updatedAt: number;
|
|
settings: Partial<Settings>;
|
|
};
|
|
|
|
// Keys that describe THIS device (display, hardware, bandwidth, CPU budget,
|
|
// transient UI state, per-device permissions) rather than the user's
|
|
// preferences. Never written to, or read from, the synced event. The sync
|
|
// toggle itself is local too, so turning it off on one device is not undone by
|
|
// another.
|
|
export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>([
|
|
'settingsSync',
|
|
'globalCallHotkeys',
|
|
'pageZoom',
|
|
'mediaAutoLoad',
|
|
'pauseAnimations',
|
|
'glassmorphismSidebar',
|
|
'isPeopleDrawer',
|
|
'memberSortFilterIndex',
|
|
'presenceStatus',
|
|
'showNotifications',
|
|
'developerTools',
|
|
'pttMode',
|
|
'callNoiseSuppression',
|
|
'callDenoiseModel',
|
|
'callDenoiseNativeNS',
|
|
'callDenoiseGate',
|
|
'callDenoiseGateThreshold',
|
|
'callAudioBitrate',
|
|
'screenshareBitrate',
|
|
'screenshareFramerate',
|
|
'ringtoneVolume',
|
|
'soundboardVolume',
|
|
'cameraOnJoin',
|
|
]);
|
|
|
|
/** The subset of `settings` that is synced (deep-copied so callers can't alias). */
|
|
export const pickSyncable = (settings: Settings): Partial<Settings> => {
|
|
const out: Partial<Settings> = {};
|
|
(Object.keys(settings) as (keyof Settings)[]).forEach((key) => {
|
|
if (DEVICE_LOCAL_KEYS.has(key)) return;
|
|
const value = settings[key];
|
|
if (value === undefined) return;
|
|
(out as Record<string, unknown>)[key] =
|
|
typeof value === 'object' && value !== null ? JSON.parse(JSON.stringify(value)) : value;
|
|
});
|
|
return out;
|
|
};
|
|
|
|
/** Structural equality of two syncable subsets (key order independent). */
|
|
export const syncableEqual = (a: Partial<Settings>, b: Partial<Settings>): boolean => {
|
|
const ka = Object.keys(a).sort();
|
|
const kb = Object.keys(b).sort();
|
|
if (ka.length !== kb.length) return false;
|
|
for (let i = 0; i < ka.length; i += 1) {
|
|
if (ka[i] !== kb[i]) return false;
|
|
const va = (a as Record<string, unknown>)[ka[i]];
|
|
const vb = (b as Record<string, unknown>)[kb[i]];
|
|
if (JSON.stringify(va) !== JSON.stringify(vb)) return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
/** Type guard for what comes back from account data (any client, any version). */
|
|
export const isSyncedSettingsContent = (content: unknown): content is SyncedSettingsContent => {
|
|
if (typeof content !== 'object' || content === null) return false;
|
|
const c = content as Record<string, unknown>;
|
|
return (
|
|
typeof c.version === 'number' &&
|
|
typeof c.updatedAt === 'number' &&
|
|
Number.isFinite(c.updatedAt) &&
|
|
typeof c.settings === 'object' &&
|
|
c.settings !== null &&
|
|
!Array.isArray(c.settings)
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Overlay a remote snapshot onto the local settings. Only syncable keys the
|
|
* local schema knows about are taken (so a newer client's extra keys, or a
|
|
* device-local key smuggled in by a buggy writer, are ignored). Values are
|
|
* copied verbatim; the caller runs the result through the normal
|
|
* `getSettings()` coercion by persisting it, which is what validates enums.
|
|
*/
|
|
export const mergeRemoteSettings = (local: Settings, remote: SyncedSettingsContent): Settings => {
|
|
const merged: Settings = { ...local };
|
|
(Object.keys(remote.settings) as (keyof Settings)[]).forEach((key) => {
|
|
if (DEVICE_LOCAL_KEYS.has(key)) return;
|
|
if (!(key in local)) return;
|
|
const value = remote.settings[key];
|
|
if (value === undefined) return;
|
|
// Shape guard: a value of a different JS type than this build stores for
|
|
// the key (e.g. a renamed enum that became an object) is skipped; enum
|
|
// *values* are validated by getSettings() coercion on the next load.
|
|
const current = local[key];
|
|
if (current !== undefined && current !== null && typeof value !== typeof current) return;
|
|
if (Array.isArray(current) !== Array.isArray(value)) return;
|
|
(merged as unknown as Record<string, unknown>)[key] = value;
|
|
});
|
|
return merged;
|
|
};
|
|
|
|
export const buildSyncedContent = (
|
|
settings: Settings,
|
|
updatedAt: number = Date.now(),
|
|
): SyncedSettingsContent => ({
|
|
version: SETTINGS_SYNC_VERSION,
|
|
updatedAt,
|
|
settings: pickSyncable(settings),
|
|
});
|
|
|
|
/**
|
|
* Should a remote snapshot be applied over what this device last synced?
|
|
* `lastSyncedAt` is the stamp of the snapshot this device last pushed or
|
|
* applied; anything older or equal is our own echo (or a stale write that lost
|
|
* the race) and is ignored.
|
|
*/
|
|
export const shouldApplyRemote = (
|
|
remote: SyncedSettingsContent,
|
|
lastSyncedAt: number | null,
|
|
): boolean => lastSyncedAt === null || remote.updatedAt > lastSyncedAt;
|