diff --git a/src/apps/chat/components/StatusBar.tsx b/src/apps/chat/components/StatusBar.tsx index ab68ed0e7..b618d3aad 100644 --- a/src/apps/chat/components/StatusBar.tsx +++ b/src/apps/chat/components/StatusBar.tsx @@ -10,6 +10,7 @@ import { ShortcutKey, ShortcutObject } from '~/common/components/shortcuts/useGl import { ConfirmationModal } from '~/common/components/modals/ConfirmationModal'; import { GoodTooltip } from '~/common/components/GoodTooltip'; import { useGlobalShortcutsStore } from '~/common/components/shortcuts/store-global-shortcuts'; +import { useShortcutsPreferencesStore, shortcutFingerprint } from '~/common/components/shortcuts/store-shortcuts-preferences'; import { useOverlayComponents } from '~/common/layout/overlays/useOverlayComponents'; import { useUXLabsStore } from '~/common/stores/store-ux-labs'; @@ -176,9 +177,12 @@ export function StatusBar(props: { toggleMinimized?: () => void, isMinimized?: b // external state const labsShowShortcutBar = useUXLabsStore(state => state.labsShowShortcutBar); + const disabledShortcuts = useShortcutsPreferencesStore(state => state.disabledShortcuts); const shortcuts = useGlobalShortcutsStore(useShallow(state => { - // get visible shortcuts - let visibleShortcuts = !labsShowShortcutBar ? [] : state.getAllShortcuts().filter(shortcut => !!shortcut.description); + // get visible shortcuts, excluding user-denied ones + let visibleShortcuts = !labsShowShortcutBar ? [] : state.getAllShortcuts().filter(shortcut => + !!shortcut.description && !disabledShortcuts.includes(shortcutFingerprint(shortcut)), + ); // filter by highest level if levels are present const maxLevel = Math.max(...visibleShortcuts.map(s => s.level ?? 0)); diff --git a/src/apps/chat/components/composer/Composer.tsx b/src/apps/chat/components/composer/Composer.tsx index 7b51702b8..d9a16bdcb 100644 --- a/src/apps/chat/components/composer/Composer.tsx +++ b/src/apps/chat/components/composer/Composer.tsx @@ -26,6 +26,7 @@ import { ConversationsManager } from '~/common/chat-overlay/ConversationsManager import { DMessageId, DMessageMetadata, DMetaReferenceItem, messageFragmentsReduceText } from '~/common/stores/chat/chat.message'; import { PhPaintBrush } from '~/common/components/icons/phosphor/PhPaintBrush'; import { ShortcutKey, ShortcutObject, useGlobalShortcuts } from '~/common/components/shortcuts/useGlobalShortcuts'; +import { isShortcutDenied } from '~/common/components/shortcuts/store-shortcuts-preferences'; import { addSnackbar } from '~/common/components/snackbar/useSnackbarsStore'; import { animationEnterBelow } from '~/common/util/animUtils'; import { browserSpeechRecognitionCapability, PLACEHOLDER_INTERIM_TRANSCRIPT, SpeechResult, useSpeechRecognition } from '~/common/components/speechrecognition/useSpeechRecognition'; @@ -545,16 +546,20 @@ export function Composer(props: { // Alt (Windows) or Option (Mac) + Enter: append the message instead of sending it if (e.altKey && !e.metaKey && !e.ctrlKey) { - if (await handleSendAction('append-user', composeText)) // 'alt+enter' -> write - e.stopPropagation(); - return e.preventDefault(); + if (!isShortcutDenied({ key: 'Enter', alt: true })) { + if (await handleSendAction('append-user', composeText)) // 'alt+enter' -> write + e.stopPropagation(); + return e.preventDefault(); + } } // Ctrl (Windows) or Command (Mac) + Enter: send for beaming if (e.ctrlKey && !e.metaKey && !e.altKey) { - if (await handleSendAction('beam-content', composeText)) // 'ctrl+enter' -> beam - e.stopPropagation(); - return e.preventDefault(); + if (!isShortcutDenied({ key: 'Enter', ctrl: true })) { + if (await handleSendAction('beam-content', composeText)) // 'ctrl+enter' -> beam + e.stopPropagation(); + return e.preventDefault(); + } } // Shift: toggles the 'enter is newline' diff --git a/src/apps/settings-modal/ShortcutsModal.tsx b/src/apps/settings-modal/ShortcutsModal.tsx index 925f0c383..63c27617d 100644 --- a/src/apps/settings-modal/ShortcutsModal.tsx +++ b/src/apps/settings-modal/ShortcutsModal.tsx @@ -1,10 +1,13 @@ import * as React from 'react'; -import { Box, Chip, Divider, Typography } from '@mui/joy'; +import { Box, Chip, Divider, IconButton, Tooltip, Typography } from '@mui/joy'; +import BlockIcon from '@mui/icons-material/Block'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import { GoodModal } from '~/common/components/modals/GoodModal'; import type { ShortcutDefinition } from '~/common/components/shortcuts/useGlobalShortcuts'; import { shortcutsCatalog } from '~/common/components/shortcuts/shortcutsCatalog'; +import { shortcutFingerprint, useShortcutsPreferencesStore } from '~/common/components/shortcuts/store-shortcuts-preferences'; import { useGlobalShortcutsStore } from '~/common/components/shortcuts/store-global-shortcuts'; import { useIsMobile } from '~/common/components/useMatchMedia'; import { Is } from '~/common/util/pwaUtils'; @@ -36,6 +39,11 @@ const _styles = { justifyContent: 'space-between', gap: 1, }, + rowLeft: { + display: 'flex', + alignItems: 'center', + gap: 1, + }, keys: { display: 'flex', gap: 0.5, @@ -111,6 +119,42 @@ function ShortcutKeyCombo(props: { def: ShortcutDefinition }) { } +function ShortcutRow(props: { item: typeof shortcutsCatalog[number]['items'][number], active: boolean }) { + const { item, active } = props; + + const fp = shortcutFingerprint(item); + const isDenied = useShortcutsPreferencesStore((state) => state.disabledShortcuts.includes(fp)); + + const handleToggle = React.useCallback(() => { + useShortcutsPreferencesStore.getState().toggleShortcutDisabled(fp); + }, [fp]); + + const effectivelyActive = active && !isDenied; + + return ( + + + + + {isDenied ? : } + + + + + + {item.description} + + + ); +} + + export function ShortcutsModal(props: { onClose: () => void }) { // external state @@ -130,14 +174,7 @@ export function ShortcutsModal(props: { onClose: () => void }) { {category.items.map((item, i) => { const active = _isActive(item, activeFingerprints); - return ( - - - - {item.description} - - - ); + return ; })} ))} diff --git a/src/common/components/shortcuts/globalShortcutsHandler.ts b/src/common/components/shortcuts/globalShortcutsHandler.ts index 355e83507..9319c545b 100644 --- a/src/common/components/shortcuts/globalShortcutsHandler.ts +++ b/src/common/components/shortcuts/globalShortcutsHandler.ts @@ -1,4 +1,5 @@ import { useGlobalShortcutsStore } from './store-global-shortcuts'; +import { isShortcutDenied } from './store-shortcuts-preferences'; export function ensureGlobalShortcutHandler() { @@ -41,6 +42,10 @@ function _handleGlobalShortcutKeyDown(event: KeyboardEvent) { if (shortcut.skipIfInput && _isTextInputFocused()) continue; + // Skip if the shortcut is on the user's denylist + if (isShortcutDenied(shortcut)) + continue; + // Execute the action (and prevent the default browser action) event.preventDefault(); event.stopPropagation(); diff --git a/src/common/components/shortcuts/store-shortcuts-preferences.ts b/src/common/components/shortcuts/store-shortcuts-preferences.ts new file mode 100644 index 000000000..230ae312e --- /dev/null +++ b/src/common/components/shortcuts/store-shortcuts-preferences.ts @@ -0,0 +1,43 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import type { ShortcutDefinition } from './useGlobalShortcuts'; + + +export function shortcutFingerprint(def: Pick): string { + return `${def.key.toLowerCase()}:${!!def.ctrl}:${!!def.shift}:${!!def.alt}`; +} + + +interface ShortcutsPreferencesStore { + disabledShortcuts: string[]; + toggleShortcutDisabled: (fingerprint: string) => void; +} + +export const useShortcutsPreferencesStore = create()( + persist( + (set) => ({ + + disabledShortcuts: [], + + toggleShortcutDisabled: (fingerprint: string) => + set((state) => { + const idx = state.disabledShortcuts.indexOf(fingerprint); + if (idx >= 0) + return { disabledShortcuts: state.disabledShortcuts.filter((f) => f !== fingerprint) }; + return { disabledShortcuts: [...state.disabledShortcuts, fingerprint] }; + }), + + }), + { + name: 'app-shortcuts-preferences', + version: 1, + }, + ), +); + + +export function isShortcutDenied(def: Pick): boolean { + const fp = shortcutFingerprint(def); + return useShortcutsPreferencesStore.getState().disabledShortcuts.includes(fp); +}