import React, { useCallback } from 'react'; import FocusTrap from 'focus-trap-react'; import { atom, useAtom, useSetAtom } from 'jotai'; import { Box, Dialog, Header, Icon, IconButton, Icons, Line, Overlay, OverlayBackdrop, OverlayCenter, Scroll, Text, config, } from 'folds'; import { stopPropagation } from '../../utils/keyboard'; import { editableActiveElement } from '../../utils/dom'; import { useKeyDown } from '../../hooks/useKeyDown'; import { useModalStyle } from '../../hooks/useModalStyle'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { isMacOS } from '../../utils/user-agent'; import { KeySymbol } from '../../utils/key-symbol'; import * as css from './KeyboardShortcutsDialog.css'; /** Global open-state for the keyboard shortcuts help dialog. */ export const keyboardShortcutsDialogAtom = atom(false); /** Read/control the keyboard shortcuts dialog open-state. */ export function useKeyboardShortcutsDialog() { const [open, setOpen] = useAtom(keyboardShortcutsDialogAtom); const openDialog = useCallback(() => setOpen(true), [setOpen]); const closeDialog = useCallback(() => setOpen(false), [setOpen]); return { open, openDialog, closeDialog }; } /** * Registers the global `Shift + /` (`?`) shortcut that opens the keyboard * shortcuts help dialog. Ignored while the user is typing into an input, * textarea or contenteditable so it never steals a literal `?` character. * * Mount once in the client shell (e.g. `ClientNonUIFeatures`). */ export function useKeyboardShortcutsTrigger() { const setOpen = useSetAtom(keyboardShortcutsDialogAtom); useKeyDown( window, useCallback( (evt: KeyboardEvent) => { // Never intercept `?` while the user is typing into a field/editor. if (editableActiveElement()) return; // `?` is produced by Shift + `/` on the common layouts. if (evt.key === '?') { evt.preventDefault(); // Stop RoomView's window-level "type any char → focus composer" // handler from also firing — otherwise focus lands in the composer // behind the dialog and Escape gets swallowed by the contenteditable. evt.stopImmediatePropagation(); setOpen(true); } }, [setOpen], ), ); } type ShortcutRow = { description: string; keys: string[]; }; type ShortcutSection = { title: string; rows: ShortcutRow[]; }; function ShortcutKeys({ keys }: { keys: string[] }) { return ( {keys.map((key, index) => ( {key} ))} ); } /** * Accessible keyboard shortcuts help dialog. Renders (as a modal overlay) only * while `keyboardShortcutsDialogAtom` is `true`. Open it with the `?` shortcut * (see `useKeyboardShortcutsTrigger`) or via `useKeyboardShortcutsDialog`. */ export function KeyboardShortcutsDialog() { const { open, closeDialog } = useKeyboardShortcutsDialog(); const modalStyle = useModalStyle(480); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl'; if (!open) return null; const sections: ShortcutSection[] = [ { title: 'General', rows: [ { description: 'Show keyboard shortcuts', keys: ['?'] }, { description: 'Close open panel, otherwise mark room as read', keys: [KeySymbol.Escape] }, ], }, { title: 'Composer', rows: [ { description: 'Focus the message composer', keys: ['Any character'] }, { description: 'Send message', keys: enterForNewline ? [modKey, 'Enter'] : ['Enter'], }, { description: 'Insert a new line', keys: enterForNewline ? ['Enter'] : [KeySymbol.Shift, 'Enter'], }, { description: 'Send message (always)', keys: [modKey, 'Enter'] }, ], }, { title: 'Messages', rows: [ { description: 'Reveal message actions (react, reply, more)', keys: ['Hover / focus'] }, ], }, ]; return ( }>
Keyboard Shortcuts
{sections.map((section, sectionIndex) => ( {sectionIndex > 0 && } {section.title} {section.rows.map((row) => ( {row.description} ))} ))} {enterForNewline ? 'Enter inserts a new line while “Enter for newline” is enabled in Settings.' : 'Enter sends the message. Enable “Enter for newline” in Settings to swap this.'}
); }