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:
@@ -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]),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)} />;
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
Reference in New Issue
Block a user