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
+3 -2
View File
@@ -83,6 +83,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
}, [shareConfirm]);
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [pttActive, setPttActive] = useState(false);
@@ -209,7 +210,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
return false;
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== 'KeyM') return;
if (e.code !== deafenKey) return;
if (e.repeat) return;
if (isEditable(e.target as HTMLElement)) return;
e.preventDefault();
@@ -217,7 +218,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [callEmbed]);
}, [callEmbed, deafenKey]);
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed]),
+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>
+4 -3
View File
@@ -264,19 +264,20 @@ function MessageNotifications() {
function QuickSwitcherFeature() {
const [open, setOpen] = useState(false);
const [quickSwitcherKey] = useSetting(settingsAtom, 'quickSwitcherKey');
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
if ((e.ctrlKey || e.metaKey) && e.code === quickSwitcherKey) {
e.preventDefault();
setOpen(true);
setOpen((prev) => !prev);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
}, [quickSwitcherKey]);
if (!open) return null;
return <QuickSwitcher onClose={() => setOpen(false)} />;
+6
View File
@@ -80,6 +80,9 @@ export interface Settings {
nightLightEnabled: boolean;
nightLightOpacity: number;
deafenKey: string;
quickSwitcherKey: string;
}
const defaultSettings: Settings = {
@@ -129,6 +132,9 @@ const defaultSettings: Settings = {
nightLightEnabled: false,
nightLightOpacity: 30,
deafenKey: 'KeyM',
quickSwitcherKey: 'KeyP',
};
export const getSettings = (): Settings => {