fix(calls): hotkey rebind denylist; ignore modifiers; keep Space on buttons

- useKeyBind refuses Tab/Enter/arrows/Home/End/Page*/Escape and bare
  modifier codes, and refuses a code equal to the other call key, with an
  inline message (isBindableCallKey, unit-tested).
- PTT and deafen handlers ignore events with Ctrl/Alt/Meta held (deafen
  also Shift), so Cmd+M / Ctrl+M no longer toggle deafen.
- PTT only preventDefault()s when the target is not an interactive
  control, so Space still activates focused buttons during a call.

Fixes #23

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 14:48:34 -04:00
co-authored by Claude Opus 5
parent 6e4c4bc795
commit 2344c8273e
4 changed files with 129 additions and 9 deletions
+24 -8
View File
@@ -117,6 +117,7 @@ import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { isBindableCallKey } from '../../../utils/callKeybind';
/**
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
@@ -1474,8 +1475,12 @@ function Privacy() {
);
}
function useKeyBind(setter: (code: string) => void) {
// [Gitea #23] Denylist navigation-critical/modifier codes and reject a code that
// collides with the other call key (`otherKey`), so a rebind can never trap
// keyboard focus in-call or silently double-bind PTT and deafen to the same key.
function useKeyBind(setter: (code: string) => void, otherKey?: string) {
const [listening, setListening] = useState(false);
const [error, setError] = useState<string | null>(null);
const listenerRef = useRef<((e: KeyboardEvent) => void) | null>(null);
useEffect(
@@ -1487,19 +1492,28 @@ function useKeyBind(setter: (code: string) => void) {
const startListening = useCallback(() => {
if (listening) return;
setError(null);
setListening(true);
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
if (e.code !== 'Escape') setter(e.code);
if (e.code === 'Escape') {
// Escape always cancels the rebind without changing the key.
} else if (!isBindableCallKey(e.code)) {
setError('That key can’t be bound — it’s needed for keyboard navigation.');
} else if (otherKey && e.code === otherKey) {
setError('That key is already bound to the other call shortcut.');
} else {
setter(e.code);
}
setListening(false);
window.removeEventListener('keydown', onKey, true);
listenerRef.current = null;
};
listenerRef.current = onKey;
window.addEventListener('keydown', onKey, true);
}, [listening, setter]);
}, [listening, setter, otherKey]);
return { listening, startListening };
return { listening, startListening, error };
}
const keyLabel = (code: string) =>
@@ -1556,8 +1570,8 @@ function Calls() {
previewRingtone(value, Math.max(0, Math.min(1, ringtoneVolume / 100)));
};
const pttBind = useKeyBind(setPttKey);
const deafenBind = useKeyBind(setDeafenKey);
const pttBind = useKeyBind(setPttKey, deafenKey);
const deafenBind = useKeyBind(setDeafenKey, pttKey);
const mlSupported = isMLDenoiseSupported();
const selectedDenoiseModel = DENOISE_MODELS.find((m) => m.id === callDenoiseModel);
@@ -1823,7 +1837,7 @@ function Calls() {
{pttMode && (
<SettingTile
title="PTT Key"
description="Press a key to bind it as your push-to-talk key."
description={pttBind.error ?? 'Press a key to bind it as your push-to-talk key.'}
after={
<Button
size="300"
@@ -1841,7 +1855,9 @@ function Calls() {
)}
<SettingTile
title="Push to Deafen"
description="Toggle speaker mute during a call. Press Escape to cancel rebind."
description={
deafenBind.error ?? 'Toggle speaker mute during a call. Press Escape to cancel rebind.'
}
after={
<Button
size="300"