diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 5ad26b9c..b5e0f150 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -14,12 +14,15 @@ "dev": "mkdir -p build && cp ../../icon.png build/icon.png && bash scripts/gen-icns.sh && cp build/icon.icns $(find ../../node_modules -path '*/electron/dist/Electron.app/Contents/Resources/electron.icns' 2>/dev/null | head -1) 2>/dev/null; tsc && electron .", "prebuild": "mkdir -p build && cp ../../icon.png build/icon.png", "build": "tsc && electron-builder", - "clean": "rm -rf dist dist-electron" + "clean": "rm -rf dist dist-electron", + "postinstall": "electron-rebuild -f -w uiohook-napi" }, "dependencies": { - "electron-updater": "^6.3.0" + "electron-updater": "^6.3.0", + "uiohook-napi": "^1.5.5" }, "devDependencies": { + "@electron/rebuild": "^3.7.1", "electron": "^40.0.0", "electron-builder": "^25.1.8", "typescript": "^5.7.2" diff --git a/packages/desktop/src/keybindManager.ts b/packages/desktop/src/keybindManager.ts new file mode 100644 index 00000000..5f19811d --- /dev/null +++ b/packages/desktop/src/keybindManager.ts @@ -0,0 +1,156 @@ +import { uIOhook, UiohookKeyboardEvent, UiohookMouseEvent } from 'uiohook-napi'; +import { BrowserWindow, systemPreferences } from 'electron'; + +interface KeybindConfig { + actionId: string; + keys: number[]; + mouseButton?: number; +} + +export class KeybindManager { + private keybinds: KeybindConfig[] = []; + private pressedKeys = new Set(); + private activeActions = new Set(); + private window: BrowserWindow | null = null; + private started = false; + + constructor() { + this.onKeyDown = this.onKeyDown.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + this.onMouseDown = this.onMouseDown.bind(this); + this.onMouseUp = this.onMouseUp.bind(this); + } + + setWindow(win: BrowserWindow): void { + this.window = win; + } + + updateKeybinds(keybinds: KeybindConfig[]): void { + this.keybinds = keybinds; + for (const actionId of this.activeActions) { + if (!keybinds.some((kb) => kb.actionId === actionId)) { + this.sendAction(actionId, false); + this.activeActions.delete(actionId); + } + } + if (keybinds.length > 0 && !this.started) { + this.start(); + } + if (keybinds.length === 0 && this.started) { + this.stop(); + } + } + + private start(): void { + if (this.started) return; + if (process.platform === 'darwin') { + const trusted = systemPreferences.isTrustedAccessibilityClient(true); + this.sendAccessibilityStatus(trusted); + if (!trusted) return; + } + uIOhook.on('keydown', this.onKeyDown); + uIOhook.on('keyup', this.onKeyUp); + uIOhook.on('mousedown', this.onMouseDown); + uIOhook.on('mouseup', this.onMouseUp); + try { + uIOhook.start(); + this.started = true; + } catch (err) { + console.error('[KeybindManager] Failed to start uiohook:', err); + this.window?.webContents.send('keybind-hook-error', { message: String(err) }); + } + } + + stop(): void { + if (!this.started) return; + for (const actionId of this.activeActions) { + this.sendAction(actionId, false); + } + this.activeActions.clear(); + this.pressedKeys.clear(); + uIOhook.removeAllListeners(); + try { uIOhook.stop(); } catch { /* ignore */ } + this.started = false; + } + + checkAccessibility(): boolean { + if (process.platform !== 'darwin') return true; + return systemPreferences.isTrustedAccessibilityClient(false); + } + + private onKeyDown(e: UiohookKeyboardEvent): void { + this.pressedKeys.add(e.keycode); + this.evaluateKeybinds(); + } + + private onKeyUp(e: UiohookKeyboardEvent): void { + this.pressedKeys.delete(e.keycode); + this.checkReleases(); + } + + private onMouseDown(e: UiohookMouseEvent): void { + const button = e.button as number; + if (button <= 2) return; + this.evaluateKeybindsWithMouse(button); + } + + private onMouseUp(e: UiohookMouseEvent): void { + const button = e.button as number; + if (button <= 2) return; + this.checkMouseReleases(button); + } + + private evaluateKeybinds(): void { + for (const kb of this.keybinds) { + if (this.activeActions.has(kb.actionId)) continue; + if (kb.mouseButton) continue; + if (kb.keys.length === 0) continue; + if (kb.keys.every((k) => this.pressedKeys.has(k))) { + this.activeActions.add(kb.actionId); + this.sendAction(kb.actionId, true); + } + } + } + + private evaluateKeybindsWithMouse(mouseButton: number): void { + for (const kb of this.keybinds) { + if (this.activeActions.has(kb.actionId)) continue; + if (kb.mouseButton !== mouseButton) continue; + if (kb.keys.every((k) => this.pressedKeys.has(k))) { + this.activeActions.add(kb.actionId); + this.sendAction(kb.actionId, true); + } + } + } + + private checkReleases(): void { + for (const actionId of this.activeActions) { + const kb = this.keybinds.find((k) => k.actionId === actionId); + if (!kb) continue; + if (kb.mouseButton) continue; + if (!kb.keys.every((k) => this.pressedKeys.has(k))) { + this.activeActions.delete(actionId); + this.sendAction(actionId, false); + } + } + } + + private checkMouseReleases(mouseButton: number): void { + for (const actionId of this.activeActions) { + const kb = this.keybinds.find((k) => k.actionId === actionId); + if (!kb || kb.mouseButton !== mouseButton) continue; + this.activeActions.delete(actionId); + this.sendAction(actionId, false); + } + } + + private sendAction(actionId: string, pressed: boolean): void { + if (!this.window || this.window.isDestroyed()) return; + this.window.webContents.send('keybind-action', { actionId, pressed }); + } + + private sendAccessibilityStatus(trusted: boolean): void { + if (!this.window || this.window.isDestroyed()) return; + this.window.webContents.send('accessibility-status', { trusted }); + } +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 430f5640..ccca13ff 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -14,8 +14,10 @@ import { import path from 'path'; import fs from 'fs'; import { startActivityDetection, stopActivityDetection, getCurrentActivity } from './activityDetector'; +import { KeybindManager } from './keybindManager'; let mainWindow: BrowserWindow | null = null; +const keybindManager = new KeybindManager(); let tray: Tray | null = null; let isQuitting = false; let pendingDeepLink: string | null = null; @@ -276,6 +278,8 @@ function createWindow(): void { }, }); + keybindManager.setWindow(mainWindow); + if (savedState.isMaximized) { mainWindow.maximize(); } @@ -545,6 +549,15 @@ function registerIpcHandlers(): void { return updated; }); + + // Keybinds + ipcMain.on('keybinds-sync', (_event, keybinds) => { + keybindManager.updateKeybinds(keybinds); + }); + + ipcMain.handle('check-accessibility', () => { + return keybindManager.checkAccessibility(); + }); } // ─── Auto-Update ──────────────────────────────────────────────────────────── @@ -810,5 +823,6 @@ if (!gotTheLock) { app.on('before-quit', () => { isQuitting = true; stopActivityDetection(); + keybindManager.stop(); }); } diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index e186af24..dbdd5fa4 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -76,4 +76,25 @@ contextBridge.exposeInMainWorld('backspace', { return () => { ipcRenderer.removeListener('activity-detected', handler); }; }, getCurrentActivity: () => ipcRenderer.invoke('get-current-activity'), + + // Keybind support + syncKeybinds: (keybinds: Array<{ actionId: string; keys: number[]; mouseButton?: number }>) => { + ipcRenderer.send('keybinds-sync', keybinds); + }, + onKeybindAction: (callback: (action: { actionId: string; pressed: boolean }) => void) => { + const handler = (_event: Electron.IpcRendererEvent, action: { actionId: string; pressed: boolean }) => callback(action); + ipcRenderer.on('keybind-action', handler); + return () => { ipcRenderer.removeListener('keybind-action', handler); }; + }, + onAccessibilityStatus: (callback: (status: { trusted: boolean }) => void) => { + const handler = (_event: Electron.IpcRendererEvent, status: { trusted: boolean }) => callback(status); + ipcRenderer.on('accessibility-status', handler); + return () => { ipcRenderer.removeListener('accessibility-status', handler); }; + }, + onKeybindHookError: (callback: (error: { message: string }) => void) => { + const handler = (_event: Electron.IpcRendererEvent, error: { message: string }) => callback(error); + ipcRenderer.on('keybind-hook-error', handler); + return () => { ipcRenderer.removeListener('keybind-hook-error', handler); }; + }, + checkAccessibility: () => ipcRenderer.invoke('check-accessibility'), }); diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 5ae8bb3c..ff4f1998 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -32,6 +32,7 @@ import { useDelayedLoading } from '../../hooks/useDelayedLoading'; import { useWebSocket } from '../../hooks/useWebSocket'; import { useFederationToasts } from '../../hooks/useFederationToasts'; import { useLiveKit } from '../../hooks/useLiveKit'; +import { useKeybinds } from '../../hooks/useKeybinds'; import { useDeepLinkHandler } from '../../platform/deepLink'; import { initActivityBridge, teardownActivityBridge } from '../../platform/activityBridge'; import { useSpaceStore } from '../../stores/spaceStore'; @@ -123,6 +124,9 @@ export function AppLayout() { // Federation toast notifications for remote instance connection state changes useFederationToasts(); + // Keybinds handler + useKeybinds(); + // Deep link handler for Electron (backspace:// protocol) useDeepLinkHandler(); diff --git a/packages/web/src/components/modals/UserSettings.tsx b/packages/web/src/components/modals/UserSettings.tsx index 71525edc..9682a109 100644 --- a/packages/web/src/components/modals/UserSettings.tsx +++ b/packages/web/src/components/modals/UserSettings.tsx @@ -9,10 +9,11 @@ import { PrivacyPanel } from './settingsPanels/PrivacyPanel'; import { ConnectionsPanel } from './settingsPanels/ConnectionsPanel'; import { DesktopPanel } from './settingsPanels/DesktopPanel'; import { InstancePanel } from './settingsPanels/InstancePanel'; +import { KeybindsPanel } from './settingsPanels/KeybindsPanel'; import { isElectron } from '../../platform/platform'; import { SettingsSectionsProvider, useSettingsSectionsContext } from './SettingsSectionsContext'; -type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'desktop' | 'instance'; +type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'keybinds' | 'desktop' | 'instance'; function SidebarSubLinks() { const ctx = useSettingsSectionsContext(); @@ -65,7 +66,7 @@ export function UserSettingsModal() { useEffect(() => { if (isOpen) { const requested = modalData.tab as SettingsTab | undefined; - if (requested && ['account', 'voice', 'privacy', 'connections', 'instance'].includes(requested)) { + if (requested && ['account', 'voice', 'privacy', 'connections', 'keybinds', 'instance'].includes(requested)) { // Only allow instance tab for admins if (requested === 'instance' && !isAdmin) { setTab('account'); @@ -125,6 +126,7 @@ export function UserSettingsModal() {
App Settings
+ {isElectron() && } {isAdmin && ( @@ -174,6 +176,7 @@ export function UserSettingsModal() {
App Settings
+ {isElectron() && } {isAdmin && ( @@ -216,6 +219,7 @@ export function UserSettingsModal() { {tab === 'voice' && } {tab === 'privacy' && } {tab === 'connections' && } + {tab === 'keybinds' && } {tab === 'desktop' && } {tab === 'instance' && isAdmin && }
diff --git a/packages/web/src/components/modals/settingsPanels/KeybindsPanel.tsx b/packages/web/src/components/modals/settingsPanels/KeybindsPanel.tsx new file mode 100644 index 00000000..6492a17a --- /dev/null +++ b/packages/web/src/components/modals/settingsPanels/KeybindsPanel.tsx @@ -0,0 +1,372 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { useKeybindStore, BINDABLE_ACTIONS, Keybind } from '../../../stores/keybindStore'; +import { isElectron, isElectronMac } from '../../../platform/platform'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MODIFIER_CODES = new Set([ + 'ShiftLeft', 'ShiftRight', 'ControlLeft', 'ControlRight', + 'AltLeft', 'AltRight', 'MetaLeft', 'MetaRight', +]); + +const MOUSE_BUTTON_MAP: Record = { 1: 3, 3: 4, 4: 5 }; +const MOUSE_BUTTON_NAMES: Record = { 3: 'Middle Click', 4: 'Mouse 4', 5: 'Mouse 5' }; + +function keyDisplayName(code: string, key: string): string { + if (code.startsWith('Shift')) return 'Shift'; + if (code.startsWith('Control')) return 'Ctrl'; + if (code.startsWith('Alt')) return isElectronMac() ? 'Option' : 'Alt'; + if (code.startsWith('Meta')) return isElectronMac() ? 'Cmd' : 'Win'; + if (code.startsWith('Key')) return code.slice(3).toUpperCase(); + if (code.startsWith('Digit')) return code.slice(5); + if (key === ' ') return 'Space'; + return key.length === 1 ? key.toUpperCase() : key; +} + +function codeToNumeric(code: string): number { + return code.charCodeAt(0) * 256 + (code.charCodeAt(1) || 0); +} + +// --------------------------------------------------------------------------- +// Keybind Row +// --------------------------------------------------------------------------- + +interface KeybindRowProps { + actionId: string; + label: string; + keybind: Keybind | undefined; + isRecording: boolean; + recordingDisplay: string; + onStartRecording: () => void; + onDelete: () => void; + rowRef: React.RefObject; +} + +function KeybindRow({ actionId, label, keybind, isRecording, recordingDisplay, onStartRecording, onDelete, rowRef }: KeybindRowProps) { + return ( +
+
+
{label}
+
+ {isRecording ? ( + + {recordingDisplay || 'Press a key combo...'} + + ) : keybind ? ( + keybind.displayLabel + ) : ( + 'Not bound' + )} +
+
+
+ {!isRecording && ( + <> + + {keybind && ( + + )} + + )} + {isRecording && ( + ESC to cancel + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Conflict Dialog (inline) +// --------------------------------------------------------------------------- + +interface ConflictInfo { + newActionId: string; + existingKeybind: Keybind; + pendingKeybind: Keybind; +} + +// --------------------------------------------------------------------------- +// KeybindsPanel +// --------------------------------------------------------------------------- + +export function KeybindsPanel() { + const { keybinds, setKeybind, removeKeybind, findConflict } = useKeybindStore(); + + const [recordingActionId, setRecordingActionId] = useState(null); + const [recordingDisplay, setRecordingDisplay] = useState(''); + const [conflict, setConflict] = useState(null); + + const [accessibilityTrusted, setAccessibilityTrusted] = useState(null); + const [hookError, setHookError] = useState(null); + + const pressedCodesRef = useRef(new Map()); + const mouseButtonRef = useRef(null); + const mouseDisplayRef = useRef(null); + const debounceTimerRef = useRef | null>(null); + const rowRefs = useRef>>({}); + + for (const action of BINDABLE_ACTIONS) { + if (!rowRefs.current[action.id]) { + rowRefs.current[action.id] = React.createRef(); + } + } + + // --- Platform checks on mount --- + useEffect(() => { + if (isElectronMac() && window.backspace?.checkAccessibility) { + window.backspace.checkAccessibility().then(setAccessibilityTrusted); + } + if (isElectron() && window.backspace?.onAccessibilityStatus) { + const cleanup = window.backspace.onAccessibilityStatus((status) => { + setAccessibilityTrusted(status.trusted); + }); + return cleanup; + } + }, []); + + useEffect(() => { + if (isElectron() && window.backspace?.onKeybindHookError) { + const cleanup = window.backspace.onKeybindHookError((error) => { + setHookError(error.message); + }); + return cleanup; + } + }, []); + + // --- Recording logic --- + const cancelRecording = useCallback(() => { + setRecordingActionId(null); + setRecordingDisplay(''); + pressedCodesRef.current.clear(); + mouseButtonRef.current = null; + mouseDisplayRef.current = null; + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + }, []); + + const finalizeRecording = useCallback((actionId: string) => { + const codes = pressedCodesRef.current; + const mouseBtn = mouseButtonRef.current; + + const keys = Array.from(codes.values()).map((c) => c.numeric).sort((a, b) => a - b); + const displayParts = Array.from(codes.values()).map((c) => c.display); + if (mouseBtn && MOUSE_BUTTON_NAMES[mouseBtn]) { + displayParts.push(MOUSE_BUTTON_NAMES[mouseBtn]); + } + const displayLabel = displayParts.join(' + '); + + const hasNonModifier = Array.from(codes.keys()).some((code) => !MODIFIER_CODES.has(code)); + if (!hasNonModifier && !mouseBtn) { + return; + } + + const newKeybind: Keybind = { actionId, keys, mouseButton: mouseBtn ?? undefined, displayLabel }; + + const existing = findConflict(keys, mouseBtn ?? undefined, actionId); + if (existing) { + setConflict({ newActionId: actionId, existingKeybind: existing, pendingKeybind: newKeybind }); + cancelRecording(); + return; + } + + setKeybind(newKeybind); + cancelRecording(); + }, [findConflict, setKeybind, cancelRecording]); + + // --- Recording event listeners --- + useEffect(() => { + if (!recordingActionId) return; + + function onKeyDown(e: KeyboardEvent) { + e.preventDefault(); + e.stopPropagation(); + + if (e.code === 'Escape') { + cancelRecording(); + return; + } + + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + + const display = keyDisplayName(e.code, e.key); + const numeric = codeToNumeric(e.code); + pressedCodesRef.current.set(e.code, { numeric, display }); + + const parts = Array.from(pressedCodesRef.current.values()).map((c) => c.display); + if (mouseDisplayRef.current) parts.push(mouseDisplayRef.current); + setRecordingDisplay(parts.join(' + ')); + } + + function onKeyUp(e: KeyboardEvent) { + e.preventDefault(); + e.stopPropagation(); + + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = setTimeout(() => { + if (recordingActionId) finalizeRecording(recordingActionId); + }, 300); + } + + function onMouseDown(e: MouseEvent) { + const rowRef = rowRefs.current[recordingActionId!]; + if (rowRef?.current && !rowRef.current.contains(e.target as Node)) { + cancelRecording(); + return; + } + + const uiButton = MOUSE_BUTTON_MAP[e.button]; + if (!uiButton) return; + + e.preventDefault(); + e.stopPropagation(); + + mouseButtonRef.current = uiButton; + mouseDisplayRef.current = MOUSE_BUTTON_NAMES[uiButton] ?? `Mouse ${uiButton}`; + + const parts = Array.from(pressedCodesRef.current.values()).map((c) => c.display); + parts.push(mouseDisplayRef.current); + setRecordingDisplay(parts.join(' + ')); + + finalizeRecording(recordingActionId!); + } + + window.addEventListener('keydown', onKeyDown, true); + window.addEventListener('keyup', onKeyUp, true); + window.addEventListener('mousedown', onMouseDown, true); + + return () => { + window.removeEventListener('keydown', onKeyDown, true); + window.removeEventListener('keyup', onKeyUp, true); + window.removeEventListener('mousedown', onMouseDown, true); + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + }; + }, [recordingActionId, cancelRecording, finalizeRecording]); + + // --- Conflict resolution --- + const confirmConflict = () => { + if (!conflict) return; + removeKeybind(conflict.existingKeybind.actionId); + setKeybind(conflict.pendingKeybind); + setConflict(null); + }; + + const cancelConflict = () => setConflict(null); + + const getKeybind = (actionId: string) => keybinds.find((kb) => kb.actionId === actionId); + + return ( +
+ {/* macOS Accessibility Warning */} + {isElectronMac() && accessibilityTrusted === false && ( +
+
Accessibility Permission Required
+
+ Backspace needs Accessibility permission for global shortcuts to work outside the app. +
+ +
+ )} + + {/* Linux hook error warning */} + {isElectron() && hookError && ( +
+
Global Shortcuts Unavailable
+
+ Failed to start input listener. On Linux, your user may need to be in the input group. +
+
+ )} + + {/* Web limitation note */} + {!isElectron() && ( +
+ Shortcuts work while this tab is focused. For global shortcuts that work in other apps, use the desktop app. +
+ )} + + {/* Keybind rows */} +
+
+ Voice Shortcuts +
+
+ {BINDABLE_ACTIONS.map((action) => ( + { + cancelRecording(); + setRecordingActionId(action.id); + }} + onDelete={() => removeKeybind(action.id)} + rowRef={rowRefs.current[action.id]!} + /> + ))} +
+
+ + {/* Conflict dialog */} + {conflict && ( +
+
+ {conflict.pendingKeybind.displayLabel} is already bound to{' '} + + {BINDABLE_ACTIONS.find((a) => a.id === conflict.existingKeybind.actionId)?.label} + + . Overwrite? +
+
+ + +
+
+ )} +
+ ); +} diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx index 85940d87..8c342081 100644 --- a/packages/web/src/components/voice/VoiceControlBar.tsx +++ b/packages/web/src/components/voice/VoiceControlBar.tsx @@ -2,13 +2,10 @@ import React, { useEffect, useState, useRef } from 'react'; import { useVoiceStore } from '../../stores/voiceStore'; import { useUIStore } from '../../stores/uiStore'; import { useAuthStore } from '../../stores/authStore'; -import { getActiveRoom } from '../../hooks/useLiveKit'; -import { wsSend } from '../../hooks/useWebSocket'; import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore'; import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover'; -import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare'; -import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; +import { handleMuteAction, handleDeafenAction, handleCameraAction, handleScreenShareAction, handleDisconnectAction } from '../../utils/voiceActions'; const btnBase = 'w-10 h-10 flex items-center justify-center rounded-full transition-colors'; const btnDefault = `${btnBase} bg-surface-channel text-txt-secondary hover:bg-surface-elevated hover:text-txt-primary`; @@ -20,9 +17,6 @@ export function VoiceControlBar() { const isDeafened = useVoiceStore((s) => s.isDeafened); const isCameraOn = useVoiceStore((s) => s.isCameraOn); const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); - const toggleMic = useVoiceStore((s) => s.toggleMic); - const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); - const toggleCamera = useVoiceStore((s) => s.toggleCamera); const voiceChatOpen = useUIStore((s) => s.voiceChatOpen); const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat); const voiceFullscreen = useUIStore((s) => s.voiceFullscreen); @@ -30,7 +24,6 @@ export function VoiceControlBar() { const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const myUser = useAuthStore((s) => s.user); const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null); - const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : ''; const myOriginId = useSpaceStore((s) => currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : s.members.find(m => m.userId === myUser?.id)?.userId ?? myUser?.id); const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds); const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds); @@ -44,102 +37,33 @@ export function VoiceControlBar() { const [qualityOpen, setQualityOpen] = useState(false); const qualityBtnRef = useRef(null); - const handleMute = React.useCallback(async () => { - if (isSpaceMuted || isSpaceDeafened) return; - const wasDeafened = useVoiceStore.getState().isDeafened; - toggleMic(); - broadcastVoiceStatus(); - // If unmuting while deafened cleared deafen, broadcast via LiveKit data channel - if (wasDeafened && !useVoiceStore.getState().isDeafened) { - broadcastDeafenViaLiveKit(); - } - }, [isSpaceMuted, isSpaceDeafened, toggleMic]); + const handleMute = React.useCallback(() => { + handleMuteAction(isSpaceMuted, isSpaceDeafened); + }, [isSpaceMuted, isSpaceDeafened]); - const handleDeafen = React.useCallback(async () => { - if (isSpaceDeafened) return; - toggleDeafen(); - broadcastVoiceStatus(); - broadcastDeafenViaLiveKit(); - }, [isSpaceDeafened, toggleDeafen]); + const handleDeafen = React.useCallback(() => { + handleDeafenAction(isSpaceDeafened); + }, [isSpaceDeafened]); - const handleCamera = async () => { - const room = getActiveRoom(); - if (!room) return; - try { - const willEnable = !isCameraOn; - if (willEnable) { - await room.localParticipant.setCameraEnabled(true, - { resolution: CAMERA_PRESET.resolution }, - { - videoCodec: CAMERA_PRESET.codec, - videoEncoding: CAMERA_PRESET.encoding, - simulcast: true, - } - ); - } else { - await room.localParticipant.setCameraEnabled(false); - } - toggleCamera(); - broadcastVoiceStatus(); - } catch (err) { - console.error('[VoiceControlBar] Failed to toggle camera:', err); - } - }; + const handleCamera = () => handleCameraAction(); - const handleScreenShare = async () => { - const room = getActiveRoom(); - if (!room) return; - try { - if (!isScreenSharing) { - const started = await startScreenShare(room); - if (started) broadcastVoiceStatus(); - } else { - await stopScreenShare(room); - broadcastVoiceStatus(); - } - } catch (err) { - console.error('[VoiceControlBar] Failed to toggle screen share:', err); - } - }; + const handleScreenShare = () => handleScreenShareAction(); - const handleDisconnect = () => { - const { activeDmCall } = useVoiceStore.getState(); - if (activeDmCall) { - wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, getChannelOrigin(activeDmCall.dmChannelId)); - useVoiceStore.getState().setActiveDmCall(null); - } else { - wsSend({ type: 'voice_leave' }, voiceOrigin); - useVoiceStore.getState().leaveVoice(); - } - if (voiceFullscreen) { - useUIStore.getState().setVoiceFullscreen(false); - if (document.fullscreenElement) { - document.exitFullscreen().catch(() => {}); - } - } - }; + const handleDisconnect = () => handleDisconnectAction(); const handleFullscreen = () => { toggleVoiceFullscreen(); }; - // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; - if (e.key === 'm' || e.key === 'M') { - e.preventDefault(); - handleMute(); - } else if (e.key === 'd' || e.key === 'D') { - e.preventDefault(); - handleDeafen(); - } else if (e.key === 'Escape' && voiceFullscreen) { + if (e.key === 'Escape' && voiceFullscreen) { useUIStore.getState().setVoiceFullscreen(false); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleMute, handleDeafen, voiceFullscreen]); + }, [voiceFullscreen]); return (
diff --git a/packages/web/src/hooks/useKeybinds.ts b/packages/web/src/hooks/useKeybinds.ts new file mode 100644 index 00000000..f834802c --- /dev/null +++ b/packages/web/src/hooks/useKeybinds.ts @@ -0,0 +1,225 @@ +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), + }; +} + +/** Dispatch a keybind action to the appropriate voice handler */ +function dispatchKeybindAction(actionId: string, pressed: boolean): void { + 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 { + // Stable numeric ID from KeyboardEvent.code — consistent within web context + // because the recorder captures using the same mapping + return code.charCodeAt(0) * 256 + (code.charCodeAt(1) || 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 --- + useEffect(() => { + if (!isElectron()) return; + const api = window.backspace; + if (!api?.syncKeybinds || !api?.onKeybindAction) return; + + api.syncKeybinds(keybinds.map((kb) => ({ + actionId: kb.actionId, + keys: kb.keys, + mouseButton: kb.mouseButton, + }))); + + const cleanup = api.onKeybindAction((action) => { + dispatchKeybindAction(action.actionId, action.pressed); + }); + + return cleanup; + }, [keybinds]); + + // --- Web fallback: capture-phase listeners --- + useEffect(() => { + if (isElectron()) return; + if (keybinds.length === 0) return; + + const cleanup = setupWebFallback(keybindsRef); + return cleanup ?? undefined; + }, [keybinds]); +} diff --git a/packages/web/src/platform/electron.d.ts b/packages/web/src/platform/electron.d.ts index 9788d6cc..09fdd82d 100644 --- a/packages/web/src/platform/electron.d.ts +++ b/packages/web/src/platform/electron.d.ts @@ -52,6 +52,13 @@ interface BackspaceElectronAPI { // Activity detection (game/app process scanning) onActivityDetected: (callback: (activity: unknown) => void) => (() => void); getCurrentActivity: () => Promise; + + // Keybind support + syncKeybinds: (keybinds: Array<{ actionId: string; keys: number[]; mouseButton?: number }>) => void; + onKeybindAction: (callback: (action: { actionId: string; pressed: boolean }) => void) => (() => void); + onAccessibilityStatus: (callback: (status: { trusted: boolean }) => void) => (() => void); + onKeybindHookError: (callback: (error: { message: string }) => void) => (() => void); + checkAccessibility: () => Promise; } interface Window { diff --git a/packages/web/src/stores/keybindStore.test.ts b/packages/web/src/stores/keybindStore.test.ts new file mode 100644 index 00000000..0802fc39 --- /dev/null +++ b/packages/web/src/stores/keybindStore.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useKeybindStore } from './keybindStore'; + +beforeEach(() => { + useKeybindStore.setState({ keybinds: [] }); +}); + +describe('keybindStore', () => { + it('starts with no keybinds', () => { + expect(useKeybindStore.getState().keybinds).toEqual([]); + }); + + it('adds a keybind', () => { + useKeybindStore.getState().setKeybind({ + actionId: 'toggleMute', + keys: [42, 50], + displayLabel: 'Shift + M', + }); + const kb = useKeybindStore.getState().keybinds; + expect(kb).toHaveLength(1); + expect(kb[0].actionId).toBe('toggleMute'); + expect(kb[0].keys).toEqual([42, 50]); // sorted + }); + + it('sorts keys on save', () => { + useKeybindStore.getState().setKeybind({ + actionId: 'toggleMute', + keys: [50, 42], + displayLabel: 'Shift + M', + }); + expect(useKeybindStore.getState().keybinds[0].keys).toEqual([42, 50]); + }); + + it('replaces existing keybind for same action', () => { + const store = useKeybindStore.getState(); + store.setKeybind({ actionId: 'toggleMute', keys: [42], displayLabel: 'Shift' }); + store.setKeybind({ actionId: 'toggleMute', keys: [50], displayLabel: 'M' }); + expect(useKeybindStore.getState().keybinds).toHaveLength(1); + expect(useKeybindStore.getState().keybinds[0].displayLabel).toBe('M'); + }); + + it('removes a keybind', () => { + useKeybindStore.getState().setKeybind({ + actionId: 'toggleMute', + keys: [42], + displayLabel: 'Shift', + }); + useKeybindStore.getState().removeKeybind('toggleMute'); + expect(useKeybindStore.getState().keybinds).toEqual([]); + }); + + it('detects conflicts', () => { + useKeybindStore.getState().setKeybind({ + actionId: 'toggleMute', + keys: [42, 50], + displayLabel: 'Shift + M', + }); + const conflict = useKeybindStore.getState().findConflict([42, 50]); + expect(conflict).not.toBeNull(); + expect(conflict!.actionId).toBe('toggleMute'); + }); + + it('excludes specified action from conflict check', () => { + useKeybindStore.getState().setKeybind({ + actionId: 'toggleMute', + keys: [42, 50], + displayLabel: 'Shift + M', + }); + const conflict = useKeybindStore.getState().findConflict([42, 50], undefined, 'toggleMute'); + expect(conflict).toBeNull(); + }); + + it('detects mouse button conflicts', () => { + useKeybindStore.getState().setKeybind({ + actionId: 'toggleMute', + keys: [], + mouseButton: 4, + displayLabel: 'Mouse 4', + }); + const conflict = useKeybindStore.getState().findConflict([], 4); + expect(conflict).not.toBeNull(); + }); + + it('rejects blacklisted mouse buttons', () => { + useKeybindStore.getState().setKeybind({ + actionId: 'toggleMute', + keys: [], + mouseButton: 1, // left click — blacklisted + displayLabel: 'Mouse 1', + }); + expect(useKeybindStore.getState().keybinds).toEqual([]); + }); + + it('supports modifier + mouse button combos', () => { + useKeybindStore.getState().setKeybind({ + actionId: 'pushToTalk', + keys: [42], + mouseButton: 4, + displayLabel: 'Shift + Mouse 4', + }); + const kb = useKeybindStore.getState().keybinds; + expect(kb).toHaveLength(1); + expect(kb[0].keys).toEqual([42]); + expect(kb[0].mouseButton).toBe(4); + }); +}); diff --git a/packages/web/src/stores/keybindStore.ts b/packages/web/src/stores/keybindStore.ts new file mode 100644 index 00000000..3e909465 --- /dev/null +++ b/packages/web/src/stores/keybindStore.ts @@ -0,0 +1,70 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export interface Keybind { + actionId: string; + keys: number[]; // uIOhook keycodes, sorted ascending + mouseButton?: number; // uIOhook mouse button (3=middle, 4=back, 5=forward) + displayLabel: string; // human-readable, captured at record time +} + +export const BINDABLE_ACTIONS = [ + { id: 'toggleMute', label: 'Toggle Mute', type: 'toggle' as const }, + { id: 'toggleDeafen', label: 'Toggle Deafen', type: 'toggle' as const }, + { id: 'pushToTalk', label: 'Push to Talk', type: 'hold' as const }, + { id: 'toggleCamera', label: 'Toggle Camera', type: 'toggle' as const }, + { id: 'toggleScreenShare', label: 'Toggle Screen Share', type: 'toggle' as const }, + { id: 'disconnect', label: 'Disconnect', type: 'toggle' as const }, +] as const; + +/** Mouse buttons that must not be bound (would break OS interaction) */ +const BLACKLISTED_MOUSE_BUTTONS = new Set([1, 2]); // left, right + +interface KeybindState { + keybinds: Keybind[]; + setKeybind: (keybind: Keybind) => void; + removeKeybind: (actionId: string) => void; + findConflict: (keys: number[], mouseButton?: number, excludeActionId?: string) => Keybind | null; +} + +function keybindsEqual(a: Keybind, b: { keys: number[]; mouseButton?: number }): boolean { + if (a.keys.length !== b.keys.length) return false; + if (a.mouseButton !== b.mouseButton) return false; + return a.keys.every((k, i) => k === b.keys[i]); +} + +export const useKeybindStore = create()( + persist( + (set, get) => ({ + keybinds: [], + + setKeybind: (keybind: Keybind) => { + if (keybind.mouseButton && BLACKLISTED_MOUSE_BUTTONS.has(keybind.mouseButton)) return; + const sorted = { ...keybind, keys: [...keybind.keys].sort((a, b) => a - b) }; + set((state) => ({ + keybinds: [ + ...state.keybinds.filter((kb) => kb.actionId !== sorted.actionId), + sorted, + ], + })); + }, + + removeKeybind: (actionId: string) => { + set((state) => ({ + keybinds: state.keybinds.filter((kb) => kb.actionId !== actionId), + })); + }, + + findConflict: (keys: number[], mouseButton?: number, excludeActionId?: string) => { + const sorted = [...keys].sort((a, b) => a - b); + return get().keybinds.find( + (kb) => kb.actionId !== excludeActionId && keybindsEqual(kb, { keys: sorted, mouseButton }) + ) ?? null; + }, + }), + { + name: 'backspace-keybinds', + version: 1, + } + ) +); diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 183fa172..2aef5805 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -79,6 +79,9 @@ interface VoiceState { setOutputVolume: (volume: number) => void; setInputDevice: (deviceId: string) => void; setOutputDevice: (deviceId: string) => void; + pttActive: boolean; + setMuted: (muted: boolean) => void; + setPttActive: (active: boolean) => void; toggleMic: () => void; toggleCamera: () => void; toggleScreenShare: () => void; @@ -122,6 +125,7 @@ export const useVoiceStore = create()( voiceUsers: new Map(), currentVoiceChannelId: null, isMuted: false, + pttActive: false, isDeafened: false, isCameraOn: false, isScreenSharing: false, @@ -290,6 +294,9 @@ export const useVoiceStore = create()( setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }), + setMuted: (muted: boolean) => set({ isMuted: muted }), + setPttActive: (active: boolean) => set({ pttActive: active }), + toggleMic: () => set((state) => { // User intent toggle — effective state (intent || serverEnforcement) is // computed at broadcast/hardware time, so the mic stays off while server-muted diff --git a/packages/web/src/utils/voiceActions.ts b/packages/web/src/utils/voiceActions.ts new file mode 100644 index 00000000..83c548f5 --- /dev/null +++ b/packages/web/src/utils/voiceActions.ts @@ -0,0 +1,110 @@ +import { useVoiceStore } from '../stores/voiceStore'; +import { useUIStore } from '../stores/uiStore'; +import { getActiveRoom } from '../hooks/useLiveKit'; +import { wsSend } from '../hooks/useWebSocket'; +import { getChannelOrigin } from '../stores/spaceStore'; +import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from './voice'; +import { CAMERA_PRESET, startScreenShare, stopScreenShare } from './screenShare'; + +/** + * Toggle mute. Respects space-mute/deafen guards. + * Extracted from VoiceControlBar so keybinds and buttons share the same logic. + */ +export function handleMuteAction(isSpaceMuted: boolean, isSpaceDeafened: boolean): void { + if (isSpaceMuted || isSpaceDeafened) return; + const wasDeafened = useVoiceStore.getState().isDeafened; + useVoiceStore.getState().toggleMic(); + broadcastVoiceStatus(); + if (wasDeafened && !useVoiceStore.getState().isDeafened) { + broadcastDeafenViaLiveKit(); + } +} + +/** + * Toggle deafen. Respects space-deafen guard. + */ +export function handleDeafenAction(isSpaceDeafened: boolean): void { + if (isSpaceDeafened) return; + useVoiceStore.getState().toggleDeafen(); + broadcastVoiceStatus(); + broadcastDeafenViaLiveKit(); +} + +/** + * Toggle camera. Requires LiveKit room. + */ +export async function handleCameraAction(): Promise { + const room = getActiveRoom(); + if (!room) return; + const isCameraOn = useVoiceStore.getState().isCameraOn; + try { + const willEnable = !isCameraOn; + if (willEnable) { + await room.localParticipant.setCameraEnabled(true, + { resolution: CAMERA_PRESET.resolution }, + { + videoCodec: CAMERA_PRESET.codec, + videoEncoding: CAMERA_PRESET.encoding, + simulcast: true, + } + ); + } else { + await room.localParticipant.setCameraEnabled(false); + } + useVoiceStore.getState().toggleCamera(); + broadcastVoiceStatus(); + } catch (err) { + console.error('[voiceActions] Failed to toggle camera:', err); + } +} + +/** + * Toggle screen share. Requires LiveKit room. + * Note: startScreenShare/stopScreenShare manage voiceStore.isScreenSharing internally. + * Do NOT call toggleScreenShare() here — it would double-flip the state. + */ +export async function handleScreenShareAction(): Promise { + const room = getActiveRoom(); + if (!room) return; + const isScreenSharing = useVoiceStore.getState().isScreenSharing; + try { + if (!isScreenSharing) { + const started = await startScreenShare(room); + if (started) broadcastVoiceStatus(); + } else { + await stopScreenShare(room); + broadcastVoiceStatus(); + } + } catch (err) { + console.error('[voiceActions] Failed to toggle screen share:', err); + } +} + +/** + * Disconnect from voice. Handles DM call teardown and fullscreen exit. + */ +export function handleDisconnectAction(): void { + const voice = useVoiceStore.getState(); + const { activeDmCall, currentVoiceChannelId } = voice; + + if (activeDmCall) { + wsSend( + { type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, + getChannelOrigin(activeDmCall.dmChannelId) + ); + voice.setActiveDmCall(null); + } else if (currentVoiceChannelId) { + const origin = getChannelOrigin(currentVoiceChannelId); + wsSend({ type: 'voice_leave' }, origin); + voice.leaveVoice(); + } + + // Exit fullscreen if active + const voiceFullscreen = useUIStore.getState().voiceFullscreen; + if (voiceFullscreen) { + useUIStore.getState().setVoiceFullscreen(false); + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c021cef..26f6a0f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: electron-updater: specifier: ^6.3.0 version: 6.8.3 + uiohook-napi: + specifier: ^1.5.5 + version: 1.5.5 devDependencies: electron: specifier: ^40.0.0 @@ -4041,6 +4044,10 @@ packages: node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-gyp@9.4.1: resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==} engines: {node: ^12.13 || ^14.13 || >=16} @@ -4989,6 +4996,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + uiohook-napi@1.5.5: + resolution: {integrity: sha512-oSlTdnECw2GBfsJPTbBQBeE4v/EXP0EZmX6BJq5nzH/JgFaBE8JpFwEA/kLhiEP7HxQw28FViWiYgdIZzWuuJQ==} + engines: {node: '>= 16'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -9586,6 +9597,8 @@ snapshots: dependencies: semver: 7.7.4 + node-gyp-build@4.8.4: {} + node-gyp@9.4.1: dependencies: env-paths: 2.2.1 @@ -10717,6 +10730,10 @@ snapshots: typescript@5.9.3: {} + uiohook-napi@1.5.5: + dependencies: + node-gyp-build: 4.8.4 + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4