feat: configurable keybindings for push-to-deafen and quick switcher

- Add deafenKey (default M) and quickSwitcherKey (default P) to settingsAtom
- Settings → Calls: Push to Deafen keybind tile using shared useKeyBind hook
- Settings → Keyboard Shortcuts: new section with Quick Room Switcher keybind
- Extract useKeyBind + keyLabel helpers to reduce duplication in Calls section
- CallControls reads deafenKey from settings (reactive, re-registers on change)
- ClientNonUIFeatures reads quickSwitcherKey from settings (same pattern)
- QuickSwitcher now toggles open/closed on repeat press (Ctrl+key again closes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 00:45:43 -04:00
parent 3cbc5112a7
commit 9d4679d260
4 changed files with 107 additions and 40 deletions
+94 -35
View File
@@ -900,6 +900,37 @@ function Privacy() {
);
}
function useKeyBind(setter: (code: string) => void) {
const [listening, setListening] = useState(false);
const listenerRef = useRef<((e: KeyboardEvent) => void) | null>(null);
useEffect(
() => () => {
if (listenerRef.current) window.removeEventListener('keydown', listenerRef.current, true);
},
[],
);
const startListening = useCallback(() => {
if (listening) return;
setListening(true);
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
if (e.code !== 'Escape') setter(e.code);
setListening(false);
window.removeEventListener('keydown', onKey, true);
listenerRef.current = null;
};
listenerRef.current = onKey;
window.addEventListener('keydown', onKey, true);
}, [listening, setter]);
return { listening, startListening };
}
const keyLabel = (code: string) =>
code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
function Calls() {
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(
@@ -908,37 +939,10 @@ function Calls() {
);
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
const [listeningForKey, setListeningForKey] = useState(false);
const keyListenerRef = useRef<((e: KeyboardEvent) => void) | null>(null);
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
useEffect(
() => () => {
if (keyListenerRef.current)
window.removeEventListener('keydown', keyListenerRef.current, true);
},
[],
);
const handleKeyBind = useCallback(() => {
if (listeningForKey) return;
setListeningForKey(true);
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
if (e.code === 'Escape') {
setListeningForKey(false);
} else {
setPttKey(e.code);
setListeningForKey(false);
}
window.removeEventListener('keydown', onKey, true);
keyListenerRef.current = null;
};
keyListenerRef.current = onKey;
window.addEventListener('keydown', onKey, true);
}, [listeningForKey, setPttKey]);
const keyLabel = (code: string) =>
code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
const pttBind = useKeyBind(setPttKey);
const deafenBind = useKeyBind(setDeafenKey);
return (
<Box direction="Column" gap="100">
@@ -976,23 +980,40 @@ function Calls() {
/>
{pttMode && (
<SettingTile
title="PTT Key Binding"
title="PTT Key"
description="Press a key to bind it as your push-to-talk key."
after={
<Button
size="300"
variant={listeningForKey ? 'Warning' : 'Secondary'}
fill={listeningForKey ? 'Solid' : 'Soft'}
variant={pttBind.listening ? 'Warning' : 'Secondary'}
fill={pttBind.listening ? 'Solid' : 'Soft'}
radii="300"
outlined
onClick={handleKeyBind}
onClick={pttBind.startListening}
style={{ minWidth: '90px' }}
>
<Text size="B300">{listeningForKey ? 'Press a key…' : keyLabel(pttKey)}</Text>
<Text size="B300">{pttBind.listening ? 'Press a key…' : keyLabel(pttKey)}</Text>
</Button>
}
/>
)}
<SettingTile
title="Push to Deafen"
description="Toggle speaker mute during a call. Press Escape to cancel rebind."
after={
<Button
size="300"
variant={deafenBind.listening ? 'Warning' : 'Secondary'}
fill={deafenBind.listening ? 'Solid' : 'Soft'}
radii="300"
outlined
onClick={deafenBind.startListening}
style={{ minWidth: '90px' }}
>
<Text size="B300">{deafenBind.listening ? 'Press a key…' : keyLabel(deafenKey)}</Text>
</Button>
}
/>
</SequenceCard>
</Box>
);
@@ -1295,6 +1316,43 @@ function Messages() {
);
}
function KeyboardShortcuts() {
const [quickSwitcherKey, setQuickSwitcherKey] = useSetting(settingsAtom, 'quickSwitcherKey');
const qsBind = useKeyBind(setQuickSwitcherKey);
return (
<Box direction="Column" gap="100">
<Text size="L400">Keyboard Shortcuts</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Quick Room Switcher"
description="Open the quick room switcher overlay. Ctrl/Cmd + this key."
after={
<Button
size="300"
variant={qsBind.listening ? 'Warning' : 'Secondary'}
fill={qsBind.listening ? 'Solid' : 'Soft'}
radii="300"
outlined
onClick={qsBind.startListening}
style={{ minWidth: '90px' }}
>
<Text size="B300">
{qsBind.listening ? 'Press a key…' : `Ctrl+${keyLabel(quickSwitcherKey)}`}
</Text>
</Button>
}
/>
</SequenceCard>
</Box>
);
}
type GeneralProps = {
requestClose: () => void;
};
@@ -1325,6 +1383,7 @@ export function General({ requestClose }: GeneralProps) {
<Messages />
<Privacy />
<Calls />
<KeyboardShortcuts />
</Box>
</PageContent>
</Scroll>