From cad28c81c00ec434dd06c31bc102f4ba25a424c9 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Wed, 18 Mar 2020 16:40:21 +0000
Subject: [PATCH] Add Keyboard shortcuts dialog

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---
 package.json                                  |   1 +
 res/css/_components.scss                      |   1 +
 .../dialogs/_KeyboardShortcutsDialog.scss     |  65 ++++
 src/{Keyboard.js => Keyboard.ts}              |   5 +-
 src/accessibility/KeyboardShortcuts.tsx       | 313 ++++++++++++++++++
 src/components/structures/LoggedInView.js     |  10 +-
 src/components/views/dialogs/InfoDialog.js    |   6 +-
 .../settings/tabs/user/HelpUserSettingsTab.js |   4 +
 src/i18n/strings/en_EN.json                   |  40 ++-
 yarn.lock                                     |   5 +
 10 files changed, 444 insertions(+), 6 deletions(-)
 create mode 100644 res/css/views/dialogs/_KeyboardShortcutsDialog.scss
 rename src/{Keyboard.js => Keyboard.ts} (92%)
 create mode 100644 src/accessibility/KeyboardShortcuts.tsx

diff --git a/package.json b/package.json
index 8cda349e03..2316482f87 100644
--- a/package.json
+++ b/package.json
@@ -118,6 +118,7 @@
     "@babel/preset-typescript": "^7.7.4",
     "@babel/register": "^7.7.4",
     "@peculiar/webcrypto": "^1.0.22",
+    "@types/classnames": "^2.2.10",
     "@types/react": "16.9",
     "babel-eslint": "^10.0.3",
     "babel-jest": "^24.9.0",
diff --git a/res/css/_components.scss b/res/css/_components.scss
index bc636eb3c6..8f05394a48 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -65,6 +65,7 @@
 @import "./views/dialogs/_GroupAddressPicker.scss";
 @import "./views/dialogs/_IncomingSasDialog.scss";
 @import "./views/dialogs/_InviteDialog.scss";
+@import "./views/dialogs/_KeyboardShortcutsDialog.scss";
 @import "./views/dialogs/_MessageEditHistoryDialog.scss";
 @import "./views/dialogs/_NewSessionReviewDialog.scss";
 @import "./views/dialogs/_RoomSettingsDialog.scss";
diff --git a/res/css/views/dialogs/_KeyboardShortcutsDialog.scss b/res/css/views/dialogs/_KeyboardShortcutsDialog.scss
new file mode 100644
index 0000000000..231623f6ef
--- /dev/null
+++ b/res/css/views/dialogs/_KeyboardShortcutsDialog.scss
@@ -0,0 +1,65 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_KeyboardShortcutsDialog {
+    display: flex;
+    flex-wrap: wrap;
+    -webkit-box-orient: vertical;
+    -webkit-box-direction: normal;
+    flex-direction: column;
+    margin-bottom: -50px;
+    max-height: 700px; // XXX: this may need adjusting when adding new shortcuts
+
+    .mx_KeyboardShortcutsDialog_category {
+        width: 33.3333%; // 3 columns
+        margin: 0 0 40px;
+
+        & > div {
+            padding-left: 5px;
+        }
+    }
+
+    h3 {
+        margin: 0 0 10px;
+    }
+
+    h5 {
+        margin: 15px 0 5px;
+        font-weight: normal;
+    }
+
+    kbd {
+        padding: 5px;
+        border-radius: 4px;
+        background: $roomheader-addroom-bg-color;
+        margin-right: 5px;
+        min-width: 20px;
+        text-align: center;
+        display: inline-block;
+        border: 1px solid black;
+        box-shadow: 0 2px black;
+        margin-bottom: 4px;
+        text-transform: capitalize;
+
+        & + kbd {
+            margin-left: 5px;
+        }
+    }
+
+    .mx_KeyboardShortcutsDialog_inline div {
+        display: inline;
+    }
+}
diff --git a/src/Keyboard.js b/src/Keyboard.ts
similarity index 92%
rename from src/Keyboard.js
rename to src/Keyboard.ts
index 478d75acc1..f5cf0a5492 100644
--- a/src/Keyboard.js
+++ b/src/Keyboard.ts
@@ -40,6 +40,7 @@ export const Key = {
     GREATER_THAN: ">",
     BACKTICK: "`",
     SPACE: " ",
+    SLASH: "/",
     A: "a",
     B: "b",
     C: "c",
@@ -68,8 +69,9 @@ export const Key = {
     Z: "z",
 };
 
+export const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
+
 export function isOnlyCtrlOrCmdKeyEvent(ev) {
-    const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
     if (isMac) {
         return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
     } else {
@@ -78,7 +80,6 @@ export function isOnlyCtrlOrCmdKeyEvent(ev) {
 }
 
 export function isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) {
-    const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
     if (isMac) {
         return ev.metaKey && !ev.altKey && !ev.ctrlKey;
     } else {
diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx
new file mode 100644
index 0000000000..ceec3f94bc
--- /dev/null
+++ b/src/accessibility/KeyboardShortcuts.tsx
@@ -0,0 +1,313 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import * as React from "react";
+import classNames from "classnames";
+
+import * as sdk from "../index";
+import Modal from "../Modal";
+import { _t, _td } from "../languageHandler";
+import {isMac, Key} from "../Keyboard";
+
+// TS: once languageHandler is TS we can probably inline this into the enum
+_td("Navigation");
+_td("Calls");
+_td("Composer");
+_td("Room List");
+_td("Autocomplete");
+
+export enum Categories {
+    NAVIGATION="Navigation",
+    CALLS="Calls",
+    COMPOSER="Composer",
+    ROOM_LIST="Room List",
+    AUTOCOMPLETE="Autocomplete",
+}
+
+// TS: once languageHandler is TS we can probably inline this into the enum
+_td("Alt");
+_td("Alt Gr");
+_td("Shift");
+_td("Super");
+_td("Ctrl");
+
+export enum Modifiers {
+    ALT="Alt",
+    OPTION="Option", // This gets displayed as an Icon
+    ALT_GR="Alt Gr",
+    SHIFT="Shift",
+    SUPER="Super", // should this be "Windows"?
+    // Instead of using below, consider CMD_OR_CTRL
+    COMMAND="Command", // This gets displayed as an Icon
+    CONTROL="Ctrl",
+}
+
+// Meta-modifier: isMac ? CMD : CONTROL
+export const CMD_OR_CTRL = isMac ? Modifiers.COMMAND : Modifiers.CONTROL;
+
+interface IKeybind {
+    modifiers?: Modifiers[];
+    key: string; // TS: fix this once Key is an enum
+}
+
+interface IShortcut {
+    keybinds: IKeybind[];
+    description: string;
+}
+
+const shortcuts: Record<Categories, IShortcut[]> = {
+    [Categories.COMPOSER]: [
+        {
+            keybinds: [{
+                modifiers: [CMD_OR_CTRL],
+                key: Key.B,
+            }],
+            description: _td("Toggle Bold"),
+        }, {
+            keybinds: [{
+                modifiers: [CMD_OR_CTRL],
+                key: Key.I,
+            }],
+            description: _td("Toggle Italics"),
+        }, {
+            keybinds: [{
+                modifiers: [CMD_OR_CTRL],
+                key: Key.GREATER_THAN,
+            }],
+            description: _td("Toggle Quote"),
+        }, {
+            keybinds: [{
+                modifiers: [CMD_OR_CTRL],
+                key: Key.M,
+            }],
+            description: _td("Toggle Markdown"),
+        }, {
+            keybinds: [{
+                modifiers: [Modifiers.SHIFT],
+                key: Key.ENTER,
+            }],
+            description: _td("New line"),
+        }, {
+            keybinds: [{
+                key: Key.ARROW_UP,
+            }, {
+                key: Key.ARROW_DOWN,
+            }],
+            description: _td("Navigate recent messages to edit"),
+        }, {
+            keybinds: [{
+                modifiers: [CMD_OR_CTRL],
+                key: Key.HOME,
+            }, {
+                modifiers: [CMD_OR_CTRL],
+                key: Key.END,
+            }],
+            description: _td("Jump to start/end of the composer"),
+        },
+    ],
+
+    [Categories.CALLS]: [
+        {
+            keybinds: [{
+                modifiers: [CMD_OR_CTRL],
+                key: Key.D,
+            }],
+            description: _td("Toggle microphone mute"),
+        }, {
+            keybinds: [{
+                modifiers: [CMD_OR_CTRL],
+                key: Key.E,
+            }],
+            description: _td("Toggle video on/off"),
+        },
+    ],
+
+    [Categories.ROOM_LIST]: [
+        {
+            keybinds: [{
+                modifiers: [CMD_OR_CTRL],
+                key: Key.K,
+            }],
+            description: _td("Jump to room search"),
+        }, {
+            keybinds: [{
+                key: Key.ARROW_UP,
+            }, {
+                key: Key.ARROW_DOWN,
+            }],
+            description: _td("Navigate up/down in the room list"),
+        }, {
+            keybinds: [{
+                key: Key.ENTER,
+            }],
+            description: _td("Select room from the room list"),
+        }, {
+            keybinds: [{
+                key: Key.ARROW_LEFT,
+            }],
+            description: _td("Collapse room list section"),
+        }, {
+            keybinds: [{
+                key: Key.ARROW_RIGHT,
+            }],
+            description: _td("Expand room list section"),
+        }, {
+            keybinds: [{
+                key: Key.ESCAPE,
+            }],
+            description: _td("Clear room list filter field"),
+        },
+    ],
+
+    [Categories.NAVIGATION]: [
+        {
+            keybinds: [{
+                key: Key.PAGE_UP,
+            }, {
+                key: Key.PAGE_DOWN,
+            }],
+            description: _td("Scroll up/down in the timeline"),
+        }, {
+            keybinds: [{
+                modifiers: [CMD_OR_CTRL],
+                key: Key.BACKTICK,
+            }],
+            description: _td("Toggle the top left menu"),
+        }, {
+            keybinds: [{
+                key: Key.ESCAPE,
+            }],
+            description: _td("Close dialog or context menu"),
+        }, {
+            keybinds: [{
+                key: Key.ENTER,
+            }, {
+                key: Key.SPACE,
+            }],
+            description: _td("Activate selected button"),
+        }, {
+            keybinds: [{
+                modifiers: [CMD_OR_CTRL],
+                key: Key.SLASH,
+            }],
+            description: _td("Toggle this dialog"),
+        },
+    ],
+
+    [Categories.AUTOCOMPLETE]: [
+        {
+            keybinds: [{
+                key: Key.ARROW_UP,
+            }, {
+                key: Key.ARROW_DOWN,
+            }],
+            description: _td("Move autocomplete selection up/down"),
+        }, {
+            keybinds: [{
+                key: Key.ESCAPE,
+            }],
+            description: _td("Cancel autocomplete"),
+        },
+    ],
+};
+
+interface IModal {
+    close: () => void;
+    finished: Promise<any[]>;
+}
+
+const modifierIcon: Record<Modifiers, string> = {
+    [Modifiers.COMMAND]: "⌘",
+    [Modifiers.OPTION]: "⌥",
+};
+
+const alternateKeyName: Record<string, string> = { // TS: fix this once Key is an enum
+    [Key.PAGE_UP]: _td("Page Up"),
+    [Key.PAGE_DOWN]: _td("Page Down"),
+    [Key.ESCAPE]: _td("Esc"),
+    [Key.ENTER]: _td("Enter"),
+    [Key.SPACE]: _td("Space"),
+    [Key.HOME]: _td("Home"),
+    [Key.END]: _td("End"),
+};
+const keyIcon: Record<string, string> = { // TS: fix this once Key is an enum
+    [Key.ARROW_UP]: "↑",
+    [Key.ARROW_DOWN]: "↓",
+    [Key.ARROW_LEFT]: "←",
+    [Key.ARROW_RIGHT]: "→",
+};
+
+const Shortcut: React.FC<{
+    shortcut: IShortcut;
+}> = ({shortcut}) => {
+    const classes = classNames({
+        "mx_KeyboardShortcutsDialog_inline": shortcut.keybinds.every(k => !k.modifiers || k.modifiers.length === 0),
+    });
+
+    return <div className={classes}>
+        <h5>{ _t(shortcut.description) }</h5>
+        { shortcut.keybinds.map(s => {
+            let text = s.key;
+            if (alternateKeyName[s.key]) {
+                text = _t(alternateKeyName[s.key]);
+            } else if (keyIcon[s.key]) {
+                text = keyIcon[s.key];
+            }
+
+            return <div key={s.key}>
+                { s.modifiers && s.modifiers.map(m => {
+                    return <React.Fragment key={m}>
+                        <kbd>{ modifierIcon[m] || _t(m) }</kbd>+
+                    </React.Fragment>;
+                }) }
+                <kbd>{ text }</kbd>
+            </div>;
+        }) }
+    </div>;
+};
+
+let activeModal: IModal = null;
+export const toggleDialog = () => {
+    if (activeModal) {
+        activeModal.close();
+        activeModal = null;
+        return;
+    }
+
+    const sections = Object.entries(shortcuts).map(([category, list]) => {
+        return <div className="mx_KeyboardShortcutsDialog_category" key={category}>
+            <h3>{_t(category)}</h3>
+            <div>{list.map(shortcut => <Shortcut key={shortcut.description} shortcut={shortcut} />)}</div>
+        </div>;
+    });
+
+    const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
+    activeModal = Modal.createTrackedDialog("Keyboard Shortcuts", "", InfoDialog, {
+        className: "mx_KeyboardShortcutsDialog",
+        title: _t("Keyboard Shortcuts"),
+        description: sections,
+        hasCloseButton: true,
+        onKeyDown: (ev) => {
+            if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.key === Key.SLASH) { // Ctrl + /
+                ev.stopPropagation();
+                activeModal.close();
+            }
+        },
+        onFinished: () => {
+            activeModal = null;
+        },
+    });
+};
diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js
index d643c82120..576ae2b276 100644
--- a/src/components/structures/LoggedInView.js
+++ b/src/components/structures/LoggedInView.js
@@ -39,6 +39,7 @@ import RoomListActions from '../../actions/RoomListActions';
 import ResizeHandle from '../views/elements/ResizeHandle';
 import {Resizer, CollapseDistributor} from '../../resizer';
 import MatrixClientContext from "../../contexts/MatrixClientContext";
+import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts";
 // 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.
 // NB. this is just for server notices rather than pinned messages in general.
@@ -365,8 +366,6 @@ const LoggedInView = createReactClass({
                 }
                 break;
             case Key.BACKTICK:
-                if (ev.key !== "`") break;
-
                 // 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
@@ -379,6 +378,13 @@ const LoggedInView = createReactClass({
                     handled = true;
                 }
                 break;
+
+            case Key.SLASH:
+                if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
+                    KeyboardShortcuts.toggleDialog();
+                    handled = true;
+                }
+                break;
         }
 
         if (handled) {
diff --git a/src/components/views/dialogs/InfoDialog.js b/src/components/views/dialogs/InfoDialog.js
index 8a8f51c25a..b63f6ba9c6 100644
--- a/src/components/views/dialogs/InfoDialog.js
+++ b/src/components/views/dialogs/InfoDialog.js
@@ -32,6 +32,7 @@ export default createReactClass({
         button: PropTypes.string,
         onFinished: PropTypes.func,
         hasCloseButton: PropTypes.bool,
+        onKeyDown: PropTypes.func,
     },
 
     getDefaultProps: function() {
@@ -50,10 +51,13 @@ export default createReactClass({
         const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
         const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
         return (
-            <BaseDialog className="mx_InfoDialog" onFinished={this.props.onFinished}
+            <BaseDialog
+                className="mx_InfoDialog"
+                onFinished={this.props.onFinished}
                 title={this.props.title}
                 contentId='mx_Dialog_content'
                 hasCancel={this.props.hasCloseButton}
+                onKeyDown={this.props.onKeyDown}
             >
                 <div className={classNames("mx_Dialog_content", this.props.className)} id="mx_Dialog_content">
                     { this.props.description }
diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.js b/src/components/views/settings/tabs/user/HelpUserSettingsTab.js
index aca2f010b6..9a2db8113e 100644
--- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.js
+++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.js
@@ -24,6 +24,7 @@ import createRoom from "../../../../../createRoom";
 import Modal from "../../../../../Modal";
 import * as sdk from "../../../../../";
 import PlatformPeg from "../../../../../PlatformPeg";
+import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
 
 export default class HelpUserSettingsTab extends React.Component {
     static propTypes = {
@@ -224,6 +225,9 @@ export default class HelpUserSettingsTab extends React.Component {
                     <div className='mx_SettingsTab_subsectionText'>
                         {faqText}
                     </div>
+                    <AccessibleButton kind="primary" onClick={KeyboardShortcuts.toggleDialog}>
+                        { _t("Keyboard Shortcuts") }
+                    </AccessibleButton>
                 </div>
                 <div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
                     <span className='mx_SettingsTab_subheading'>{_t("Versions")}</span>
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index d9b8f4f0bd..cdfc3cbb89 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -742,6 +742,7 @@
     "Clear cache and reload": "Clear cache and reload",
     "To report a Matrix-related security issue, please read the Matrix.org <a>Security Disclosure Policy</a>.": "To report a Matrix-related security issue, please read the Matrix.org <a>Security Disclosure Policy</a>.",
     "FAQ": "FAQ",
+    "Keyboard Shortcuts": "Keyboard Shortcuts",
     "Versions": "Versions",
     "riot-web version:": "riot-web version:",
     "olm version:": "olm version:",
@@ -2155,5 +2156,42 @@
     "Message downloading sleep time(ms)": "Message downloading sleep time(ms)",
     "Failed to set direct chat tag": "Failed to set direct chat tag",
     "Failed to remove tag %(tagName)s from room": "Failed to remove tag %(tagName)s from room",
-    "Failed to add tag %(tagName)s to room": "Failed to add tag %(tagName)s to room"
+    "Failed to add tag %(tagName)s to room": "Failed to add tag %(tagName)s to room",
+    "Navigation": "Navigation",
+    "Calls": "Calls",
+    "Room List": "Room List",
+    "Autocomplete": "Autocomplete",
+    "Alt": "Alt",
+    "Alt Gr": "Alt Gr",
+    "Shift": "Shift",
+    "Super": "Super",
+    "Ctrl": "Ctrl",
+    "Toggle Bold": "Toggle Bold",
+    "Toggle Italics": "Toggle Italics",
+    "Toggle Quote": "Toggle Quote",
+    "Toggle Markdown": "Toggle Markdown",
+    "New line": "New line",
+    "Navigate recent messages to edit": "Navigate recent messages to edit",
+    "Jump to start/end of the composer": "Jump to start/end of the composer",
+    "Toggle microphone mute": "Toggle microphone mute",
+    "Toggle video on/off": "Toggle video on/off",
+    "Jump to room search": "Jump to room search",
+    "Navigate up/down in the room list": "Navigate up/down in the room list",
+    "Select room from the room list": "Select room from the room list",
+    "Collapse room list section": "Collapse room list section",
+    "Expand room list section": "Expand room list section",
+    "Clear room list filter field": "Clear room list filter field",
+    "Scroll up/down in the timeline": "Scroll up/down in the timeline",
+    "Toggle the top left menu": "Toggle the top left menu",
+    "Close dialog or context menu": "Close dialog or context menu",
+    "Activate selected button": "Activate selected button",
+    "Toggle this dialog": "Toggle this dialog",
+    "Move autocomplete selection up/down": "Move autocomplete selection up/down",
+    "Cancel autocomplete": "Cancel autocomplete",
+    "Page Up": "Page Up",
+    "Page Down": "Page Down",
+    "Esc": "Esc",
+    "Enter": "Enter",
+    "Space": "Space",
+    "End": "End"
 }
diff --git a/yarn.lock b/yarn.lock
index ac511949ce..ac921a619a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1178,6 +1178,11 @@
   dependencies:
     "@babel/types" "^7.3.0"
 
+"@types/classnames@^2.2.10":
+  version "2.2.10"
+  resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999"
+  integrity sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ==
+
 "@types/events@*":
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"