From 30fd22a5c560c9cd25b56069968bf95a91dd4ecf Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 18 Sep 2026 23:15:00 -0400 Subject: [PATCH] fix(calls): Push to Deafen can be switched off, and a typable deafen key can no longer fire while typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the 'went deaf while typing' reports: the deafen key (default M) and Cinny's type-anywhere-to-focus-the-composer both listen on window, so the first letter of a message typed after clicking the timeline toggled deafen and was swallowed (reproduced: typing 'mom' → deafened, composer shows 'om'). Now: (1) Settings → Calls → Push to Deafen has an on/off switch (deafenHotkey); (2) a letter/digit/Space deafen key only toggles where no composer is on screen — typing wins; (3) such keys are never bound system-wide on desktop — only F-keys, numpad and the lock/navigation cluster qualify (isSafeGlobalToggleKey) — so 'm' typed in another app can't deafen you. Verified in live calls: M still toggles in the call view, is ignored on a chat screen, switch off disables it entirely. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- LOTUS_FEATURES.md | 4 ++ src/app/features/settings/general/General.tsx | 42 +++++++++++++----- src/app/hooks/useCallHotkeys.test.ts | 40 ++++++++++++++++- src/app/hooks/useCallHotkeys.ts | 44 ++++++++++++++++--- src/app/state/settings.ts | 3 ++ 5 files changed, 115 insertions(+), 18 deletions(-) diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 607857e21..90056fb11 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -1179,6 +1179,10 @@ Links you paste, send, edit, or merely _see_ lose ad/analytics identifiers — ` The syncable subset of Lotus settings (theme, composer toolbar order, notification/quiet-hour preferences, call keys, privacy toggles, …) is mirrored to the `io.lotus.settings` account-data event on the user's own homeserver and applied on every other device. Device-bound keys stay local (`DEVICE_LOCAL_KEYS` in `src/app/utils/settingsSync.ts`: page zoom, media auto-load, animation pause, glassmorphism, noise-suppression tier/model, bitrates, volumes, notification permission, developer tools, PTT mode, camera-on-join, drawer state). Conflicts are last-write-wins on an `updatedAt` stamp forced monotonic per device; a per-account `lastSyncedAt` marker in localStorage stops a device from echoing a snapshot it just applied. **Settings → General → Sync** has the toggle (itself device-local), **Push now** (make this device win everywhere) and **Clear synced copy**. Hook: `src/app/hooks/useSettingsSync.ts`, mounted from `ClientNonUIFeatures`. +### Push to Deafen: off switch + typing-safe + +**Settings → Calls → Push to Deafen** now has a switch (off = no key toggles deafen; the call-bar headphone button remains). Root cause of the "I went deaf while typing" reports: Cinny's type-anywhere-to-focus-the-composer and the deafen key both listen on `window`, so with the default `M` the first letter of a message typed after clicking the timeline toggled deafen and was swallowed (`mom` → `om`). Rules now: a typable key (letter/digit/Space/…) only toggles deafen in the call view — on any screen with a composer, typing wins; and such keys are never bound system-wide on desktop (`isSafeGlobalToggleKey`: F-keys, numpad, lock/navigation cluster qualify), so the letter `m` typed in Discord or a game can't deafen you either. The tile explains this and suggests an F-key/Numpad key for an everywhere binding. + ### Forwarded messages show their provenance A forwarded message used to arrive as if the forwarder had written it. `buildForwardContent` now stamps `io.lotus.forwarded` (`sender`, `origin_server_ts`, `room_id`, `event_id`; forwarding a forward keeps the _original_ stamp) and the timeline (main + threads) renders a reply-style line above the message — **↪ Forwarded from bob in Other Room · 9:05 PM** — which is a button that jumps to the original when you are in the source room; if you are not, it shows only the sender and time (the source room's name is deliberately not shared). Other Matrix clients ignore the key and see the plain content. Component: `src/app/components/message/ForwardedHeader.tsx`. diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index d92015006..b26c73c1f 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -112,6 +112,7 @@ import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine import { SequenceCardStyle } from '../styles.css'; import { useTauriUpdater } from '../../../hooks/useTauriUpdater'; import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri'; +import { isSafeGlobalToggleKey } from '../../../hooks/useCallHotkeys'; import { customWindowChromeAtom } from '../../../state/customWindowChrome'; import { useDateFormatItems } from '../../../hooks/useDateFormat'; import { useReducedMotion } from '../../../hooks/useReducedMotion'; @@ -1644,6 +1645,7 @@ function Calls() { const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode'); const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey'); const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey'); + const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey'); const [globalCallHotkeys, setGlobalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys'); const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute'); const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes'); @@ -1962,20 +1964,36 @@ function Calls() { - {deafenBind.listening ? 'Press a key…' : keyLabel(deafenKey)} - + + + {deafenHotkey && ( + + )} + } /> {isTauriEnv() && ( diff --git a/src/app/hooks/useCallHotkeys.test.ts b/src/app/hooks/useCallHotkeys.test.ts index 6a2912a7b..25aa44f94 100644 --- a/src/app/hooks/useCallHotkeys.test.ts +++ b/src/app/hooks/useCallHotkeys.test.ts @@ -1,6 +1,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { isDeafenKeyDown, isPttKeyDown, shouldActOnGlobalHotkey } from './useCallHotkeys'; +import { + isDeafenKeyDown, + isPttKeyDown, + isSafeGlobalToggleKey, + shouldActOnGlobalHotkey, +} from './useCallHotkeys'; const key = ( code: string, @@ -50,3 +55,36 @@ test('shouldActOnGlobalHotkey ignores Ctrl/Alt/Meta chords', () => { assert.equal(shouldActOnGlobalHotkey(ev({ alt: true })), false); assert.equal(shouldActOnGlobalHotkey(ev({ meta: true })), false); }); + +test('isSafeGlobalToggleKey: only keys nobody types prose with may toggle deafen system-wide', () => { + // typed-in-other-apps keys — never global + for (const code of [ + 'KeyM', + 'KeyA', + 'Digit1', + 'Space', + 'Enter', + 'Backspace', + 'Tab', + 'Comma', + 'Slash', + 'ShiftLeft', + 'Escape', + ]) { + assert.equal(isSafeGlobalToggleKey(code), false, code); + } + // dedicated keys — fine + for (const code of [ + 'F5', + 'F13', + 'Numpad0', + 'NumpadAdd', + 'ScrollLock', + 'Pause', + 'Insert', + 'PageDown', + 'CapsLock', + ]) { + assert.equal(isSafeGlobalToggleKey(code), true, code); + } +}); diff --git a/src/app/hooks/useCallHotkeys.ts b/src/app/hooks/useCallHotkeys.ts index 3900822ae..302c6548f 100644 --- a/src/app/hooks/useCallHotkeys.ts +++ b/src/app/hooks/useCallHotkeys.ts @@ -57,6 +57,22 @@ export const isPttKeyDown = (e: KeyLike, pttKey: string): boolean => export const isDeafenKeyDown = (e: KeyLike, deafenKey: string): boolean => e.code === deafenKey && !e.repeat && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey; +/** + * Whether a key may be watched SYSTEM-WIDE as a toggle (deafen). The desktop + * poll cannot tell "typed the letter m in Discord" from "pressed the deafen + * key", so a bare letter/digit/Space/Enter bound globally deafens people at + * random while they type in other apps (the "why did I go deaf?" reports). + * Only keys nobody types prose with qualify: F-keys, the numpad, and the + * lock/navigation cluster. Everything else stays in-window only, where the + * editable-field check protects typing. + */ +export const isSafeGlobalToggleKey = (code: string): boolean => + /^F\d{1,2}$/.test(code) || + /^Numpad/.test(code) || + /^(ScrollLock|Pause|Insert|Home|End|PageUp|PageDown|CapsLock|NumLock|PrintScreen|ContextMenu)$/.test( + code, + ); + // BUG-7: use ownerDocument.body so isEditable works inside the EC iframe const isEditable = (el: HTMLElement): boolean => { const tag = el.tagName; @@ -127,6 +143,7 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean const [pttMode] = useSetting(settingsAtom, 'pttMode'); const [pttKey] = useSetting(settingsAtom, 'pttKey'); const [deafenKey] = useSetting(settingsAtom, 'deafenKey'); + const [deafenHotkey] = useSetting(settingsAtom, 'deafenHotkey'); const [globalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys'); const { microphone } = useCallControlState(embed?.control); const setPttActive = useSetAtom(pttActiveAtom); @@ -225,13 +242,17 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean if (!isTauri() || !embed || !globalCallHotkeys) return undefined; const bindings = [ ...(pttMode ? [{ id: 'ptt', code: pttKey }] : []), - { id: 'deafen', code: deafenKey }, + // A bare letter/digit deafen key is never watched system-wide (see + // isSafeGlobalToggleKey) — it would fire while typing in other apps. + ...(deafenHotkey && isSafeGlobalToggleKey(deafenKey) + ? [{ id: 'deafen', code: deafenKey }] + : []), ]; invokeTauri('set_global_hotkeys', { bindings }); return () => { invokeTauri('set_global_hotkeys', { bindings: [] }); }; - }, [embed, globalCallHotkeys, pttMode, pttKey, deafenKey]); + }, [embed, globalCallHotkeys, pttMode, pttKey, deafenKey, deafenHotkey]); useTauriEvent('lotus-global-hotkey', (detail) => { const current = embedRef.current; if (!current || !globalCallHotkeys) return; @@ -258,10 +279,23 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean }); useEffect(() => { - if (!embed) return undefined; + if (!embed || !deafenHotkey) return undefined; const onKeyDown = (e: KeyboardEvent) => { if (!isDeafenKeyDown(e, deafenKey)) return; - if (isEditable(e.target as HTMLElement)) return; + const target = e.target as HTMLElement; + if (isEditable(target)) return; + // Typing always wins over a typable deafen key. Cinny's "type anywhere + // to focus the composer" (RoomView) also listens on window, so with the + // default `M` the FIRST letter of a message typed after clicking the + // timeline used to toggle deafen — and get swallowed ("mom" → "om"). If a + // composer is on this screen and the key is one people type with, let the + // composer have it; dedicated keys (F-keys, numpad, …) still toggle. + if ( + !isSafeGlobalToggleKey(deafenKey) && + target.ownerDocument.querySelector('[data-slate-editor]') + ) { + return; + } e.preventDefault(); embed.control.toggleSound(); }; @@ -276,5 +310,5 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean window.removeEventListener('keydown', onKeyDown); unbindIframe(); }; - }, [embed, deafenKey]); + }, [embed, deafenKey, deafenHotkey]); } diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 12beb2997..aefee0179 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -260,6 +260,8 @@ export interface Settings { glassmorphismSidebar: boolean; deafenKey: string; + /** Master switch for the push-to-deafen key (both in-window and system-wide). */ + deafenHotkey: boolean; warnOnUnverifiedDevices: boolean; @@ -381,6 +383,7 @@ const defaultSettings: Settings = { glassmorphismSidebar: false, deafenKey: 'KeyM', + deafenHotkey: true, warnOnUnverifiedDevices: false,