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).
243 lines
8.6 KiB
TypeScript
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]);
|
|
}
|