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? +
+
+ + +
+
+ )} +
+ ); +}