diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index e26950b862..b969982bda 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -6,6 +6,12 @@ export enum KeyBindingContext { MessageComposer = 'MessageComposer', /** Key bindings for text editing autocompletion */ AutoComplete = 'AutoComplete', + /** Left room list sidebar */ + RoomList = 'RoomList', + /** Current room view */ + Room = 'Room', + /** Shortcuts to navigate do various menus / dialogs / screens */ + Navigation = 'Navigation', } export enum KeyAction { @@ -51,6 +57,59 @@ export enum KeyAction { AutocompletePrevSelection = 'AutocompletePrevSelection', /** Move to the next autocomplete selection */ AutocompleteNextSelection = 'AutocompleteNextSelection', + + // Room list + + /** Clear room list filter field */ + RoomListClearSearch = 'RoomListClearSearch', + /** Navigate up/down in the room list */ + RoomListPrevRoom = 'RoomListPrevRoom', + /** Navigate down in the room list */ + RoomListNextRoom = 'RoomListNextRoom', + /** Select room from the room list */ + RoomListSelectRoom = 'RoomListSelectRoom', + /** Collapse room list section */ + RoomListCollapseSection = 'RoomListCollapseSection', + /** Expand room list section, if already expanded, jump to first room in the selection */ + RoomListExpandSection = 'RoomListExpandSection', + + // Room + + /** Jump to room search */ + RoomFocusRoomSearch = 'RoomFocusRoomSearch', + /** Scroll up in the timeline */ + RoomScrollUp = 'RoomScrollUp', + /** Scroll down in the timeline */ + RoomScrollDown = 'RoomScrollDown', + /** Dismiss read marker and jump to bottom */ + RoomDismissReadMarker = 'RoomDismissReadMarker', + /* Upload a file */ + RoomUploadFile = 'RoomUploadFile', + /* Search (must be enabled) */ + RoomSearch = 'RoomSearch', + /* Jump to the first (downloaded) message in the room */ + RoomJumpToFirstMessage = 'RoomJumpToFirstMessage', + /* Jump to the latest message in the room */ + RoomJumpToLatestMessage = 'RoomJumpToLatestMessage', + + // Navigation + + /** Toggle the room side panel */ + NavToggleRoomSidePanel = 'NavToggleRoomSidePanel', + /** Toggle the user menu */ + NavToggleUserMenu = 'NavToggleUserMenu', + /* Toggle the short cut help dialog */ + NavToggleShortCutDialog = 'NavToggleShortCutDialog', + /* Got to the Element home screen */ + NavGoToHome = 'NavGoToHome', + /* Select prev room */ + NavSelectPrevRoom = 'NavSelectPrevRoom', + /* Select next room */ + NavSelectNextRoom = 'NavSelectNextRoom', + /* Select prev room with unread messages*/ + NavSelectPrevUnreadRoom = 'NavSelectPrevUnreadRoom', + /* Select next room with unread messages*/ + NavSelectNextUnreadRoom = 'NavSelectNextUnreadRoom', } /** @@ -255,6 +314,188 @@ const autocompleteBindings = (): KeyBinding[] => { key: Key.ARROW_DOWN, }, }, + ]; +} + +const roomListBindings = (): KeyBinding[] => { + return [ + { + action: KeyAction.RoomListClearSearch, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: KeyAction.RoomListPrevRoom, + keyCombo: { + key: Key.ARROW_UP, + }, + }, + { + action: KeyAction.RoomListNextRoom, + keyCombo: { + key: Key.ARROW_DOWN, + }, + }, + { + action: KeyAction.RoomListSelectRoom, + keyCombo: { + key: Key.ENTER, + }, + }, + { + action: KeyAction.RoomListCollapseSection, + keyCombo: { + key: Key.ARROW_LEFT, + }, + }, + { + action: KeyAction.RoomListExpandSection, + keyCombo: { + key: Key.ARROW_RIGHT, + }, + }, + ]; +} + +const roomBindings = (): KeyBinding[] => { + const bindings = [ + { + action: KeyAction.RoomFocusRoomSearch, + keyCombo: { + key: Key.K, + ctrlOrCmd: true, + }, + }, + { + action: KeyAction.RoomScrollUp, + keyCombo: { + key: Key.PAGE_UP, + }, + }, + { + action: KeyAction.RoomScrollDown, + keyCombo: { + key: Key.PAGE_DOWN, + }, + }, + { + action: KeyAction.RoomDismissReadMarker, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: KeyAction.RoomUploadFile, + keyCombo: { + key: Key.U, + ctrlOrCmd: true, + shiftKey: true, + }, + }, + { + action: KeyAction.RoomJumpToFirstMessage, + keyCombo: { + key: Key.HOME, + ctrlKey: true, + }, + }, + { + action: KeyAction.RoomJumpToLatestMessage, + keyCombo: { + key: Key.END, + ctrlKey: true, + }, + }, + ]; + + if (SettingsStore.getValue('ctrlFForSearch')) { + bindings.push({ + action: KeyAction.RoomSearch, + keyCombo: { + key: Key.F, + ctrlOrCmd: true, + }, + }); + } + + return bindings; +} + +const navigationBindings = (): KeyBinding[] => { + return [ + { + action: KeyAction.NavToggleRoomSidePanel, + keyCombo: { + key: Key.PERIOD, + ctrlOrCmd: true, + }, + }, + { + action: KeyAction.NavToggleUserMenu, + // Ideally this would be CTRL+P for "Profile", but that's + // taken by the print dialog. CTRL+I for "Information" + // was previously chosen but conflicted with italics in + // composer, so CTRL+` it is + keyCombo: { + key: Key.BACKTICK, + ctrlOrCmd: true, + }, + }, + { + action: KeyAction.NavToggleShortCutDialog, + keyCombo: { + key: Key.SLASH, + ctrlOrCmd: true, + }, + }, + { + action: KeyAction.NavToggleShortCutDialog, + keyCombo: { + key: Key.SLASH, + ctrlOrCmd: true, + shiftKey: true, + }, + }, + { + action: KeyAction.NavGoToHome, + keyCombo: { + key: Key.H, + ctrlOrCmd: true, + altKey: true, + }, + }, + + { + action: KeyAction.NavSelectPrevRoom, + keyCombo: { + key: Key.ARROW_UP, + altKey: true, + }, + }, + { + action: KeyAction.NavSelectNextRoom, + keyCombo: { + key: Key.ARROW_DOWN, + altKey: true, + }, + }, + { + action: KeyAction.NavSelectPrevUnreadRoom, + keyCombo: { + key: Key.ARROW_UP, + altKey: true, + shiftKey: true, + }, + }, + { + action: KeyAction.NavSelectNextUnreadRoom, + keyCombo: { + key: Key.ARROW_DOWN, + altKey: true, + shiftKey: true, + }, + }, ] } @@ -323,6 +564,9 @@ export class KeyBindingsManager { contextBindings: Record = { [KeyBindingContext.MessageComposer]: messageComposerBindings, [KeyBindingContext.AutoComplete]: autocompleteBindings, + [KeyBindingContext.RoomList]: roomListBindings, + [KeyBindingContext.Room]: roomBindings, + [KeyBindingContext.Navigation]: navigationBindings, }; /** diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index c76cd7cee7..dd8bc1f3db 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -21,7 +21,7 @@ import * as PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { DragDropContext } from 'react-beautiful-dnd'; -import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isMac} from '../../Keyboard'; +import {Key} from '../../Keyboard'; import PageTypes from '../../PageTypes'; import CallMediaHandler from '../../CallMediaHandler'; import { fixupColorFonts } from '../../utils/FontManager'; @@ -55,6 +55,7 @@ import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; import Modal from "../../Modal"; import { ICollapseConfig } from "../../resizer/distributors/collapse"; import HostSignupContainer from '../views/host_signup/HostSignupContainer'; +import { getKeyBindingsManager, KeyAction, KeyBindingContext } from '../../KeyBindingsManager'; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -399,86 +400,54 @@ class LoggedInView extends React.Component { _onKeyDown = (ev) => { let handled = false; - const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); - const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey; - const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT; - const modKey = isMac ? ev.metaKey : ev.ctrlKey; - switch (ev.key) { - case Key.PAGE_UP: - case Key.PAGE_DOWN: - if (!hasModifier && !isModifier) { - this._onScrollKeyPressed(ev); - handled = true; - } + const roomAction = getKeyBindingsManager().getAction(KeyBindingContext.Room, ev); + switch (roomAction) { + case KeyAction.RoomFocusRoomSearch: + dis.dispatch({ + action: 'focus_room_filter', + }); + handled = true; break; + case KeyAction.RoomScrollUp: + case KeyAction.RoomScrollDown: + case KeyAction.RoomJumpToFirstMessage: + case KeyAction.RoomJumpToLatestMessage: + this._onScrollKeyPressed(ev); + handled = true; + break; + case KeyAction.RoomSearch: + dis.dispatch({ + action: 'focus_search', + }); + handled = true; + break; + } + if (handled) { + ev.stopPropagation(); + ev.preventDefault(); + return; + } - case Key.HOME: - case Key.END: - if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - this._onScrollKeyPressed(ev); - handled = true; - } + const navAction = getKeyBindingsManager().getAction(KeyBindingContext.Navigation, ev); + switch (navAction) { + case KeyAction.NavToggleUserMenu: + dis.fire(Action.ToggleUserMenu); + handled = true; break; - case Key.K: - if (ctrlCmdOnly) { - dis.dispatch({ - action: 'focus_room_filter', - }); - handled = true; - } + case KeyAction.NavToggleShortCutDialog: + KeyboardShortcuts.toggleDialog(); + handled = true; break; - case Key.F: - if (ctrlCmdOnly && SettingsStore.getValue("ctrlFForSearch")) { - dis.dispatch({ - action: 'focus_search', - }); - handled = true; - } + case KeyAction.NavGoToHome: + dis.dispatch({ + action: 'view_home_page', + }); + Modal.closeCurrentModal("homeKeyboardShortcut"); + handled = true; break; - case Key.BACKTICK: - // Ideally this would be CTRL+P for "Profile", but that's - // taken by the print dialog. CTRL+I for "Information" - // was previously chosen but conflicted with italics in - // composer, so CTRL+` it is - - if (ctrlCmdOnly) { - dis.fire(Action.ToggleUserMenu); - handled = true; - } - break; - - case Key.SLASH: - if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev)) { - KeyboardShortcuts.toggleDialog(); - handled = true; - } - break; - - case Key.H: - if (ev.altKey && modKey) { - dis.dispatch({ - action: 'view_home_page', - }); - Modal.closeCurrentModal("homeKeyboardShortcut"); - handled = true; - } - break; - - case Key.ARROW_UP: - case Key.ARROW_DOWN: - if (ev.altKey && !ev.ctrlKey && !ev.metaKey) { - dis.dispatch({ - action: Action.ViewRoomDelta, - delta: ev.key === Key.ARROW_UP ? -1 : 1, - unread: ev.shiftKey, - }); - handled = true; - } - break; - - case Key.PERIOD: - if (ctrlCmdOnly && (this.props.page_type === "room_view" || this.props.page_type === "group_view")) { + case KeyAction.NavToggleRoomSidePanel: + if (this.props.page_type === "room_view" || this.props.page_type === "group_view") { dis.dispatch({ action: Action.ToggleRightPanel, type: this.props.page_type === "room_view" ? "room" : "group", @@ -486,16 +455,47 @@ class LoggedInView extends React.Component { handled = true; } break; - - default: - // if we do not have a handler for it, pass it to the platform which might - handled = PlatformPeg.get().onKeyDown(ev); + case KeyAction.NavSelectPrevRoom: + dis.dispatch({ + action: Action.ViewRoomDelta, + delta: -1, + unread: false, + }); + handled = true; + break; + case KeyAction.NavSelectNextRoom: + dis.dispatch({ + action: Action.ViewRoomDelta, + delta: 1, + unread: false, + }); + handled = true; + break; + case KeyAction.NavSelectPrevUnreadRoom: + dis.dispatch({ + action: Action.ViewRoomDelta, + delta: -1, + unread: true, + }); + break; + case KeyAction.NavSelectNextUnreadRoom: + dis.dispatch({ + action: Action.ViewRoomDelta, + delta: 1, + unread: true, + }); + break; } - + // if we do not have a handler for it, pass it to the platform which might + handled = PlatformPeg.get().onKeyDown(ev); if (handled) { ev.stopPropagation(); ev.preventDefault(); - } else if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) { + return; + } + + const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT; + if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) { // The above condition is crafted to _allow_ characters with Shift // already pressed (but not the Shift key down itself). diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index a64e40bc65..2e900d2f0e 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -20,11 +20,11 @@ import classNames from "classnames"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import { ActionPayload } from "../../dispatcher/payloads"; -import { Key } from "../../Keyboard"; import AccessibleButton from "../views/elements/AccessibleButton"; import { Action } from "../../dispatcher/actions"; import RoomListStore from "../../stores/room-list/RoomListStore"; import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition"; +import { getKeyBindingsManager, KeyAction, KeyBindingContext } from "../../KeyBindingsManager"; interface IProps { isMinimized: boolean; @@ -106,18 +106,25 @@ export default class RoomSearch extends React.PureComponent { }; private onKeyDown = (ev: React.KeyboardEvent) => { - if (ev.key === Key.ESCAPE) { - this.clearInput(); - defaultDispatcher.fire(Action.FocusComposer); - } else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) { - this.props.onVerticalArrow(ev); - } else if (ev.key === Key.ENTER) { - const shouldClear = this.props.onEnter(ev); - if (shouldClear) { - // wrap in set immediate to delay it so that we don't clear the filter & then change room - setImmediate(() => { - this.clearInput(); - }); + const action = getKeyBindingsManager().getAction(KeyBindingContext.RoomList, ev); + switch (action) { + case KeyAction.RoomListClearSearch: + this.clearInput(); + defaultDispatcher.fire(Action.FocusComposer); + break; + case KeyAction.RoomListNextRoom: + case KeyAction.RoomListPrevRoom: + this.props.onVerticalArrow(ev); + break; + case KeyAction.RoomListSelectRoom: { + const shouldClear = this.props.onEnter(ev); + if (shouldClear) { + // wrap in set immediate to delay it so that we don't clear the filter & then change room + setImmediate(() => { + this.clearInput(); + }); + } + break; } } }; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 7b72b7f33f..c09f1f7c45 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -41,7 +41,6 @@ import rateLimitedFunc from '../../ratelimitedfunc'; import * as ObjectUtils from '../../ObjectUtils'; import * as Rooms from '../../Rooms'; import eventSearch, { searchPagination } from '../../Searching'; -import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent, Key } from '../../Keyboard'; import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; import RoomViewStore from '../../stores/RoomViewStore'; @@ -79,6 +78,7 @@ import Notifier from "../../Notifier"; import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; +import { getKeyBindingsManager, KeyAction, KeyBindingContext } from '../../KeyBindingsManager'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -661,26 +661,20 @@ export default class RoomView extends React.Component { private onReactKeyDown = ev => { let handled = false; - switch (ev.key) { - case Key.ESCAPE: - if (!ev.altKey && !ev.ctrlKey && !ev.shiftKey && !ev.metaKey) { - this.messagePanel.forgetReadMarker(); - this.jumpToLiveTimeline(); - handled = true; - } + const action = getKeyBindingsManager().getAction(KeyBindingContext.Room, ev); + switch (action) { + case KeyAction.RoomDismissReadMarker: + this.messagePanel.forgetReadMarker(); + this.jumpToLiveTimeline(); + handled = true; break; - case Key.PAGE_UP: - if (!ev.altKey && !ev.ctrlKey && ev.shiftKey && !ev.metaKey) { - this.jumpToReadMarker(); - handled = true; - } + case KeyAction.RoomScrollUp: + this.jumpToReadMarker(); + handled = true; break; - case Key.U: // Mac returns lowercase - case Key.U.toUpperCase(): - if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) && ev.shiftKey) { - dis.dispatch({ action: "upload_file" }, true); - handled = true; - } + case KeyAction.RoomUploadFile: + dis.dispatch({ action: "upload_file" }, true); + handled = true; break; } diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index a2574bf60c..c0919090b0 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -51,6 +51,7 @@ import { objectExcluding, objectHasDiff } from "../../../utils/objects"; import TemporaryTile from "./TemporaryTile"; import { ListNotificationState } from "../../../stores/notifications/ListNotificationState"; import IconizedContextMenu from "../context_menus/IconizedContextMenu"; +import { getKeyBindingsManager, KeyAction, KeyBindingContext } from "../../../KeyBindingsManager"; const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS @@ -470,18 +471,19 @@ export default class RoomSublist extends React.Component { }; private onHeaderKeyDown = (ev: React.KeyboardEvent) => { - switch (ev.key) { - case Key.ARROW_LEFT: + const action = getKeyBindingsManager().getAction(KeyBindingContext.RoomList, ev); + switch (action) { + case KeyAction.RoomListCollapseSection: ev.stopPropagation(); if (this.state.isExpanded) { - // On ARROW_LEFT collapse the room sublist if it isn't already + // Collapse the room sublist if it isn't already this.toggleCollapsed(); } break; - case Key.ARROW_RIGHT: { + case KeyAction.RoomListExpandSection: { ev.stopPropagation(); if (!this.state.isExpanded) { - // On ARROW_RIGHT expand the room sublist if it isn't already + // Expand the room sublist if it isn't already this.toggleCollapsed(); } else if (this.sublistRef.current) { // otherwise focus the first room