fix(calls): Push to Deafen can be switched off, and a typable deafen key can no longer fire while typing
CI / Build & Quality Checks (push) Successful in 1m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 2m0s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-18 23:15:00 -04:00
co-authored by Claude Opus 5
parent da2d7a7d4f
commit 30fd22a5c5
5 changed files with 115 additions and 18 deletions
+39 -1
View File
@@ -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);
}
});
+39 -5
View File
@@ -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<GlobalHotkeyEvent>('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]);
}