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 = {}; /** 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): WebCleanup { const pressedKeys = new Set(); const activeActions = new Set(); 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 = { 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]); }