feat: configurable keybindings for push-to-deafen and quick switcher
CI / Build & Quality Checks (push) Successful in 10m43s

- 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 2d59be9dd3
commit 6251d148d0
4 changed files with 107 additions and 40 deletions
+3 -2
View File
@@ -83,6 +83,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
}, [shareConfirm]); }, [shareConfirm]);
const [pttMode] = useSetting(settingsAtom, 'pttMode'); const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey'); const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [pttActive, setPttActive] = useState(false); const [pttActive, setPttActive] = useState(false);
@@ -209,7 +210,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
return false; return false;
}; };
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== 'KeyM') return; if (e.code !== deafenKey) return;
if (e.repeat) return; if (e.repeat) return;
if (isEditable(e.target as HTMLElement)) return; if (isEditable(e.target as HTMLElement)) return;
e.preventDefault(); e.preventDefault();
@@ -217,7 +218,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
}; };
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown);
}, [callEmbed]); }, [callEmbed, deafenKey]);
const [hangupState, hangup] = useAsyncCallback( const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed]), 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() { function Calls() {
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin'); const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting( const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(
@@ -908,37 +939,10 @@ function Calls() {
); );
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode'); const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey'); const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
const [listeningForKey, setListeningForKey] = useState(false); const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
const keyListenerRef = useRef<((e: KeyboardEvent) => void) | null>(null);
useEffect( const pttBind = useKeyBind(setPttKey);
() => () => { const deafenBind = useKeyBind(setDeafenKey);
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', '');
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
@@ -976,23 +980,40 @@ function Calls() {
/> />
{pttMode && ( {pttMode && (
<SettingTile <SettingTile
title="PTT Key Binding" title="PTT Key"
description="Press a key to bind it as your push-to-talk key." description="Press a key to bind it as your push-to-talk key."
after={ after={
<Button <Button
size="300" size="300"
variant={listeningForKey ? 'Warning' : 'Secondary'} variant={pttBind.listening ? 'Warning' : 'Secondary'}
fill={listeningForKey ? 'Solid' : 'Soft'} fill={pttBind.listening ? 'Solid' : 'Soft'}
radii="300" radii="300"
outlined outlined
onClick={handleKeyBind} onClick={pttBind.startListening}
style={{ minWidth: '90px' }} 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> </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> </SequenceCard>
</Box> </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 = { type GeneralProps = {
requestClose: () => void; requestClose: () => void;
}; };
@@ -1325,6 +1383,7 @@ export function General({ requestClose }: GeneralProps) {
<Messages /> <Messages />
<Privacy /> <Privacy />
<Calls /> <Calls />
<KeyboardShortcuts />
</Box> </Box>
</PageContent> </PageContent>
</Scroll> </Scroll>
+4 -3
View File
@@ -264,19 +264,20 @@ function MessageNotifications() {
function QuickSwitcherFeature() { function QuickSwitcherFeature() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [quickSwitcherKey] = useSetting(settingsAtom, 'quickSwitcherKey');
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'p') { if ((e.ctrlKey || e.metaKey) && e.code === quickSwitcherKey) {
e.preventDefault(); e.preventDefault();
setOpen(true); setOpen((prev) => !prev);
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', handleKeyDown);
}; };
}, []); }, [quickSwitcherKey]);
if (!open) return null; if (!open) return null;
return <QuickSwitcher onClose={() => setOpen(false)} />; return <QuickSwitcher onClose={() => setOpen(false)} />;
+6
View File
@@ -80,6 +80,9 @@ export interface Settings {
nightLightEnabled: boolean; nightLightEnabled: boolean;
nightLightOpacity: number; nightLightOpacity: number;
deafenKey: string;
quickSwitcherKey: string;
} }
const defaultSettings: Settings = { const defaultSettings: Settings = {
@@ -129,6 +132,9 @@ const defaultSettings: Settings = {
nightLightEnabled: false, nightLightEnabled: false,
nightLightOpacity: 30, nightLightOpacity: 30,
deafenKey: 'KeyM',
quickSwitcherKey: 'KeyP',
}; };
export const getSettings = (): Settings => { export const getSettings = (): Settings => {