Files
backspace/packages/web/src/hooks/useKeybinds.ts
T
Jannis Braun 100ac15939 fix: deduplicate keybind actions when native hook and web fallback both fire
When the app is focused and the native uiohook hook is active, both the
IPC bridge and the web fallback capture the same keypress, dispatching
the toggle action twice (~0-20ms apart). This double-toggles mute/deafen
back to the original state.

Add a 100ms dedup window in dispatchKeybindAction to suppress the second
dispatch. Safe for human input (key repeat starts at ~500ms).
2026-03-22 23:55:59 +01:00

243 lines
8.6 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { useKeybindStore, Keybind } from '../stores/keybindStore';
import { useVoiceStore } from '../stores/voiceStore';
import { isElectron } from '../platform/platform';
import { handleMuteAction, handleDeafenAction, handleCameraAction, handleScreenShareAction, handleDisconnectAction } from '../utils/voiceActions';
import { broadcastVoiceStatus } from '../utils/voice';
import { getChannelOrigin, getMyUserIdForOrigin, useSpaceStore } from '../stores/spaceStore';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Resolve space-mute/deafen state for the current voice session */
function getSpaceEnforcementState(): { isSpaceMuted: boolean; isSpaceDeafened: boolean } {
const vs = useVoiceStore.getState();
const { currentVoiceChannelId, spaceMutedUserIds, spaceDeafenedUserIds } = vs;
if (!currentVoiceChannelId) return { isSpaceMuted: false, isSpaceDeafened: false };
const origin = getChannelOrigin(currentVoiceChannelId);
const myId = getMyUserIdForOrigin(origin);
const spaceId = useSpaceStore.getState().channelToSpaceMap.get(currentVoiceChannelId);
const spaceKey = spaceId && myId ? `${spaceId}:${myId}` : '';
return {
isSpaceMuted: spaceMutedUserIds.has(spaceKey),
isSpaceDeafened: spaceDeafenedUserIds.has(spaceKey),
};
}
/** Dedup rapid duplicate dispatches (native hook + web fallback both fire when focused) */
const lastDispatch: Record<string, number> = {};
/** Dispatch a keybind action to the appropriate voice handler */
function dispatchKeybindAction(actionId: string, pressed: boolean): void {
const now = Date.now();
const key = `${actionId}:${pressed}`;
if (now - (lastDispatch[key] || 0) < 100) return;
lastDispatch[key] = now;
const voice = useVoiceStore.getState();
if (!voice.currentVoiceChannelId) return;
const { isSpaceMuted, isSpaceDeafened } = getSpaceEnforcementState();
switch (actionId) {
case 'toggleMute':
if (pressed) handleMuteAction(isSpaceMuted, isSpaceDeafened);
break;
case 'toggleDeafen':
if (pressed) handleDeafenAction(isSpaceDeafened);
break;
case 'toggleCamera':
if (pressed) handleCameraAction();
break;
case 'toggleScreenShare':
if (pressed) handleScreenShareAction();
break;
case 'disconnect':
if (pressed) handleDisconnectAction();
break;
case 'pushToTalk':
voice.setMuted(!pressed); // pressed=true → unmute, pressed=false → mute
broadcastVoiceStatus();
break;
}
}
// ---------------------------------------------------------------------------
// Modifier key detection (for web fallback input suppression)
// ---------------------------------------------------------------------------
function isCharacterKey(e: KeyboardEvent): boolean {
return e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey;
}
function isInputElement(target: EventTarget | null): boolean {
if (!target || !(target instanceof HTMLElement)) return false;
return target instanceof HTMLInputElement
|| target instanceof HTMLTextAreaElement
|| target.isContentEditable;
}
// ---------------------------------------------------------------------------
// Web fallback: capture-phase listeners
// ---------------------------------------------------------------------------
type WebCleanup = (() => void) | null;
function setupWebFallback(keybindsRef: React.MutableRefObject<Keybind[]>): WebCleanup {
const pressedKeys = new Set<number>();
const activeActions = new Set<string>();
function browserCodeToUiohook(code: string): number {
// djb2 hash — must match codeToNumeric() in KeybindsPanel.tsx
let hash = 5381;
for (let i = 0; i < code.length; i++) {
hash = ((hash << 5) + hash + code.charCodeAt(i)) | 0;
}
return hash >>> 0;
}
function checkKeybinds(isDown: boolean): void {
for (const kb of keybindsRef.current) {
const keysMatch = kb.keys.length === 0 || kb.keys.every((k) => pressedKeys.has(k));
// Mouse buttons handled separately in mousedown handler
if (!kb.mouseButton && keysMatch && kb.keys.length > 0) {
if (isDown && !activeActions.has(kb.actionId)) {
activeActions.add(kb.actionId);
dispatchKeybindAction(kb.actionId, true);
}
}
}
// Check for releases
if (!isDown) {
for (const actionId of activeActions) {
const kb = keybindsRef.current.find((k) => k.actionId === actionId);
if (kb && !kb.keys.every((k) => pressedKeys.has(k))) {
activeActions.delete(actionId);
dispatchKeybindAction(actionId, false);
}
}
}
}
function onKeyDown(e: KeyboardEvent): void {
// Input suppression: single character key + no modifiers + input focused → skip
if (isInputElement(e.target) && isCharacterKey(e)) return;
const code = browserCodeToUiohook(e.code);
pressedKeys.add(code);
checkKeybinds(true);
}
function onKeyUp(e: KeyboardEvent): void {
const code = browserCodeToUiohook(e.code);
pressedKeys.delete(code);
checkKeybinds(false);
}
// Browser button index → uiohook button index
const buttonMap: Record<number, number> = { 1: 3, 3: 4, 4: 5 };
function onMouseDown(e: MouseEvent): void {
const uiButton = buttonMap[e.button];
if (!uiButton) return;
for (const kb of keybindsRef.current) {
if (kb.mouseButton === uiButton) {
const keysMatch = kb.keys.length === 0 || kb.keys.every((k) => pressedKeys.has(k));
if (keysMatch && !activeActions.has(kb.actionId)) {
activeActions.add(kb.actionId);
dispatchKeybindAction(kb.actionId, true);
}
}
}
}
function onMouseUp(e: MouseEvent): void {
const uiButton = buttonMap[e.button];
if (!uiButton) return;
for (const actionId of [...activeActions]) {
const kb = keybindsRef.current.find((k) => k.actionId === actionId);
if (kb && kb.mouseButton === uiButton) {
activeActions.delete(actionId);
dispatchKeybindAction(actionId, false);
}
}
}
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('keyup', onKeyUp, true);
window.addEventListener('mousedown', onMouseDown, true);
window.addEventListener('mouseup', onMouseUp, true);
return () => {
window.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('keyup', onKeyUp, true);
window.removeEventListener('mousedown', onMouseDown, true);
window.removeEventListener('mouseup', onMouseUp, true);
};
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useKeybinds(): void {
const keybinds = useKeybindStore((s) => s.keybinds);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const keybindsRef = useRef(keybinds);
keybindsRef.current = keybinds;
// --- PTT activation lifecycle ---
useEffect(() => {
const hasPtt = keybinds.some((kb) => kb.actionId === 'pushToTalk');
const inVoice = !!currentVoiceChannelId;
const voice = useVoiceStore.getState();
if (hasPtt && inVoice) {
voice.setPttActive(true);
voice.setMuted(true);
broadcastVoiceStatus();
} else if (voice.pttActive) {
voice.setPttActive(false);
}
}, [keybinds, currentVoiceChannelId]);
// --- Electron IPC bridge (global shortcuts via uiohook in main process) ---
useEffect(() => {
if (keybinds.length === 0) return;
if (!isElectron()) return;
const api = window.backspace;
if (!api?.syncKeybinds || !api?.onKeybindAction) return;
// Sync keybind config to main process — it registers OS-level hooks
api.syncKeybinds(keybinds.map((kb) => ({
actionId: kb.actionId,
keys: kb.keys,
mouseButton: kb.mouseButton,
})));
// Listen for matched actions from main process
const cleanup = api.onKeybindAction((action) => {
dispatchKeybindAction(action.actionId, action.pressed);
});
return cleanup;
}, [keybinds]);
// --- Web fallback: always active as safety net ---
// On web: this is the only keybind path (works when tab is focused).
// On Electron: this provides in-app keybinds even if the OS-level hook
// fails to start (e.g. macOS Accessibility permission denied). When the
// global hook IS working, both fire but dispatchKeybindAction is
// idempotent for toggles (no-op if not in voice / already handled).
useEffect(() => {
if (keybinds.length === 0) return;
const cleanup = setupWebFallback(keybindsRef);
return cleanup ?? undefined;
}, [keybinds]);
}