From c7f9defd12491a962d2012eb07b5e37bc60f17a6 Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Thu, 11 Feb 2021 22:18:10 +1300 Subject: [PATCH 001/104] Add simple implementation of a KeyBindingsManager + match tests --- src/KeyBindingsManager.ts | 102 ++++++++++++++++++++++++ test/KeyBindingsManager-test.ts | 137 ++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 src/KeyBindingsManager.ts create mode 100644 test/KeyBindingsManager-test.ts diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts new file mode 100644 index 0000000000..b9cf9749ca --- /dev/null +++ b/src/KeyBindingsManager.ts @@ -0,0 +1,102 @@ +import { isMac } from "./Keyboard"; + +export enum KeyBindingContext { + +} + +export enum KeyAction { + None = 'None', +} + +/** + * Represent a key combination. + * + * The combo is evaluated strictly, i.e. the KeyboardEvent must match the exactly what is specified in the KeyCombo. + */ +export type KeyCombo = { + /** Currently only one `normal` key is supported */ + keys: string[]; + + /** On PC: ctrl is pressed; on Mac: meta is pressed */ + ctrlOrCmd?: boolean; + + altKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + shiftKey?: boolean; +} + +export type KeyBinding = { + keyCombo: KeyCombo; + action: KeyAction; +} + +/** + * Helper method to check if a KeyboardEvent matches a KeyCombo + * + * Note, this method is only exported for testing. + */ +export function isKeyComboMatch(ev: KeyboardEvent, combo: KeyCombo, onMac: boolean): boolean { + if (combo.keys.length > 0 && ev.key !== combo.keys[0]) { + return false; + } + + const comboCtrl = combo.ctrlKey ?? false; + const comboAlt = combo.altKey ?? false; + const comboShift = combo.shiftKey ?? false; + const comboMeta = combo.metaKey ?? false; + // When ctrlOrCmd is set, the keys need do evaluated differently on PC and Mac + if (combo.ctrlOrCmd) { + if (onMac) { + if (!ev.metaKey + || ev.ctrlKey !== comboCtrl + || ev.altKey !== comboAlt + || ev.shiftKey !== comboShift) { + return false; + } + } else { + if (!ev.ctrlKey + || ev.metaKey !== comboMeta + || ev.altKey !== comboAlt + || ev.shiftKey !== comboShift) { + return false; + } + } + return true; + } + + if (ev.metaKey !== comboMeta + || ev.ctrlKey !== comboCtrl + || ev.altKey !== comboAlt + || ev.shiftKey !== comboShift) { + return false; + } + + return true; +} + +export class KeyBindingsManager { + contextBindings: Record = {}; + + /** + * Finds a matching KeyAction for a given KeyboardEvent + */ + getAction(context: KeyBindingContext, ev: KeyboardEvent): KeyAction { + const bindings = this.contextBindings[context]; + if (!bindings) { + return KeyAction.None; + } + const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac)); + if (binding) { + return binding.action; + } + + return KeyAction.None; + } +} + +const manager = new KeyBindingsManager(); + +export function getKeyBindingsManager(): KeyBindingsManager { + return manager; +} diff --git a/test/KeyBindingsManager-test.ts b/test/KeyBindingsManager-test.ts new file mode 100644 index 0000000000..f272878658 --- /dev/null +++ b/test/KeyBindingsManager-test.ts @@ -0,0 +1,137 @@ +import { isKeyComboMatch, KeyCombo } from '../src/KeyBindingsManager'; +const assert = require('assert'); + +function mockKeyEvent(key: string, modifiers?: { + ctrlKey?: boolean, + altKey?: boolean, + shiftKey?: boolean, + metaKey?: boolean +}): KeyboardEvent { + return { + key, + ctrlKey: modifiers?.ctrlKey ?? false, + altKey: modifiers?.altKey ?? false, + shiftKey: modifiers?.shiftKey ?? false, + metaKey: modifiers?.metaKey ?? false + } as KeyboardEvent; +} + +describe('KeyBindingsManager', () => { + it('should match basic key combo', () => { + const combo1: KeyCombo = { + keys: ['k'], + }; + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k'), combo1, false), true); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('n'), combo1, false), false); + + }); + + it('should match key + modifier key combo', () => { + const combo: KeyCombo = { + keys: ['k'], + ctrlKey: true, + }; + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true }), combo, false), true); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { ctrlKey: true }), combo, false), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k'), combo, false), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { shiftKey: true }), combo, false), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { shiftKey: true, metaKey: true }), combo, false), false); + + const combo2: KeyCombo = { + keys: ['k'], + metaKey: true, + }; + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { metaKey: true }), combo2, false), true); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { metaKey: true }), combo2, false), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k'), combo2, false), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { altKey: true, metaKey: true }), combo2, false), false); + + const combo3: KeyCombo = { + keys: ['k'], + altKey: true, + }; + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { altKey: true }), combo3, false), true); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { altKey: true }), combo3, false), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k'), combo3, false), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, metaKey: true }), combo3, false), false); + + const combo4: KeyCombo = { + keys: ['k'], + shiftKey: true, + }; + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { shiftKey: true }), combo4, false), true); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { shiftKey: true }), combo4, false), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k'), combo4, false), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { shiftKey: true, ctrlKey: true }), combo4, false), false); + }); + + it('should match key + multiple modifiers key combo', () => { + const combo: KeyCombo = { + keys: ['k'], + ctrlKey: true, + altKey: true, + }; + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, altKey: true }), combo, false), true); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { ctrlKey: true, altKey: true }), combo, false), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, metaKey: true }), combo, false), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, metaKey: true, shiftKey: true }), combo, + false), false); + + const combo2: KeyCombo = { + keys: ['k'], + ctrlKey: true, + shiftKey: true, + altKey: true, + }; + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, shiftKey: true, altKey: true }), combo2, + false), true); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { ctrlKey: true, shiftKey: true, altKey: true }), combo2, + false), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, metaKey: true }), combo2, false), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', + { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }), combo2, false), false); + + const combo3: KeyCombo = { + keys: ['k'], + ctrlKey: true, + shiftKey: true, + altKey: true, + metaKey: true, + }; + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', + { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }), combo3, false), true); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', + { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }), combo3, false), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', + { ctrlKey: true, shiftKey: true, altKey: true }), combo3, false), false); + }); + + it('should match ctrlOrMeta key combo', () => { + const combo: KeyCombo = { + keys: ['k'], + ctrlOrCmd: true, + }; + // PC: + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true }), combo, false), true); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { metaKey: true }), combo, false), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { ctrlKey: true }), combo, false), false); + // MAC: + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { metaKey: true }), combo, true), true); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true }), combo, true), false); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { ctrlKey: true }), combo, true), false); + }); + + it('should match advanced ctrlOrMeta key combo', () => { + const combo: KeyCombo = { + keys: ['k'], + ctrlOrCmd: true, + altKey: true, + }; + // PC: + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, altKey: true }), combo, false), true); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { metaKey: true, altKey: true }), combo, false), false); + // MAC: + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { metaKey: true, altKey: true }), combo, true), true); + assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, altKey: true }), combo, true), false); + }); +}); From b4c5dec4e5f7e904b6eb54f767c0c3b698e0a40e Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Sun, 14 Feb 2021 15:56:55 +1300 Subject: [PATCH 002/104] Use the KeyBindingsManager for the SendMessageComposer --- src/KeyBindingsManager.ts | 73 +++++++++++++-- .../views/rooms/SendMessageComposer.js | 91 ++++++++----------- 2 files changed, 107 insertions(+), 57 deletions(-) diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index b9cf9749ca..c32610670d 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -1,17 +1,23 @@ -import { isMac } from "./Keyboard"; +import { isMac, Key } from './Keyboard'; +import SettingsStore from './settings/SettingsStore'; export enum KeyBindingContext { - + SendMessageComposer = 'SendMessageComposer', } export enum KeyAction { None = 'None', + // SendMessageComposer actions: + Send = 'Send', + SelectPrevSendHistory = 'SelectPrevSendHistory', + SelectNextSendHistory = 'SelectNextSendHistory', + EditLastMessage = 'EditLastMessage', } /** * Represent a key combination. * - * The combo is evaluated strictly, i.e. the KeyboardEvent must match the exactly what is specified in the KeyCombo. + * The combo is evaluated strictly, i.e. the KeyboardEvent must match exactly what is specified in the KeyCombo. */ export type KeyCombo = { /** Currently only one `normal` key is supported */ @@ -27,8 +33,53 @@ export type KeyCombo = { } export type KeyBinding = { - keyCombo: KeyCombo; action: KeyAction; + keyCombo: KeyCombo; +} + +const messageComposerBindings = (): KeyBinding[] => { + const bindings: KeyBinding[] = [ + { + action: KeyAction.SelectPrevSendHistory, + keyCombo: { + keys: [Key.ARROW_UP], + altKey: true, + ctrlKey: true, + }, + }, + { + action: KeyAction.SelectNextSendHistory, + keyCombo: { + keys: [Key.ARROW_DOWN], + altKey: true, + ctrlKey: true, + }, + }, + { + action: KeyAction.EditLastMessage, + keyCombo: { + keys: [Key.ARROW_UP], + } + }, + ]; + if (SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend')) { + bindings.push({ + action: KeyAction.Send, + keyCombo: { + keys: [Key.ENTER], + ctrlOrCmd: true, + }, + }); + } else { + bindings.push({ + action: KeyAction.Send, + keyCombo: { + keys: [Key.ENTER], + }, + }); + } + + return bindings; } /** @@ -75,14 +126,24 @@ export function isKeyComboMatch(ev: KeyboardEvent, combo: KeyCombo, onMac: boole return true; } +export type KeyBindingsGetter = () => KeyBinding[]; + export class KeyBindingsManager { - contextBindings: Record = {}; + /** + * Map of KeyBindingContext to a KeyBinding getter arrow function. + * + * Returning a getter function allowed to have dynamic bindings, e.g. when settings change the bindings can be + * recalculated. + */ + contextBindings: Record = { + [KeyBindingContext.SendMessageComposer]: messageComposerBindings, + }; /** * Finds a matching KeyAction for a given KeyboardEvent */ getAction(context: KeyBindingContext, ev: KeyboardEvent): KeyAction { - const bindings = this.contextBindings[context]; + const bindings = this.contextBindings[context]?.(); if (!bindings) { return KeyAction.None; } diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 62c474e417..0559c71e9e 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -48,6 +48,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import CountlyAnalytics from "../../../CountlyAnalytics"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import EMOJI_REGEX from 'emojibase-regex'; +import { getKeyBindingsManager, KeyAction, KeyBindingContext } from '../../../KeyBindingsManager'; function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); @@ -144,60 +145,48 @@ export default class SendMessageComposer extends React.Component { if (this._editorRef.isComposing(event)) { return; } - const hasModifier = event.altKey || event.ctrlKey || event.metaKey || event.shiftKey; - const ctrlEnterToSend = !!SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend'); - const send = ctrlEnterToSend - ? event.key === Key.ENTER && isOnlyCtrlOrCmdKeyEvent(event) - : event.key === Key.ENTER && !hasModifier; - if (send) { - this._sendMessage(); - event.preventDefault(); - } else if (event.key === Key.ARROW_UP) { - this.onVerticalArrow(event, true); - } else if (event.key === Key.ARROW_DOWN) { - this.onVerticalArrow(event, false); - } else if (event.key === Key.ESCAPE) { - dis.dispatch({ - action: 'reply_to_event', - event: null, - }); - } else if (this._prepareToEncrypt) { - // This needs to be last! - this._prepareToEncrypt(); + const action = getKeyBindingsManager().getAction(KeyBindingContext.MessageComposer, event); + switch (action) { + case KeyAction.Send: + this._sendMessage(); + event.preventDefault(); + break; + case KeyAction.SelectPrevSendHistory: + case KeyAction.SelectNextSendHistory: + // Try select composer history + const selected = this.selectSendHistory(action === KeyAction.SelectPrevSendHistory); + if (selected) { + // We're selecting history, so prevent the key event from doing anything else + event.preventDefault(); + } + break; + case KeyAction.EditLastMessage: + // selection must be collapsed and caret at start + if (this._editorRef.isSelectionCollapsed() && this._editorRef.isCaretAtStart()) { + const editEvent = findEditableEvent(this.props.room, false); + if (editEvent) { + // We're selecting history, so prevent the key event from doing anything else + event.preventDefault(); + dis.dispatch({ + action: 'edit_event', + event: editEvent, + }); + } + } + break; + default: + if (event.key === Key.ESCAPE) { + dis.dispatch({ + action: 'reply_to_event', + event: null, + }); + } else if (this._prepareToEncrypt) { + // This needs to be last! + this._prepareToEncrypt(); + } } }; - onVerticalArrow(e, up) { - // arrows from an initial-caret composer navigates recent messages to edit - // ctrl-alt-arrows navigate send history - if (e.shiftKey || e.metaKey) return; - - const shouldSelectHistory = e.altKey && e.ctrlKey; - const shouldEditLastMessage = !e.altKey && !e.ctrlKey && up && !this.props.replyToEvent; - - if (shouldSelectHistory) { - // Try select composer history - const selected = this.selectSendHistory(up); - if (selected) { - // We're selecting history, so prevent the key event from doing anything else - e.preventDefault(); - } - } else if (shouldEditLastMessage) { - // selection must be collapsed and caret at start - if (this._editorRef.isSelectionCollapsed() && this._editorRef.isCaretAtStart()) { - const editEvent = findEditableEvent(this.props.room, false); - if (editEvent) { - // We're selecting history, so prevent the key event from doing anything else - e.preventDefault(); - dis.dispatch({ - action: 'edit_event', - event: editEvent, - }); - } - } - } - } - // we keep sent messages/commands in a separate history (separate from undo history) // so you can alt+up/down in them selectSendHistory(up) { From 4a138f3b84f4346ebde24c37a7cf2a22e3490b8e Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Mon, 15 Feb 2021 19:21:08 +1300 Subject: [PATCH 003/104] Only support a single key in the KeyCombo Keep it simple... --- src/KeyBindingsManager.ts | 15 +++++++-------- test/KeyBindingsManager-test.ts | 20 ++++++++++---------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index c32610670d..030cd94e99 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -20,8 +20,7 @@ export enum KeyAction { * The combo is evaluated strictly, i.e. the KeyboardEvent must match exactly what is specified in the KeyCombo. */ export type KeyCombo = { - /** Currently only one `normal` key is supported */ - keys: string[]; + key?: string; /** On PC: ctrl is pressed; on Mac: meta is pressed */ ctrlOrCmd?: boolean; @@ -42,7 +41,7 @@ const messageComposerBindings = (): KeyBinding[] => { { action: KeyAction.SelectPrevSendHistory, keyCombo: { - keys: [Key.ARROW_UP], + key: Key.ARROW_UP, altKey: true, ctrlKey: true, }, @@ -50,7 +49,7 @@ const messageComposerBindings = (): KeyBinding[] => { { action: KeyAction.SelectNextSendHistory, keyCombo: { - keys: [Key.ARROW_DOWN], + key: Key.ARROW_DOWN, altKey: true, ctrlKey: true, }, @@ -58,7 +57,7 @@ const messageComposerBindings = (): KeyBinding[] => { { action: KeyAction.EditLastMessage, keyCombo: { - keys: [Key.ARROW_UP], + key: Key.ARROW_UP, } }, ]; @@ -66,7 +65,7 @@ const messageComposerBindings = (): KeyBinding[] => { bindings.push({ action: KeyAction.Send, keyCombo: { - keys: [Key.ENTER], + key: Key.ENTER, ctrlOrCmd: true, }, }); @@ -74,7 +73,7 @@ const messageComposerBindings = (): KeyBinding[] => { bindings.push({ action: KeyAction.Send, keyCombo: { - keys: [Key.ENTER], + key: Key.ENTER, }, }); } @@ -88,7 +87,7 @@ const messageComposerBindings = (): KeyBinding[] => { * Note, this method is only exported for testing. */ export function isKeyComboMatch(ev: KeyboardEvent, combo: KeyCombo, onMac: boolean): boolean { - if (combo.keys.length > 0 && ev.key !== combo.keys[0]) { + if (combo.key !== undefined && ev.key !== combo.key) { return false; } diff --git a/test/KeyBindingsManager-test.ts b/test/KeyBindingsManager-test.ts index f272878658..28204be9c8 100644 --- a/test/KeyBindingsManager-test.ts +++ b/test/KeyBindingsManager-test.ts @@ -19,7 +19,7 @@ function mockKeyEvent(key: string, modifiers?: { describe('KeyBindingsManager', () => { it('should match basic key combo', () => { const combo1: KeyCombo = { - keys: ['k'], + key: 'k', }; assert.strictEqual(isKeyComboMatch(mockKeyEvent('k'), combo1, false), true); assert.strictEqual(isKeyComboMatch(mockKeyEvent('n'), combo1, false), false); @@ -28,7 +28,7 @@ describe('KeyBindingsManager', () => { it('should match key + modifier key combo', () => { const combo: KeyCombo = { - keys: ['k'], + key: 'k', ctrlKey: true, }; assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true }), combo, false), true); @@ -38,7 +38,7 @@ describe('KeyBindingsManager', () => { assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { shiftKey: true, metaKey: true }), combo, false), false); const combo2: KeyCombo = { - keys: ['k'], + key: 'k', metaKey: true, }; assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { metaKey: true }), combo2, false), true); @@ -47,7 +47,7 @@ describe('KeyBindingsManager', () => { assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { altKey: true, metaKey: true }), combo2, false), false); const combo3: KeyCombo = { - keys: ['k'], + key: 'k', altKey: true, }; assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { altKey: true }), combo3, false), true); @@ -56,7 +56,7 @@ describe('KeyBindingsManager', () => { assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, metaKey: true }), combo3, false), false); const combo4: KeyCombo = { - keys: ['k'], + key: 'k', shiftKey: true, }; assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { shiftKey: true }), combo4, false), true); @@ -67,7 +67,7 @@ describe('KeyBindingsManager', () => { it('should match key + multiple modifiers key combo', () => { const combo: KeyCombo = { - keys: ['k'], + key: 'k', ctrlKey: true, altKey: true, }; @@ -78,7 +78,7 @@ describe('KeyBindingsManager', () => { false), false); const combo2: KeyCombo = { - keys: ['k'], + key: 'k', ctrlKey: true, shiftKey: true, altKey: true, @@ -92,7 +92,7 @@ describe('KeyBindingsManager', () => { { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }), combo2, false), false); const combo3: KeyCombo = { - keys: ['k'], + key: 'k', ctrlKey: true, shiftKey: true, altKey: true, @@ -108,7 +108,7 @@ describe('KeyBindingsManager', () => { it('should match ctrlOrMeta key combo', () => { const combo: KeyCombo = { - keys: ['k'], + key: 'k', ctrlOrCmd: true, }; // PC: @@ -123,7 +123,7 @@ describe('KeyBindingsManager', () => { it('should match advanced ctrlOrMeta key combo', () => { const combo: KeyCombo = { - keys: ['k'], + key: 'k', ctrlOrCmd: true, altKey: true, }; From 12387b497862393c286eedc2c31466ae69b10c83 Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Tue, 16 Feb 2021 19:05:39 +1300 Subject: [PATCH 004/104] Use the KeyBindingsManager in EditMessageComposer --- src/KeyBindingsManager.ts | 40 +++++++++--- .../views/rooms/EditMessageComposer.js | 64 ++++++++++--------- 2 files changed, 65 insertions(+), 39 deletions(-) diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index 030cd94e99..b411c7ff27 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -2,21 +2,33 @@ import { isMac, Key } from './Keyboard'; import SettingsStore from './settings/SettingsStore'; export enum KeyBindingContext { - SendMessageComposer = 'SendMessageComposer', + /** Key bindings for the chat message composer component */ + MessageComposer = 'MessageComposer', } export enum KeyAction { None = 'None', + // SendMessageComposer actions: + + /** Send a message */ Send = 'Send', + /** Go backwards through the send history and use the message in composer view */ SelectPrevSendHistory = 'SelectPrevSendHistory', + /** Go forwards through the send history */ SelectNextSendHistory = 'SelectNextSendHistory', - EditLastMessage = 'EditLastMessage', + /** Start editing the user's last sent message */ + EditPrevMessage = 'EditPrevMessage', + /** Start editing the user's next sent message */ + EditNextMessage = 'EditNextMessage', + + /** Cancel editing a message */ + CancelEditing = 'CancelEditing', } /** * Represent a key combination. - * + * * The combo is evaluated strictly, i.e. the KeyboardEvent must match exactly what is specified in the KeyCombo. */ export type KeyCombo = { @@ -55,10 +67,22 @@ const messageComposerBindings = (): KeyBinding[] => { }, }, { - action: KeyAction.EditLastMessage, + action: KeyAction.EditPrevMessage, keyCombo: { key: Key.ARROW_UP, - } + }, + }, + { + action: KeyAction.EditNextMessage, + keyCombo: { + key: Key.ARROW_DOWN, + }, + }, + { + action: KeyAction.CancelEditing, + keyCombo: { + key: Key.ESCAPE, + }, }, ]; if (SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend')) { @@ -83,7 +107,7 @@ const messageComposerBindings = (): KeyBinding[] => { /** * Helper method to check if a KeyboardEvent matches a KeyCombo - * + * * Note, this method is only exported for testing. */ export function isKeyComboMatch(ev: KeyboardEvent, combo: KeyCombo, onMac: boolean): boolean { @@ -130,12 +154,12 @@ export type KeyBindingsGetter = () => KeyBinding[]; export class KeyBindingsManager { /** * Map of KeyBindingContext to a KeyBinding getter arrow function. - * + * * Returning a getter function allowed to have dynamic bindings, e.g. when settings change the bindings can be * recalculated. */ contextBindings: Record = { - [KeyBindingContext.SendMessageComposer]: messageComposerBindings, + [KeyBindingContext.MessageComposer]: messageComposerBindings, }; /** diff --git a/src/components/views/rooms/EditMessageComposer.js b/src/components/views/rooms/EditMessageComposer.js index c59b3555b9..8aa637f680 100644 --- a/src/components/views/rooms/EditMessageComposer.js +++ b/src/components/views/rooms/EditMessageComposer.js @@ -29,11 +29,10 @@ import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import classNames from 'classnames'; import {EventStatus} from 'matrix-js-sdk'; import BasicMessageComposer from "./BasicMessageComposer"; -import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {Action} from "../../../dispatcher/actions"; -import SettingsStore from "../../../settings/SettingsStore"; import CountlyAnalytics from "../../../CountlyAnalytics"; +import {getKeyBindingsManager, KeyAction, KeyBindingContext} from '../../../KeyBindingsManager'; function _isReply(mxEvent) { const relatesTo = mxEvent.getContent()["m.relates_to"]; @@ -134,38 +133,41 @@ export default class EditMessageComposer extends React.Component { if (this._editorRef.isComposing(event)) { return; } - if (event.metaKey || event.altKey || event.shiftKey) { - return; - } - const ctrlEnterToSend = !!SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend'); - const send = ctrlEnterToSend ? event.key === Key.ENTER && isOnlyCtrlOrCmdKeyEvent(event) - : event.key === Key.ENTER; - if (send) { - this._sendEdit(); - event.preventDefault(); - } else if (event.key === Key.ESCAPE) { - this._cancelEdit(); - } else if (event.key === Key.ARROW_UP) { - if (this._editorRef.isModified() || !this._editorRef.isCaretAtStart()) { - return; - } - const previousEvent = findEditableEvent(this._getRoom(), false, this.props.editState.getEvent().getId()); - if (previousEvent) { - dis.dispatch({action: 'edit_event', event: previousEvent}); + const action = getKeyBindingsManager().getAction(KeyBindingContext.MessageComposer, event); + switch (action) { + case KeyAction.Send: + this._sendEdit(); event.preventDefault(); + break; + case KeyAction.CancelEditing: + this._cancelEdit(); + break; + case KeyAction.EditPrevMessage: { + if (this._editorRef.isModified() || !this._editorRef.isCaretAtStart()) { + return; + } + const previousEvent = findEditableEvent(this._getRoom(), false, + this.props.editState.getEvent().getId()); + if (previousEvent) { + dis.dispatch({action: 'edit_event', event: previousEvent}); + event.preventDefault(); + } + break; } - } else if (event.key === Key.ARROW_DOWN) { - if (this._editorRef.isModified() || !this._editorRef.isCaretAtEnd()) { - return; + case KeyAction.EditNextMessage: { + if (this._editorRef.isModified() || !this._editorRef.isCaretAtEnd()) { + return; + } + const nextEvent = findEditableEvent(this._getRoom(), true, this.props.editState.getEvent().getId()); + if (nextEvent) { + dis.dispatch({action: 'edit_event', event: nextEvent}); + } else { + dis.dispatch({action: 'edit_event', event: null}); + dis.fire(Action.FocusComposer); + } + event.preventDefault(); + break; } - const nextEvent = findEditableEvent(this._getRoom(), true, this.props.editState.getEvent().getId()); - if (nextEvent) { - dis.dispatch({action: 'edit_event', event: nextEvent}); - } else { - dis.dispatch({action: 'edit_event', event: null}); - dis.fire(Action.FocusComposer); - } - event.preventDefault(); } } From ac7963b509ba630276424f86634f2a068e73bdbd Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Tue, 16 Feb 2021 19:05:51 +1300 Subject: [PATCH 005/104] Fix lint and style issues --- src/components/views/rooms/SendMessageComposer.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 0559c71e9e..5b018f2f0e 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -38,17 +38,16 @@ import * as sdk from '../../../index'; import Modal from '../../../Modal'; import {_t, _td} from '../../../languageHandler'; import ContentMessages from '../../../ContentMessages'; -import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard"; +import {Key} from "../../../Keyboard"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import RateLimitedFunc from '../../../ratelimitedfunc'; import {Action} from "../../../dispatcher/actions"; import {containsEmoji} from "../../../effects/utils"; import {CHAT_EFFECTS} from '../../../effects'; -import SettingsStore from "../../../settings/SettingsStore"; import CountlyAnalytics from "../../../CountlyAnalytics"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import EMOJI_REGEX from 'emojibase-regex'; -import { getKeyBindingsManager, KeyAction, KeyBindingContext } from '../../../KeyBindingsManager'; +import {getKeyBindingsManager, KeyAction, KeyBindingContext} from '../../../KeyBindingsManager'; function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); @@ -152,7 +151,7 @@ export default class SendMessageComposer extends React.Component { event.preventDefault(); break; case KeyAction.SelectPrevSendHistory: - case KeyAction.SelectNextSendHistory: + case KeyAction.SelectNextSendHistory: { // Try select composer history const selected = this.selectSendHistory(action === KeyAction.SelectPrevSendHistory); if (selected) { @@ -160,7 +159,8 @@ export default class SendMessageComposer extends React.Component { event.preventDefault(); } break; - case KeyAction.EditLastMessage: + } + case KeyAction.EditPrevMessage: // selection must be collapsed and caret at start if (this._editorRef.isSelectionCollapsed() && this._editorRef.isCaretAtStart()) { const editEvent = findEditableEvent(this.props.room, false); @@ -251,7 +251,7 @@ export default class SendMessageComposer extends React.Component { const myReactionKeys = [...myReactionEvents] .filter(event => !event.isRedacted()) .map(event => event.getRelation().key); - shouldReact = !myReactionKeys.includes(reaction); + shouldReact = !myReactionKeys.includes(reaction); } if (shouldReact) { MatrixClientPeg.get().sendEvent(lastMessage.getRoomId(), "m.reaction", { @@ -486,7 +486,7 @@ export default class SendMessageComposer extends React.Component { _insertQuotedMessage(event) { const {model} = this; const {partCreator} = model; - const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true }); + const quoteParts = parseEvent(event, partCreator, {isQuotedMessage: true}); // add two newlines quoteParts.push(partCreator.newline()); quoteParts.push(partCreator.newline()); From c84ad9bedc1299935b7cb8c8f4d1ebbf333ef7d3 Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Tue, 16 Feb 2021 19:12:18 +1300 Subject: [PATCH 006/104] Use key binding for cancelling a message reply --- src/KeyBindingsManager.ts | 2 +- src/components/views/rooms/SendMessageComposer.js | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index b411c7ff27..e8f4126fbd 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -22,7 +22,7 @@ export enum KeyAction { /** Start editing the user's next sent message */ EditNextMessage = 'EditNextMessage', - /** Cancel editing a message */ + /** Cancel editing a message or cancel replying to a message */ CancelEditing = 'CancelEditing', } diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 5b018f2f0e..adfa38b56a 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -38,7 +38,6 @@ import * as sdk from '../../../index'; import Modal from '../../../Modal'; import {_t, _td} from '../../../languageHandler'; import ContentMessages from '../../../ContentMessages'; -import {Key} from "../../../Keyboard"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import RateLimitedFunc from '../../../ratelimitedfunc'; import {Action} from "../../../dispatcher/actions"; @@ -174,13 +173,14 @@ export default class SendMessageComposer extends React.Component { } } break; + case KeyAction.CancelEditing: + dis.dispatch({ + action: 'reply_to_event', + event: null, + }); + break; default: - if (event.key === Key.ESCAPE) { - dis.dispatch({ - action: 'reply_to_event', - event: null, - }); - } else if (this._prepareToEncrypt) { + if (this._prepareToEncrypt) { // This needs to be last! this._prepareToEncrypt(); } From 54c38844d254546a35afbe8d81be4f6380a54262 Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Wed, 17 Feb 2021 22:00:48 +1300 Subject: [PATCH 007/104] Use key bindings in BasicMessageComposer --- src/KeyBindingsManager.ts | 164 +++++++++++++++- .../views/rooms/BasicMessageComposer.tsx | 175 +++++++++--------- 2 files changed, 245 insertions(+), 94 deletions(-) diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index e8f4126fbd..ef5084c16c 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -4,6 +4,8 @@ import SettingsStore from './settings/SettingsStore'; export enum KeyBindingContext { /** Key bindings for the chat message composer component */ MessageComposer = 'MessageComposer', + /** Key bindings for text editing autocompletion */ + AutoComplete = 'AutoComplete', } export enum KeyAction { @@ -21,9 +23,34 @@ export enum KeyAction { EditPrevMessage = 'EditPrevMessage', /** Start editing the user's next sent message */ EditNextMessage = 'EditNextMessage', - - /** Cancel editing a message or cancel replying to a message */ + /** Cancel editing a message or cancel replying to a message*/ CancelEditing = 'CancelEditing', + + /** Set bold format the current selection */ + FormatBold = 'FormatBold', + /** Set italics format the current selection */ + FormatItalics = 'FormatItalics', + /** Format the current selection as quote */ + FormatQuote = 'FormatQuote', + /** Undo the last editing */ + EditUndo = 'EditUndo', + /** Redo editing */ + EditRedo = 'EditRedo', + /** Insert new line */ + NewLine = 'NewLine', + MoveCursorToStart = 'MoveCursorToStart', + MoveCursorToEnd = 'MoveCursorToEnd', + + // Autocomplete + + /** Apply the current autocomplete selection */ + AutocompleteApply = 'AutocompleteApply', + /** Cancel autocompletion */ + AutocompleteCancel = 'AutocompleteCancel', + /** Move to the previous autocomplete selection */ + AutocompletePrevSelection = 'AutocompletePrevSelection', + /** Move to the next autocomplete selection */ + AutocompleteNextSelection = 'AutocompleteNextSelection', } /** @@ -84,7 +111,69 @@ const messageComposerBindings = (): KeyBinding[] => { key: Key.ESCAPE, }, }, + { + action: KeyAction.FormatBold, + keyCombo: { + key: Key.B, + ctrlOrCmd: true, + }, + }, + { + action: KeyAction.FormatItalics, + keyCombo: { + key: Key.I, + ctrlOrCmd: true, + }, + }, + { + action: KeyAction.FormatQuote, + keyCombo: { + key: Key.GREATER_THAN, + ctrlOrCmd: true, + shiftKey: true, + }, + }, + { + action: KeyAction.EditUndo, + keyCombo: { + key: Key.Z, + ctrlOrCmd: true, + }, + }, + // Note: the following two bindings also work with just HOME and END, add them here? + { + action: KeyAction.MoveCursorToStart, + keyCombo: { + key: Key.HOME, + ctrlOrCmd: true, + }, + }, + { + action: KeyAction.MoveCursorToEnd, + keyCombo: { + key: Key.END, + ctrlOrCmd: true, + }, + }, ]; + if (isMac) { + bindings.push({ + action: KeyAction.EditRedo, + keyCombo: { + key: Key.Z, + ctrlOrCmd: true, + shiftKey: true, + }, + }); + } else { + bindings.push({ + action: KeyAction.EditRedo, + keyCombo: { + key: Key.Y, + ctrlOrCmd: true, + }, + }); + } if (SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend')) { bindings.push({ action: KeyAction.Send, @@ -93,6 +182,12 @@ const messageComposerBindings = (): KeyBinding[] => { ctrlOrCmd: true, }, }); + bindings.push({ + action: KeyAction.NewLine, + keyCombo: { + key: Key.ENTER, + }, + }); } else { bindings.push({ action: KeyAction.Send, @@ -100,17 +195,75 @@ const messageComposerBindings = (): KeyBinding[] => { key: Key.ENTER, }, }); + bindings.push({ + action: KeyAction.NewLine, + keyCombo: { + key: Key.ENTER, + shiftKey: true, + }, + }); + if (isMac) { + bindings.push({ + action: KeyAction.NewLine, + keyCombo: { + key: Key.ENTER, + altKey: true, + }, + }); + } } - return bindings; } +const autocompleteBindings = (): KeyBinding[] => { + return [ + { + action: KeyAction.AutocompleteApply, + keyCombo: { + key: Key.TAB, + }, + }, + { + action: KeyAction.AutocompleteApply, + keyCombo: { + key: Key.TAB, + ctrlKey: true, + }, + }, + { + action: KeyAction.AutocompleteApply, + keyCombo: { + key: Key.TAB, + shiftKey: true, + }, + }, + { + action: KeyAction.AutocompleteCancel, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: KeyAction.AutocompletePrevSelection, + keyCombo: { + key: Key.ARROW_UP, + }, + }, + { + action: KeyAction.AutocompleteNextSelection, + keyCombo: { + key: Key.ARROW_DOWN, + }, + }, + ] +} + /** * Helper method to check if a KeyboardEvent matches a KeyCombo * * Note, this method is only exported for testing. */ -export function isKeyComboMatch(ev: KeyboardEvent, combo: KeyCombo, onMac: boolean): boolean { +export function isKeyComboMatch(ev: KeyboardEvent | React.KeyboardEvent, combo: KeyCombo, onMac: boolean): boolean { if (combo.key !== undefined && ev.key !== combo.key) { return false; } @@ -160,12 +313,13 @@ export class KeyBindingsManager { */ contextBindings: Record = { [KeyBindingContext.MessageComposer]: messageComposerBindings, + [KeyBindingContext.AutoComplete]: autocompleteBindings, }; /** * Finds a matching KeyAction for a given KeyboardEvent */ - getAction(context: KeyBindingContext, ev: KeyboardEvent): KeyAction { + getAction(context: KeyBindingContext, ev: KeyboardEvent | React.KeyboardEvent): KeyAction { const bindings = this.contextBindings[context]?.(); if (!bindings) { return KeyAction.None; diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 017ce77166..d0119ddc05 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -46,6 +46,7 @@ import {IDiff} from "../../../editor/diff"; import AutocompleteWrapperModel from "../../../editor/autocomplete"; import DocumentPosition from "../../../editor/position"; import {ICompletion} from "../../../autocomplete/Autocompleter"; +import { getKeyBindingsManager, KeyBindingContext, KeyAction } from '../../../KeyBindingsManager'; // matches emoticons which follow the start of a line or whitespace const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); @@ -419,98 +420,94 @@ export default class BasicMessageEditor extends React.Component private onKeyDown = (event: React.KeyboardEvent) => { const model = this.props.model; - const modKey = IS_MAC ? event.metaKey : event.ctrlKey; let handled = false; - // format bold - if (modKey && event.key === Key.B) { - this.onFormatAction(Formatting.Bold); - handled = true; - // format italics - } else if (modKey && event.key === Key.I) { - this.onFormatAction(Formatting.Italics); - handled = true; - // format quote - } else if (modKey && event.key === Key.GREATER_THAN) { - this.onFormatAction(Formatting.Quote); - handled = true; - // redo - } else if ((!IS_MAC && modKey && event.key === Key.Y) || - (IS_MAC && modKey && event.shiftKey && event.key === Key.Z)) { - if (this.historyManager.canRedo()) { - const {parts, caret} = this.historyManager.redo(); - // pass matching inputType so historyManager doesn't push echo - // when invoked from rerender callback. - model.reset(parts, caret, "historyRedo"); - } - handled = true; - // undo - } else if (modKey && event.key === Key.Z) { - if (this.historyManager.canUndo()) { - const {parts, caret} = this.historyManager.undo(this.props.model); - // pass matching inputType so historyManager doesn't push echo - // when invoked from rerender callback. - model.reset(parts, caret, "historyUndo"); - } - handled = true; - // insert newline on Shift+Enter - } else if (event.key === Key.ENTER && (event.shiftKey || (IS_MAC && event.altKey))) { - this.insertText("\n"); - handled = true; - // move selection to start of composer - } else if (modKey && event.key === Key.HOME && !event.shiftKey) { - setSelection(this.editorRef.current, model, { - index: 0, - offset: 0, - }); - handled = true; - // move selection to end of composer - } else if (modKey && event.key === Key.END && !event.shiftKey) { - setSelection(this.editorRef.current, model, { - index: model.parts.length - 1, - offset: model.parts[model.parts.length - 1].text.length, - }); - handled = true; - // autocomplete or enter to send below shouldn't have any modifier keys pressed. - } else { - const metaOrAltPressed = event.metaKey || event.altKey; - const modifierPressed = metaOrAltPressed || event.shiftKey; - if (model.autoComplete && model.autoComplete.hasCompletions()) { - const autoComplete = model.autoComplete; - switch (event.key) { - case Key.ARROW_UP: - if (!modifierPressed) { - autoComplete.onUpArrow(event); - handled = true; - } - break; - case Key.ARROW_DOWN: - if (!modifierPressed) { - autoComplete.onDownArrow(event); - handled = true; - } - break; - case Key.TAB: - if (!metaOrAltPressed) { - autoComplete.onTab(event); - handled = true; - } - break; - case Key.ESCAPE: - if (!modifierPressed) { - autoComplete.onEscape(event); - handled = true; - } - break; - default: - return; // don't preventDefault on anything else - } - } else if (event.key === Key.TAB) { - this.tabCompleteName(event); + const action = getKeyBindingsManager().getAction(KeyBindingContext.MessageComposer, event); + switch (action) { + case KeyAction.FormatBold: + this.onFormatAction(Formatting.Bold); handled = true; - } else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) { - this.formatBarRef.current.hide(); - } + break; + case KeyAction.FormatItalics: + this.onFormatAction(Formatting.Italics); + handled = true; + break; + case KeyAction.FormatQuote: + this.onFormatAction(Formatting.Quote); + handled = true; + break; + case KeyAction.EditRedo: + if (this.historyManager.canRedo()) { + const {parts, caret} = this.historyManager.redo(); + // pass matching inputType so historyManager doesn't push echo + // when invoked from rerender callback. + model.reset(parts, caret, "historyRedo"); + } + handled = true; + break; + case KeyAction.EditUndo: + if (this.historyManager.canUndo()) { + const {parts, caret} = this.historyManager.undo(this.props.model); + // pass matching inputType so historyManager doesn't push echo + // when invoked from rerender callback. + model.reset(parts, caret, "historyUndo"); + } + handled = true; + break; + case KeyAction.NewLine: + this.insertText("\n"); + handled = true; + break; + case KeyAction.MoveCursorToStart: + setSelection(this.editorRef.current, model, { + index: 0, + offset: 0, + }); + handled = true; + break; + case KeyAction.MoveCursorToEnd: + setSelection(this.editorRef.current, model, { + index: model.parts.length - 1, + offset: model.parts[model.parts.length - 1].text.length, + }); + handled = true; + break; } + if (handled) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + const autocompleteAction = getKeyBindingsManager().getAction(KeyBindingContext.AutoComplete, event); + if (model.autoComplete && model.autoComplete.hasCompletions()) { + const autoComplete = model.autoComplete; + switch (autocompleteAction) { + case KeyAction.AutocompletePrevSelection: + autoComplete.onUpArrow(event); + handled = true; + break; + case KeyAction.AutocompleteNextSelection: + autoComplete.onDownArrow(event); + handled = true; + break; + case KeyAction.AutocompleteApply: + autoComplete.onTab(event); + handled = true; + break; + case KeyAction.AutocompleteCancel: + autoComplete.onEscape(event); + handled = true; + break; + default: + return; // don't preventDefault on anything else + } + } else if (autocompleteAction === KeyAction.AutocompleteApply) { + this.tabCompleteName(event); + handled = true; + } else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) { + this.formatBarRef.current.hide(); + } + if (handled) { event.preventDefault(); event.stopPropagation(); From f29a8ef0f707a9c9a9168b7f1177dda771a802c9 Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Sun, 28 Feb 2021 20:12:36 +1300 Subject: [PATCH 008/104] Handle shift + letter combos --- src/KeyBindingsManager.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index ef5084c16c..e26950b862 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -264,8 +264,17 @@ const autocompleteBindings = (): KeyBinding[] => { * Note, this method is only exported for testing. */ export function isKeyComboMatch(ev: KeyboardEvent | React.KeyboardEvent, combo: KeyCombo, onMac: boolean): boolean { - if (combo.key !== undefined && ev.key !== combo.key) { - return false; + if (combo.key !== undefined) { + // When shift is pressed, letters are returned as upper case chars. In this case do a lower case comparison. + // This works for letter combos such as shift + U as well for none letter combos such as shift + Escape. + // If shift is not pressed, the toLowerCase conversion can be avoided. + if (ev.shiftKey) { + if (ev.key.toLowerCase() !== combo.key.toLowerCase()) { + return false; + } + } else if (ev.key !== combo.key) { + return false; + } } const comboCtrl = combo.ctrlKey ?? false; From 32ec8b7dc84af60811ef2d1155f4839fd3f79285 Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Sun, 28 Feb 2021 20:13:34 +1300 Subject: [PATCH 009/104] Add key bindings for RoomList, Room and Navigation --- src/KeyBindingsManager.ts | 244 +++++++++++++++++++++ src/components/structures/LoggedInView.tsx | 162 +++++++------- src/components/structures/RoomSearch.tsx | 33 +-- src/components/structures/RoomView.tsx | 32 ++- src/components/views/rooms/RoomSublist.tsx | 12 +- 5 files changed, 365 insertions(+), 118 deletions(-) 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 From 601be50b7127518c86e891f933c31d861fd83abb Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Mon, 1 Mar 2021 21:43:00 +1300 Subject: [PATCH 010/104] Split KeyAction into multiple enums This gives some additional type safety and makes enum member usage more clear. --- src/KeyBindingsManager.ts | 254 +++++++++--------- src/components/structures/LoggedInView.tsx | 34 +-- src/components/structures/RoomSearch.tsx | 12 +- src/components/structures/RoomView.tsx | 10 +- .../views/rooms/BasicMessageComposer.tsx | 32 +-- .../views/rooms/EditMessageComposer.js | 12 +- src/components/views/rooms/RoomSublist.tsx | 8 +- .../views/rooms/SendMessageComposer.js | 16 +- 8 files changed, 185 insertions(+), 193 deletions(-) diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index b969982bda..d8c128a2bf 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -1,24 +1,8 @@ import { isMac, Key } from './Keyboard'; import SettingsStore from './settings/SettingsStore'; -export enum KeyBindingContext { - /** Key bindings for the chat message composer component */ - 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 { - None = 'None', - - // SendMessageComposer actions: - +/** Actions for the chat message composer component */ +export enum MessageComposerAction { /** Send a message */ Send = 'Send', /** Go backwards through the send history and use the message in composer view */ @@ -46,70 +30,74 @@ export enum KeyAction { NewLine = 'NewLine', MoveCursorToStart = 'MoveCursorToStart', MoveCursorToEnd = 'MoveCursorToEnd', +} - // Autocomplete - +/** Actions for text editing autocompletion */ +export enum AutocompleteAction { /** Apply the current autocomplete selection */ - AutocompleteApply = 'AutocompleteApply', + ApplySelection = 'ApplySelection', /** Cancel autocompletion */ - AutocompleteCancel = 'AutocompleteCancel', + Cancel = 'Cancel', /** Move to the previous autocomplete selection */ - AutocompletePrevSelection = 'AutocompletePrevSelection', + PrevSelection = 'PrevSelection', /** Move to the next autocomplete selection */ - AutocompleteNextSelection = 'AutocompleteNextSelection', - - // Room list + NextSelection = 'NextSelection', +} +/** Actions for the left room list sidebar */ +export enum RoomListAction { /** Clear room list filter field */ - RoomListClearSearch = 'RoomListClearSearch', + ClearSearch = 'ClearSearch', /** Navigate up/down in the room list */ - RoomListPrevRoom = 'RoomListPrevRoom', + PrevRoom = 'PrevRoom', /** Navigate down in the room list */ - RoomListNextRoom = 'RoomListNextRoom', + NextRoom = 'NextRoom', /** Select room from the room list */ - RoomListSelectRoom = 'RoomListSelectRoom', + SelectRoom = 'SelectRoom', /** Collapse room list section */ - RoomListCollapseSection = 'RoomListCollapseSection', + CollapseSection = 'CollapseSection', /** Expand room list section, if already expanded, jump to first room in the selection */ - RoomListExpandSection = 'RoomListExpandSection', + ExpandSection = 'ExpandSection', +} - // Room - - /** Jump to room search */ - RoomFocusRoomSearch = 'RoomFocusRoomSearch', +/** Actions for the current room view */ +export enum RoomAction { + /** Jump to room search (search for a room)*/ + FocusRoomSearch = 'FocusRoomSearch', // TODO: move to NavigationAction? /** Scroll up in the timeline */ - RoomScrollUp = 'RoomScrollUp', + ScrollUp = 'ScrollUp', /** Scroll down in the timeline */ RoomScrollDown = 'RoomScrollDown', /** Dismiss read marker and jump to bottom */ - RoomDismissReadMarker = 'RoomDismissReadMarker', + DismissReadMarker = 'DismissReadMarker', /* Upload a file */ - RoomUploadFile = 'RoomUploadFile', - /* Search (must be enabled) */ - RoomSearch = 'RoomSearch', + UploadFile = 'UploadFile', + /* Focus search message in a room (must be enabled) */ + FocusSearch = 'FocusSearch', /* Jump to the first (downloaded) message in the room */ - RoomJumpToFirstMessage = 'RoomJumpToFirstMessage', + JumpToFirstMessage = 'JumpToFirstMessage', /* Jump to the latest message in the room */ - RoomJumpToLatestMessage = 'RoomJumpToLatestMessage', - - // Navigation + JumpToLatestMessage = 'JumpToLatestMessage', +} +/** Actions for navigating do various menus / dialogs / screens */ +export enum NavigationAction { /** Toggle the room side panel */ - NavToggleRoomSidePanel = 'NavToggleRoomSidePanel', + ToggleRoomSidePanel = 'ToggleRoomSidePanel', /** Toggle the user menu */ - NavToggleUserMenu = 'NavToggleUserMenu', + ToggleUserMenu = 'ToggleUserMenu', /* Toggle the short cut help dialog */ - NavToggleShortCutDialog = 'NavToggleShortCutDialog', + ToggleShortCutDialog = 'ToggleShortCutDialog', /* Got to the Element home screen */ - NavGoToHome = 'NavGoToHome', + GoToHome = 'GoToHome', /* Select prev room */ - NavSelectPrevRoom = 'NavSelectPrevRoom', + SelectPrevRoom = 'SelectPrevRoom', /* Select next room */ - NavSelectNextRoom = 'NavSelectNextRoom', + SelectNextRoom = 'SelectNextRoom', /* Select prev room with unread messages*/ - NavSelectPrevUnreadRoom = 'NavSelectPrevUnreadRoom', + SelectPrevUnreadRoom = 'SelectPrevUnreadRoom', /* Select next room with unread messages*/ - NavSelectNextUnreadRoom = 'NavSelectNextUnreadRoom', + SelectNextUnreadRoom = 'SelectNextUnreadRoom', } /** @@ -129,15 +117,15 @@ export type KeyCombo = { shiftKey?: boolean; } -export type KeyBinding = { - action: KeyAction; +export type KeyBinding = { + action: T; keyCombo: KeyCombo; } -const messageComposerBindings = (): KeyBinding[] => { - const bindings: KeyBinding[] = [ +const messageComposerBindings = (): KeyBinding[] => { + const bindings: KeyBinding[] = [ { - action: KeyAction.SelectPrevSendHistory, + action: MessageComposerAction.SelectPrevSendHistory, keyCombo: { key: Key.ARROW_UP, altKey: true, @@ -145,7 +133,7 @@ const messageComposerBindings = (): KeyBinding[] => { }, }, { - action: KeyAction.SelectNextSendHistory, + action: MessageComposerAction.SelectNextSendHistory, keyCombo: { key: Key.ARROW_DOWN, altKey: true, @@ -153,39 +141,39 @@ const messageComposerBindings = (): KeyBinding[] => { }, }, { - action: KeyAction.EditPrevMessage, + action: MessageComposerAction.EditPrevMessage, keyCombo: { key: Key.ARROW_UP, }, }, { - action: KeyAction.EditNextMessage, + action: MessageComposerAction.EditNextMessage, keyCombo: { key: Key.ARROW_DOWN, }, }, { - action: KeyAction.CancelEditing, + action: MessageComposerAction.CancelEditing, keyCombo: { key: Key.ESCAPE, }, }, { - action: KeyAction.FormatBold, + action: MessageComposerAction.FormatBold, keyCombo: { key: Key.B, ctrlOrCmd: true, }, }, { - action: KeyAction.FormatItalics, + action: MessageComposerAction.FormatItalics, keyCombo: { key: Key.I, ctrlOrCmd: true, }, }, { - action: KeyAction.FormatQuote, + action: MessageComposerAction.FormatQuote, keyCombo: { key: Key.GREATER_THAN, ctrlOrCmd: true, @@ -193,7 +181,7 @@ const messageComposerBindings = (): KeyBinding[] => { }, }, { - action: KeyAction.EditUndo, + action: MessageComposerAction.EditUndo, keyCombo: { key: Key.Z, ctrlOrCmd: true, @@ -201,14 +189,14 @@ const messageComposerBindings = (): KeyBinding[] => { }, // Note: the following two bindings also work with just HOME and END, add them here? { - action: KeyAction.MoveCursorToStart, + action: MessageComposerAction.MoveCursorToStart, keyCombo: { key: Key.HOME, ctrlOrCmd: true, }, }, { - action: KeyAction.MoveCursorToEnd, + action: MessageComposerAction.MoveCursorToEnd, keyCombo: { key: Key.END, ctrlOrCmd: true, @@ -217,7 +205,7 @@ const messageComposerBindings = (): KeyBinding[] => { ]; if (isMac) { bindings.push({ - action: KeyAction.EditRedo, + action: MessageComposerAction.EditRedo, keyCombo: { key: Key.Z, ctrlOrCmd: true, @@ -226,7 +214,7 @@ const messageComposerBindings = (): KeyBinding[] => { }); } else { bindings.push({ - action: KeyAction.EditRedo, + action: MessageComposerAction.EditRedo, keyCombo: { key: Key.Y, ctrlOrCmd: true, @@ -235,27 +223,27 @@ const messageComposerBindings = (): KeyBinding[] => { } if (SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend')) { bindings.push({ - action: KeyAction.Send, + action: MessageComposerAction.Send, keyCombo: { key: Key.ENTER, ctrlOrCmd: true, }, }); bindings.push({ - action: KeyAction.NewLine, + action: MessageComposerAction.NewLine, keyCombo: { key: Key.ENTER, }, }); } else { bindings.push({ - action: KeyAction.Send, + action: MessageComposerAction.Send, keyCombo: { key: Key.ENTER, }, }); bindings.push({ - action: KeyAction.NewLine, + action: MessageComposerAction.NewLine, keyCombo: { key: Key.ENTER, shiftKey: true, @@ -263,7 +251,7 @@ const messageComposerBindings = (): KeyBinding[] => { }); if (isMac) { bindings.push({ - action: KeyAction.NewLine, + action: MessageComposerAction.NewLine, keyCombo: { key: Key.ENTER, altKey: true, @@ -274,42 +262,42 @@ const messageComposerBindings = (): KeyBinding[] => { return bindings; } -const autocompleteBindings = (): KeyBinding[] => { +const autocompleteBindings = (): KeyBinding[] => { return [ { - action: KeyAction.AutocompleteApply, + action: AutocompleteAction.ApplySelection, keyCombo: { key: Key.TAB, }, }, { - action: KeyAction.AutocompleteApply, + action: AutocompleteAction.ApplySelection, keyCombo: { key: Key.TAB, ctrlKey: true, }, }, { - action: KeyAction.AutocompleteApply, + action: AutocompleteAction.ApplySelection, keyCombo: { key: Key.TAB, shiftKey: true, }, }, { - action: KeyAction.AutocompleteCancel, + action: AutocompleteAction.Cancel, keyCombo: { key: Key.ESCAPE, }, }, { - action: KeyAction.AutocompletePrevSelection, + action: AutocompleteAction.PrevSelection, keyCombo: { key: Key.ARROW_UP, }, }, { - action: KeyAction.AutocompleteNextSelection, + action: AutocompleteAction.NextSelection, keyCombo: { key: Key.ARROW_DOWN, }, @@ -317,40 +305,40 @@ const autocompleteBindings = (): KeyBinding[] => { ]; } -const roomListBindings = (): KeyBinding[] => { +const roomListBindings = (): KeyBinding[] => { return [ { - action: KeyAction.RoomListClearSearch, + action: RoomListAction.ClearSearch, keyCombo: { key: Key.ESCAPE, }, }, { - action: KeyAction.RoomListPrevRoom, + action: RoomListAction.PrevRoom, keyCombo: { key: Key.ARROW_UP, }, }, { - action: KeyAction.RoomListNextRoom, + action: RoomListAction.NextRoom, keyCombo: { key: Key.ARROW_DOWN, }, }, { - action: KeyAction.RoomListSelectRoom, + action: RoomListAction.SelectRoom, keyCombo: { key: Key.ENTER, }, }, { - action: KeyAction.RoomListCollapseSection, + action: RoomListAction.CollapseSection, keyCombo: { key: Key.ARROW_LEFT, }, }, { - action: KeyAction.RoomListExpandSection, + action: RoomListAction.ExpandSection, keyCombo: { key: Key.ARROW_RIGHT, }, @@ -358,35 +346,35 @@ const roomListBindings = (): KeyBinding[] => { ]; } -const roomBindings = (): KeyBinding[] => { +const roomBindings = (): KeyBinding[] => { const bindings = [ { - action: KeyAction.RoomFocusRoomSearch, + action: RoomAction.FocusRoomSearch, keyCombo: { key: Key.K, ctrlOrCmd: true, }, }, { - action: KeyAction.RoomScrollUp, + action: RoomAction.ScrollUp, keyCombo: { key: Key.PAGE_UP, }, }, { - action: KeyAction.RoomScrollDown, + action: RoomAction.RoomScrollDown, keyCombo: { key: Key.PAGE_DOWN, }, }, { - action: KeyAction.RoomDismissReadMarker, + action: RoomAction.DismissReadMarker, keyCombo: { key: Key.ESCAPE, }, }, { - action: KeyAction.RoomUploadFile, + action: RoomAction.UploadFile, keyCombo: { key: Key.U, ctrlOrCmd: true, @@ -394,14 +382,14 @@ const roomBindings = (): KeyBinding[] => { }, }, { - action: KeyAction.RoomJumpToFirstMessage, + action: RoomAction.JumpToFirstMessage, keyCombo: { key: Key.HOME, ctrlKey: true, }, }, { - action: KeyAction.RoomJumpToLatestMessage, + action: RoomAction.JumpToLatestMessage, keyCombo: { key: Key.END, ctrlKey: true, @@ -411,7 +399,7 @@ const roomBindings = (): KeyBinding[] => { if (SettingsStore.getValue('ctrlFForSearch')) { bindings.push({ - action: KeyAction.RoomSearch, + action: RoomAction.FocusSearch, keyCombo: { key: Key.F, ctrlOrCmd: true, @@ -422,17 +410,17 @@ const roomBindings = (): KeyBinding[] => { return bindings; } -const navigationBindings = (): KeyBinding[] => { +const navigationBindings = (): KeyBinding[] => { return [ { - action: KeyAction.NavToggleRoomSidePanel, + action: NavigationAction.ToggleRoomSidePanel, keyCombo: { key: Key.PERIOD, ctrlOrCmd: true, }, }, { - action: KeyAction.NavToggleUserMenu, + action: NavigationAction.ToggleUserMenu, // 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 @@ -443,14 +431,14 @@ const navigationBindings = (): KeyBinding[] => { }, }, { - action: KeyAction.NavToggleShortCutDialog, + action: NavigationAction.ToggleShortCutDialog, keyCombo: { key: Key.SLASH, ctrlOrCmd: true, }, }, { - action: KeyAction.NavToggleShortCutDialog, + action: NavigationAction.ToggleShortCutDialog, keyCombo: { key: Key.SLASH, ctrlOrCmd: true, @@ -458,7 +446,7 @@ const navigationBindings = (): KeyBinding[] => { }, }, { - action: KeyAction.NavGoToHome, + action: NavigationAction.GoToHome, keyCombo: { key: Key.H, ctrlOrCmd: true, @@ -467,21 +455,21 @@ const navigationBindings = (): KeyBinding[] => { }, { - action: KeyAction.NavSelectPrevRoom, + action: NavigationAction.SelectPrevRoom, keyCombo: { key: Key.ARROW_UP, altKey: true, }, }, { - action: KeyAction.NavSelectNextRoom, + action: NavigationAction.SelectNextRoom, keyCombo: { key: Key.ARROW_DOWN, altKey: true, }, }, { - action: KeyAction.NavSelectPrevUnreadRoom, + action: NavigationAction.SelectPrevUnreadRoom, keyCombo: { key: Key.ARROW_UP, altKey: true, @@ -489,7 +477,7 @@ const navigationBindings = (): KeyBinding[] => { }, }, { - action: KeyAction.NavSelectNextUnreadRoom, + action: NavigationAction.SelectNextUnreadRoom, keyCombo: { key: Key.ARROW_DOWN, altKey: true, @@ -551,38 +539,42 @@ export function isKeyComboMatch(ev: KeyboardEvent | React.KeyboardEvent, combo: return true; } - -export type KeyBindingsGetter = () => KeyBinding[]; - export class KeyBindingsManager { - /** - * Map of KeyBindingContext to a KeyBinding getter arrow function. - * - * Returning a getter function allowed to have dynamic bindings, e.g. when settings change the bindings can be - * recalculated. - */ - contextBindings: Record = { - [KeyBindingContext.MessageComposer]: messageComposerBindings, - [KeyBindingContext.AutoComplete]: autocompleteBindings, - [KeyBindingContext.RoomList]: roomListBindings, - [KeyBindingContext.Room]: roomBindings, - [KeyBindingContext.Navigation]: navigationBindings, - }; - /** * Finds a matching KeyAction for a given KeyboardEvent */ - getAction(context: KeyBindingContext, ev: KeyboardEvent | React.KeyboardEvent): KeyAction { - const bindings = this.contextBindings[context]?.(); - if (!bindings) { - return KeyAction.None; - } + private getAction(bindings: KeyBinding[], ev: KeyboardEvent | React.KeyboardEvent) + : T | undefined { const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac)); if (binding) { return binding.action; } + return undefined; + } - return KeyAction.None; + getMessageComposerAction(ev: KeyboardEvent | React.KeyboardEvent): MessageComposerAction | undefined { + const bindings = messageComposerBindings(); + return this.getAction(bindings, ev); + } + + getAutocompleteAction(ev: KeyboardEvent | React.KeyboardEvent): AutocompleteAction | undefined { + const bindings = autocompleteBindings(); + return this.getAction(bindings, ev); + } + + getRoomListAction(ev: KeyboardEvent | React.KeyboardEvent): RoomListAction | undefined { + const bindings = roomListBindings(); + return this.getAction(bindings, ev); + } + + getRoomAction(ev: KeyboardEvent | React.KeyboardEvent): RoomAction | undefined { + const bindings = roomBindings(); + return this.getAction(bindings, ev); + } + + getNavigationAction(ev: KeyboardEvent | React.KeyboardEvent): NavigationAction | undefined { + const bindings = navigationBindings(); + return this.getAction(bindings, ev); } } diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index dd8bc1f3db..ce5df47138 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -55,7 +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'; +import { getKeyBindingsManager, NavigationAction, RoomAction } 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. @@ -401,22 +401,22 @@ class LoggedInView extends React.Component { _onKeyDown = (ev) => { let handled = false; - const roomAction = getKeyBindingsManager().getAction(KeyBindingContext.Room, ev); + const roomAction = getKeyBindingsManager().getRoomAction(ev); switch (roomAction) { - case KeyAction.RoomFocusRoomSearch: + case RoomAction.FocusRoomSearch: dis.dispatch({ action: 'focus_room_filter', }); handled = true; break; - case KeyAction.RoomScrollUp: - case KeyAction.RoomScrollDown: - case KeyAction.RoomJumpToFirstMessage: - case KeyAction.RoomJumpToLatestMessage: + case RoomAction.ScrollUp: + case RoomAction.RoomScrollDown: + case RoomAction.JumpToFirstMessage: + case RoomAction.JumpToLatestMessage: this._onScrollKeyPressed(ev); handled = true; break; - case KeyAction.RoomSearch: + case RoomAction.FocusSearch: dis.dispatch({ action: 'focus_search', }); @@ -429,24 +429,24 @@ class LoggedInView extends React.Component { return; } - const navAction = getKeyBindingsManager().getAction(KeyBindingContext.Navigation, ev); + const navAction = getKeyBindingsManager().getNavigationAction(ev); switch (navAction) { - case KeyAction.NavToggleUserMenu: + case NavigationAction.ToggleUserMenu: dis.fire(Action.ToggleUserMenu); handled = true; break; - case KeyAction.NavToggleShortCutDialog: + case NavigationAction.ToggleShortCutDialog: KeyboardShortcuts.toggleDialog(); handled = true; break; - case KeyAction.NavGoToHome: + case NavigationAction.GoToHome: dis.dispatch({ action: 'view_home_page', }); Modal.closeCurrentModal("homeKeyboardShortcut"); handled = true; break; - case KeyAction.NavToggleRoomSidePanel: + case NavigationAction.ToggleRoomSidePanel: if (this.props.page_type === "room_view" || this.props.page_type === "group_view") { dis.dispatch({ action: Action.ToggleRightPanel, @@ -455,7 +455,7 @@ class LoggedInView extends React.Component { handled = true; } break; - case KeyAction.NavSelectPrevRoom: + case NavigationAction.SelectPrevRoom: dis.dispatch({ action: Action.ViewRoomDelta, delta: -1, @@ -463,7 +463,7 @@ class LoggedInView extends React.Component { }); handled = true; break; - case KeyAction.NavSelectNextRoom: + case NavigationAction.SelectNextRoom: dis.dispatch({ action: Action.ViewRoomDelta, delta: 1, @@ -471,14 +471,14 @@ class LoggedInView extends React.Component { }); handled = true; break; - case KeyAction.NavSelectPrevUnreadRoom: + case NavigationAction.SelectPrevUnreadRoom: dis.dispatch({ action: Action.ViewRoomDelta, delta: -1, unread: true, }); break; - case KeyAction.NavSelectNextUnreadRoom: + case NavigationAction.SelectNextUnreadRoom: dis.dispatch({ action: Action.ViewRoomDelta, delta: 1, diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 2e900d2f0e..7d127040eb 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -24,7 +24,7 @@ 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"; +import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; interface IProps { isMinimized: boolean; @@ -106,17 +106,17 @@ export default class RoomSearch extends React.PureComponent { }; private onKeyDown = (ev: React.KeyboardEvent) => { - const action = getKeyBindingsManager().getAction(KeyBindingContext.RoomList, ev); + const action = getKeyBindingsManager().getRoomListAction(ev); switch (action) { - case KeyAction.RoomListClearSearch: + case RoomListAction.ClearSearch: this.clearInput(); defaultDispatcher.fire(Action.FocusComposer); break; - case KeyAction.RoomListNextRoom: - case KeyAction.RoomListPrevRoom: + case RoomListAction.NextRoom: + case RoomListAction.PrevRoom: this.props.onVerticalArrow(ev); break; - case KeyAction.RoomListSelectRoom: { + case RoomListAction.SelectRoom: { 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 diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index c09f1f7c45..680d717615 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -78,7 +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'; +import { getKeyBindingsManager, RoomAction } from '../../KeyBindingsManager'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -661,18 +661,18 @@ export default class RoomView extends React.Component { private onReactKeyDown = ev => { let handled = false; - const action = getKeyBindingsManager().getAction(KeyBindingContext.Room, ev); + const action = getKeyBindingsManager().getRoomAction(ev); switch (action) { - case KeyAction.RoomDismissReadMarker: + case RoomAction.DismissReadMarker: this.messagePanel.forgetReadMarker(); this.jumpToLiveTimeline(); handled = true; break; - case KeyAction.RoomScrollUp: + case RoomAction.ScrollUp: this.jumpToReadMarker(); handled = true; break; - case KeyAction.RoomUploadFile: + case RoomAction.UploadFile: dis.dispatch({ action: "upload_file" }, true); handled = true; break; diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index d0119ddc05..f5e561f15a 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -46,7 +46,7 @@ import {IDiff} from "../../../editor/diff"; import AutocompleteWrapperModel from "../../../editor/autocomplete"; import DocumentPosition from "../../../editor/position"; import {ICompletion} from "../../../autocomplete/Autocompleter"; -import { getKeyBindingsManager, KeyBindingContext, KeyAction } from '../../../KeyBindingsManager'; +import { AutocompleteAction, getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager'; // matches emoticons which follow the start of a line or whitespace const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); @@ -421,21 +421,21 @@ export default class BasicMessageEditor extends React.Component private onKeyDown = (event: React.KeyboardEvent) => { const model = this.props.model; let handled = false; - const action = getKeyBindingsManager().getAction(KeyBindingContext.MessageComposer, event); + const action = getKeyBindingsManager().getMessageComposerAction(event); switch (action) { - case KeyAction.FormatBold: + case MessageComposerAction.FormatBold: this.onFormatAction(Formatting.Bold); handled = true; break; - case KeyAction.FormatItalics: + case MessageComposerAction.FormatItalics: this.onFormatAction(Formatting.Italics); handled = true; break; - case KeyAction.FormatQuote: + case MessageComposerAction.FormatQuote: this.onFormatAction(Formatting.Quote); handled = true; break; - case KeyAction.EditRedo: + case MessageComposerAction.EditRedo: if (this.historyManager.canRedo()) { const {parts, caret} = this.historyManager.redo(); // pass matching inputType so historyManager doesn't push echo @@ -444,7 +444,7 @@ export default class BasicMessageEditor extends React.Component } handled = true; break; - case KeyAction.EditUndo: + case MessageComposerAction.EditUndo: if (this.historyManager.canUndo()) { const {parts, caret} = this.historyManager.undo(this.props.model); // pass matching inputType so historyManager doesn't push echo @@ -453,18 +453,18 @@ export default class BasicMessageEditor extends React.Component } handled = true; break; - case KeyAction.NewLine: + case MessageComposerAction.NewLine: this.insertText("\n"); handled = true; break; - case KeyAction.MoveCursorToStart: + case MessageComposerAction.MoveCursorToStart: setSelection(this.editorRef.current, model, { index: 0, offset: 0, }); handled = true; break; - case KeyAction.MoveCursorToEnd: + case MessageComposerAction.MoveCursorToEnd: setSelection(this.editorRef.current, model, { index: model.parts.length - 1, offset: model.parts[model.parts.length - 1].text.length, @@ -478,30 +478,30 @@ export default class BasicMessageEditor extends React.Component return; } - const autocompleteAction = getKeyBindingsManager().getAction(KeyBindingContext.AutoComplete, event); + const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event); if (model.autoComplete && model.autoComplete.hasCompletions()) { const autoComplete = model.autoComplete; switch (autocompleteAction) { - case KeyAction.AutocompletePrevSelection: + case AutocompleteAction.PrevSelection: autoComplete.onUpArrow(event); handled = true; break; - case KeyAction.AutocompleteNextSelection: + case AutocompleteAction.NextSelection: autoComplete.onDownArrow(event); handled = true; break; - case KeyAction.AutocompleteApply: + case AutocompleteAction.ApplySelection: autoComplete.onTab(event); handled = true; break; - case KeyAction.AutocompleteCancel: + case AutocompleteAction.Cancel: autoComplete.onEscape(event); handled = true; break; default: return; // don't preventDefault on anything else } - } else if (autocompleteAction === KeyAction.AutocompleteApply) { + } else if (autocompleteAction === AutocompleteAction.ApplySelection) { this.tabCompleteName(event); handled = true; } else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) { diff --git a/src/components/views/rooms/EditMessageComposer.js b/src/components/views/rooms/EditMessageComposer.js index 8aa637f680..1cd2cc7f34 100644 --- a/src/components/views/rooms/EditMessageComposer.js +++ b/src/components/views/rooms/EditMessageComposer.js @@ -32,7 +32,7 @@ import BasicMessageComposer from "./BasicMessageComposer"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {Action} from "../../../dispatcher/actions"; import CountlyAnalytics from "../../../CountlyAnalytics"; -import {getKeyBindingsManager, KeyAction, KeyBindingContext} from '../../../KeyBindingsManager'; +import {getKeyBindingsManager, MessageComposerAction} from '../../../KeyBindingsManager'; function _isReply(mxEvent) { const relatesTo = mxEvent.getContent()["m.relates_to"]; @@ -133,16 +133,16 @@ export default class EditMessageComposer extends React.Component { if (this._editorRef.isComposing(event)) { return; } - const action = getKeyBindingsManager().getAction(KeyBindingContext.MessageComposer, event); + const action = getKeyBindingsManager().getMessageComposerAction(event); switch (action) { - case KeyAction.Send: + case MessageComposerAction.Send: this._sendEdit(); event.preventDefault(); break; - case KeyAction.CancelEditing: + case MessageComposerAction.CancelEditing: this._cancelEdit(); break; - case KeyAction.EditPrevMessage: { + case MessageComposerAction.EditPrevMessage: { if (this._editorRef.isModified() || !this._editorRef.isCaretAtStart()) { return; } @@ -154,7 +154,7 @@ export default class EditMessageComposer extends React.Component { } break; } - case KeyAction.EditNextMessage: { + case MessageComposerAction.EditNextMessage: { if (this._editorRef.isModified() || !this._editorRef.isCaretAtEnd()) { return; } diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index c0919090b0..25e3a34f34 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -51,7 +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"; +import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager"; const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS @@ -471,16 +471,16 @@ export default class RoomSublist extends React.Component { }; private onHeaderKeyDown = (ev: React.KeyboardEvent) => { - const action = getKeyBindingsManager().getAction(KeyBindingContext.RoomList, ev); + const action = getKeyBindingsManager().getRoomListAction(ev); switch (action) { - case KeyAction.RoomListCollapseSection: + case RoomListAction.CollapseSection: ev.stopPropagation(); if (this.state.isExpanded) { // Collapse the room sublist if it isn't already this.toggleCollapsed(); } break; - case KeyAction.RoomListExpandSection: { + case RoomListAction.ExpandSection: { ev.stopPropagation(); if (!this.state.isExpanded) { // Expand the room sublist if it isn't already diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index adfa38b56a..b5188b248b 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -46,7 +46,7 @@ import {CHAT_EFFECTS} from '../../../effects'; import CountlyAnalytics from "../../../CountlyAnalytics"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import EMOJI_REGEX from 'emojibase-regex'; -import {getKeyBindingsManager, KeyAction, KeyBindingContext} from '../../../KeyBindingsManager'; +import {getKeyBindingsManager, MessageComposerAction} from '../../../KeyBindingsManager'; function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); @@ -143,23 +143,23 @@ export default class SendMessageComposer extends React.Component { if (this._editorRef.isComposing(event)) { return; } - const action = getKeyBindingsManager().getAction(KeyBindingContext.MessageComposer, event); + const action = getKeyBindingsManager().getMessageComposerAction(event); switch (action) { - case KeyAction.Send: + case MessageComposerAction.Send: this._sendMessage(); event.preventDefault(); break; - case KeyAction.SelectPrevSendHistory: - case KeyAction.SelectNextSendHistory: { + case MessageComposerAction.SelectPrevSendHistory: + case MessageComposerAction.SelectNextSendHistory: { // Try select composer history - const selected = this.selectSendHistory(action === KeyAction.SelectPrevSendHistory); + const selected = this.selectSendHistory(action === MessageComposerAction.SelectPrevSendHistory); if (selected) { // We're selecting history, so prevent the key event from doing anything else event.preventDefault(); } break; } - case KeyAction.EditPrevMessage: + case MessageComposerAction.EditPrevMessage: // selection must be collapsed and caret at start if (this._editorRef.isSelectionCollapsed() && this._editorRef.isCaretAtStart()) { const editEvent = findEditableEvent(this.props.room, false); @@ -173,7 +173,7 @@ export default class SendMessageComposer extends React.Component { } } break; - case KeyAction.CancelEditing: + case MessageComposerAction.CancelEditing: dis.dispatch({ action: 'reply_to_event', event: null, From ef7284e69d58fb463c36b50813c46d1348b8cb26 Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Mon, 1 Mar 2021 22:15:05 +1300 Subject: [PATCH 011/104] Add missing JumpToOldestUnread action --- src/KeyBindingsManager.ts | 2 ++ src/components/structures/RoomView.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index d8c128a2bf..00e16ce2ab 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -70,6 +70,8 @@ export enum RoomAction { RoomScrollDown = 'RoomScrollDown', /** Dismiss read marker and jump to bottom */ DismissReadMarker = 'DismissReadMarker', + /** Jump to oldest unread message */ + JumpToOldestUnread = 'JumpToOldestUnread', /* Upload a file */ UploadFile = 'UploadFile', /* Focus search message in a room (must be enabled) */ diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 680d717615..9c9dc232a9 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -668,7 +668,7 @@ export default class RoomView extends React.Component { this.jumpToLiveTimeline(); handled = true; break; - case RoomAction.ScrollUp: + case RoomAction.JumpToOldestUnread: this.jumpToReadMarker(); handled = true; break; From 1cfb0e99d43fb5cfe76142c8defa711da0237aed Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Mon, 1 Mar 2021 22:16:05 +1300 Subject: [PATCH 012/104] Add support for multiple key bindings provider - This can be used to provide custom key bindings - Move default key bindings into its own file --- src/KeyBindingsDefaults.ts | 384 ++++++++++++++++++++++++++++++++++ src/KeyBindingsManager.ts | 418 ++++--------------------------------- 2 files changed, 421 insertions(+), 381 deletions(-) create mode 100644 src/KeyBindingsDefaults.ts diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts new file mode 100644 index 0000000000..ed98a06c7f --- /dev/null +++ b/src/KeyBindingsDefaults.ts @@ -0,0 +1,384 @@ +import { AutocompleteAction, IKeyBindingsProvider, KeyBinding, MessageComposerAction, NavigationAction, RoomAction, + RoomListAction } from "./KeyBindingsManager"; +import { isMac, Key } from "./Keyboard"; +import SettingsStore from "./settings/SettingsStore"; + +const messageComposerBindings = (): KeyBinding[] => { + const bindings: KeyBinding[] = [ + { + action: MessageComposerAction.SelectPrevSendHistory, + keyCombo: { + key: Key.ARROW_UP, + altKey: true, + ctrlKey: true, + }, + }, + { + action: MessageComposerAction.SelectNextSendHistory, + keyCombo: { + key: Key.ARROW_DOWN, + altKey: true, + ctrlKey: true, + }, + }, + { + action: MessageComposerAction.EditPrevMessage, + keyCombo: { + key: Key.ARROW_UP, + }, + }, + { + action: MessageComposerAction.EditNextMessage, + keyCombo: { + key: Key.ARROW_DOWN, + }, + }, + { + action: MessageComposerAction.CancelEditing, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: MessageComposerAction.FormatBold, + keyCombo: { + key: Key.B, + ctrlOrCmd: true, + }, + }, + { + action: MessageComposerAction.FormatItalics, + keyCombo: { + key: Key.I, + ctrlOrCmd: true, + }, + }, + { + action: MessageComposerAction.FormatQuote, + keyCombo: { + key: Key.GREATER_THAN, + ctrlOrCmd: true, + shiftKey: true, + }, + }, + { + action: MessageComposerAction.EditUndo, + keyCombo: { + key: Key.Z, + ctrlOrCmd: true, + }, + }, + // Note: the following two bindings also work with just HOME and END, add them here? + { + action: MessageComposerAction.MoveCursorToStart, + keyCombo: { + key: Key.HOME, + ctrlOrCmd: true, + }, + }, + { + action: MessageComposerAction.MoveCursorToEnd, + keyCombo: { + key: Key.END, + ctrlOrCmd: true, + }, + }, + ]; + if (isMac) { + bindings.push({ + action: MessageComposerAction.EditRedo, + keyCombo: { + key: Key.Z, + ctrlOrCmd: true, + shiftKey: true, + }, + }); + } else { + bindings.push({ + action: MessageComposerAction.EditRedo, + keyCombo: { + key: Key.Y, + ctrlOrCmd: true, + }, + }); + } + if (SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend')) { + bindings.push({ + action: MessageComposerAction.Send, + keyCombo: { + key: Key.ENTER, + ctrlOrCmd: true, + }, + }); + bindings.push({ + action: MessageComposerAction.NewLine, + keyCombo: { + key: Key.ENTER, + }, + }); + } else { + bindings.push({ + action: MessageComposerAction.Send, + keyCombo: { + key: Key.ENTER, + }, + }); + bindings.push({ + action: MessageComposerAction.NewLine, + keyCombo: { + key: Key.ENTER, + shiftKey: true, + }, + }); + if (isMac) { + bindings.push({ + action: MessageComposerAction.NewLine, + keyCombo: { + key: Key.ENTER, + altKey: true, + }, + }); + } + } + return bindings; +} + +const autocompleteBindings = (): KeyBinding[] => { + return [ + { + action: AutocompleteAction.ApplySelection, + keyCombo: { + key: Key.TAB, + }, + }, + { + action: AutocompleteAction.ApplySelection, + keyCombo: { + key: Key.TAB, + ctrlKey: true, + }, + }, + { + action: AutocompleteAction.ApplySelection, + keyCombo: { + key: Key.TAB, + shiftKey: true, + }, + }, + { + action: AutocompleteAction.Cancel, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: AutocompleteAction.PrevSelection, + keyCombo: { + key: Key.ARROW_UP, + }, + }, + { + action: AutocompleteAction.NextSelection, + keyCombo: { + key: Key.ARROW_DOWN, + }, + }, + ]; +} + +const roomListBindings = (): KeyBinding[] => { + return [ + { + action: RoomListAction.ClearSearch, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: RoomListAction.PrevRoom, + keyCombo: { + key: Key.ARROW_UP, + }, + }, + { + action: RoomListAction.NextRoom, + keyCombo: { + key: Key.ARROW_DOWN, + }, + }, + { + action: RoomListAction.SelectRoom, + keyCombo: { + key: Key.ENTER, + }, + }, + { + action: RoomListAction.CollapseSection, + keyCombo: { + key: Key.ARROW_LEFT, + }, + }, + { + action: RoomListAction.ExpandSection, + keyCombo: { + key: Key.ARROW_RIGHT, + }, + }, + ]; +} + +const roomBindings = (): KeyBinding[] => { + const bindings = [ + { + action: RoomAction.FocusRoomSearch, + keyCombo: { + key: Key.K, + ctrlOrCmd: true, + }, + }, + { + action: RoomAction.ScrollUp, + keyCombo: { + key: Key.PAGE_UP, + }, + }, + { + action: RoomAction.RoomScrollDown, + keyCombo: { + key: Key.PAGE_DOWN, + }, + }, + { + action: RoomAction.DismissReadMarker, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: RoomAction.JumpToOldestUnread, + keyCombo: { + key: Key.PAGE_UP, + shiftKey: true, + }, + }, + { + action: RoomAction.UploadFile, + keyCombo: { + key: Key.U, + ctrlOrCmd: true, + shiftKey: true, + }, + }, + { + action: RoomAction.JumpToFirstMessage, + keyCombo: { + key: Key.HOME, + ctrlKey: true, + }, + }, + { + action: RoomAction.JumpToLatestMessage, + keyCombo: { + key: Key.END, + ctrlKey: true, + }, + }, + ]; + + if (SettingsStore.getValue('ctrlFForSearch')) { + bindings.push({ + action: RoomAction.FocusSearch, + keyCombo: { + key: Key.F, + ctrlOrCmd: true, + }, + }); + } + + return bindings; +} + +const navigationBindings = (): KeyBinding[] => { + return [ + { + action: NavigationAction.ToggleRoomSidePanel, + keyCombo: { + key: Key.PERIOD, + ctrlOrCmd: true, + }, + }, + { + action: NavigationAction.ToggleUserMenu, + // 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: NavigationAction.ToggleShortCutDialog, + keyCombo: { + key: Key.SLASH, + ctrlOrCmd: true, + }, + }, + { + action: NavigationAction.ToggleShortCutDialog, + keyCombo: { + key: Key.SLASH, + ctrlOrCmd: true, + shiftKey: true, + }, + }, + { + action: NavigationAction.GoToHome, + keyCombo: { + key: Key.H, + ctrlOrCmd: true, + altKey: true, + }, + }, + + { + action: NavigationAction.SelectPrevRoom, + keyCombo: { + key: Key.ARROW_UP, + altKey: true, + }, + }, + { + action: NavigationAction.SelectNextRoom, + keyCombo: { + key: Key.ARROW_DOWN, + altKey: true, + }, + }, + { + action: NavigationAction.SelectPrevUnreadRoom, + keyCombo: { + key: Key.ARROW_UP, + altKey: true, + shiftKey: true, + }, + }, + { + action: NavigationAction.SelectNextUnreadRoom, + keyCombo: { + key: Key.ARROW_DOWN, + altKey: true, + shiftKey: true, + }, + }, + ] +} + +export const defaultBindingProvider: IKeyBindingsProvider = { + getMessageComposerBindings: messageComposerBindings, + getAutocompleteBindings: autocompleteBindings, + getRoomListBindings: roomListBindings, + getRoomBindings: roomBindings, + getNavigationBindings: navigationBindings, +} diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index 00e16ce2ab..cf11fc711f 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -1,5 +1,5 @@ -import { isMac, Key } from './Keyboard'; -import SettingsStore from './settings/SettingsStore'; +import { defaultBindingProvider } from './KeyBindingsDefaults'; +import { isMac } from './Keyboard'; /** Actions for the chat message composer component */ export enum MessageComposerAction { @@ -124,371 +124,6 @@ export type KeyBinding = { keyCombo: KeyCombo; } -const messageComposerBindings = (): KeyBinding[] => { - const bindings: KeyBinding[] = [ - { - action: MessageComposerAction.SelectPrevSendHistory, - keyCombo: { - key: Key.ARROW_UP, - altKey: true, - ctrlKey: true, - }, - }, - { - action: MessageComposerAction.SelectNextSendHistory, - keyCombo: { - key: Key.ARROW_DOWN, - altKey: true, - ctrlKey: true, - }, - }, - { - action: MessageComposerAction.EditPrevMessage, - keyCombo: { - key: Key.ARROW_UP, - }, - }, - { - action: MessageComposerAction.EditNextMessage, - keyCombo: { - key: Key.ARROW_DOWN, - }, - }, - { - action: MessageComposerAction.CancelEditing, - keyCombo: { - key: Key.ESCAPE, - }, - }, - { - action: MessageComposerAction.FormatBold, - keyCombo: { - key: Key.B, - ctrlOrCmd: true, - }, - }, - { - action: MessageComposerAction.FormatItalics, - keyCombo: { - key: Key.I, - ctrlOrCmd: true, - }, - }, - { - action: MessageComposerAction.FormatQuote, - keyCombo: { - key: Key.GREATER_THAN, - ctrlOrCmd: true, - shiftKey: true, - }, - }, - { - action: MessageComposerAction.EditUndo, - keyCombo: { - key: Key.Z, - ctrlOrCmd: true, - }, - }, - // Note: the following two bindings also work with just HOME and END, add them here? - { - action: MessageComposerAction.MoveCursorToStart, - keyCombo: { - key: Key.HOME, - ctrlOrCmd: true, - }, - }, - { - action: MessageComposerAction.MoveCursorToEnd, - keyCombo: { - key: Key.END, - ctrlOrCmd: true, - }, - }, - ]; - if (isMac) { - bindings.push({ - action: MessageComposerAction.EditRedo, - keyCombo: { - key: Key.Z, - ctrlOrCmd: true, - shiftKey: true, - }, - }); - } else { - bindings.push({ - action: MessageComposerAction.EditRedo, - keyCombo: { - key: Key.Y, - ctrlOrCmd: true, - }, - }); - } - if (SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend')) { - bindings.push({ - action: MessageComposerAction.Send, - keyCombo: { - key: Key.ENTER, - ctrlOrCmd: true, - }, - }); - bindings.push({ - action: MessageComposerAction.NewLine, - keyCombo: { - key: Key.ENTER, - }, - }); - } else { - bindings.push({ - action: MessageComposerAction.Send, - keyCombo: { - key: Key.ENTER, - }, - }); - bindings.push({ - action: MessageComposerAction.NewLine, - keyCombo: { - key: Key.ENTER, - shiftKey: true, - }, - }); - if (isMac) { - bindings.push({ - action: MessageComposerAction.NewLine, - keyCombo: { - key: Key.ENTER, - altKey: true, - }, - }); - } - } - return bindings; -} - -const autocompleteBindings = (): KeyBinding[] => { - return [ - { - action: AutocompleteAction.ApplySelection, - keyCombo: { - key: Key.TAB, - }, - }, - { - action: AutocompleteAction.ApplySelection, - keyCombo: { - key: Key.TAB, - ctrlKey: true, - }, - }, - { - action: AutocompleteAction.ApplySelection, - keyCombo: { - key: Key.TAB, - shiftKey: true, - }, - }, - { - action: AutocompleteAction.Cancel, - keyCombo: { - key: Key.ESCAPE, - }, - }, - { - action: AutocompleteAction.PrevSelection, - keyCombo: { - key: Key.ARROW_UP, - }, - }, - { - action: AutocompleteAction.NextSelection, - keyCombo: { - key: Key.ARROW_DOWN, - }, - }, - ]; -} - -const roomListBindings = (): KeyBinding[] => { - return [ - { - action: RoomListAction.ClearSearch, - keyCombo: { - key: Key.ESCAPE, - }, - }, - { - action: RoomListAction.PrevRoom, - keyCombo: { - key: Key.ARROW_UP, - }, - }, - { - action: RoomListAction.NextRoom, - keyCombo: { - key: Key.ARROW_DOWN, - }, - }, - { - action: RoomListAction.SelectRoom, - keyCombo: { - key: Key.ENTER, - }, - }, - { - action: RoomListAction.CollapseSection, - keyCombo: { - key: Key.ARROW_LEFT, - }, - }, - { - action: RoomListAction.ExpandSection, - keyCombo: { - key: Key.ARROW_RIGHT, - }, - }, - ]; -} - -const roomBindings = (): KeyBinding[] => { - const bindings = [ - { - action: RoomAction.FocusRoomSearch, - keyCombo: { - key: Key.K, - ctrlOrCmd: true, - }, - }, - { - action: RoomAction.ScrollUp, - keyCombo: { - key: Key.PAGE_UP, - }, - }, - { - action: RoomAction.RoomScrollDown, - keyCombo: { - key: Key.PAGE_DOWN, - }, - }, - { - action: RoomAction.DismissReadMarker, - keyCombo: { - key: Key.ESCAPE, - }, - }, - { - action: RoomAction.UploadFile, - keyCombo: { - key: Key.U, - ctrlOrCmd: true, - shiftKey: true, - }, - }, - { - action: RoomAction.JumpToFirstMessage, - keyCombo: { - key: Key.HOME, - ctrlKey: true, - }, - }, - { - action: RoomAction.JumpToLatestMessage, - keyCombo: { - key: Key.END, - ctrlKey: true, - }, - }, - ]; - - if (SettingsStore.getValue('ctrlFForSearch')) { - bindings.push({ - action: RoomAction.FocusSearch, - keyCombo: { - key: Key.F, - ctrlOrCmd: true, - }, - }); - } - - return bindings; -} - -const navigationBindings = (): KeyBinding[] => { - return [ - { - action: NavigationAction.ToggleRoomSidePanel, - keyCombo: { - key: Key.PERIOD, - ctrlOrCmd: true, - }, - }, - { - action: NavigationAction.ToggleUserMenu, - // 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: NavigationAction.ToggleShortCutDialog, - keyCombo: { - key: Key.SLASH, - ctrlOrCmd: true, - }, - }, - { - action: NavigationAction.ToggleShortCutDialog, - keyCombo: { - key: Key.SLASH, - ctrlOrCmd: true, - shiftKey: true, - }, - }, - { - action: NavigationAction.GoToHome, - keyCombo: { - key: Key.H, - ctrlOrCmd: true, - altKey: true, - }, - }, - - { - action: NavigationAction.SelectPrevRoom, - keyCombo: { - key: Key.ARROW_UP, - altKey: true, - }, - }, - { - action: NavigationAction.SelectNextRoom, - keyCombo: { - key: Key.ARROW_DOWN, - altKey: true, - }, - }, - { - action: NavigationAction.SelectPrevUnreadRoom, - keyCombo: { - key: Key.ARROW_UP, - altKey: true, - shiftKey: true, - }, - }, - { - action: NavigationAction.SelectNextUnreadRoom, - keyCombo: { - key: Key.ARROW_DOWN, - altKey: true, - shiftKey: true, - }, - }, - ] -} - /** * Helper method to check if a KeyboardEvent matches a KeyCombo * @@ -541,42 +176,63 @@ export function isKeyComboMatch(ev: KeyboardEvent | React.KeyboardEvent, combo: return true; } + +export type KeyBindingGetter = () => KeyBinding[]; + +export interface IKeyBindingsProvider { + getMessageComposerBindings: KeyBindingGetter; + getAutocompleteBindings: KeyBindingGetter; + getRoomListBindings: KeyBindingGetter; + getRoomBindings: KeyBindingGetter; + getNavigationBindings: KeyBindingGetter; +} + export class KeyBindingsManager { + /** + * List of key bindings providers. + * + * Key bindings from the first provider(s) in the list will have precedence over key bindings from later providers. + * + * To overwrite the default key bindings add a new providers before the default provider, e.g. a provider for + * customized key bindings. + */ + bindingsProviders: IKeyBindingsProvider[] = [ + defaultBindingProvider, + ]; + /** * Finds a matching KeyAction for a given KeyboardEvent */ - private getAction(bindings: KeyBinding[], ev: KeyboardEvent | React.KeyboardEvent) + private getAction(getters: KeyBindingGetter[], ev: KeyboardEvent | React.KeyboardEvent) : T | undefined { - const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac)); - if (binding) { - return binding.action; + for (const getter of getters) { + const bindings = getter(); + const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac)); + if (binding) { + return binding.action; + } } return undefined; } getMessageComposerAction(ev: KeyboardEvent | React.KeyboardEvent): MessageComposerAction | undefined { - const bindings = messageComposerBindings(); - return this.getAction(bindings, ev); + return this.getAction(this.bindingsProviders.map(it => it.getMessageComposerBindings), ev); } getAutocompleteAction(ev: KeyboardEvent | React.KeyboardEvent): AutocompleteAction | undefined { - const bindings = autocompleteBindings(); - return this.getAction(bindings, ev); + return this.getAction(this.bindingsProviders.map(it => it.getAutocompleteBindings), ev); } getRoomListAction(ev: KeyboardEvent | React.KeyboardEvent): RoomListAction | undefined { - const bindings = roomListBindings(); - return this.getAction(bindings, ev); + return this.getAction(this.bindingsProviders.map(it => it.getRoomListBindings), ev); } getRoomAction(ev: KeyboardEvent | React.KeyboardEvent): RoomAction | undefined { - const bindings = roomBindings(); - return this.getAction(bindings, ev); + return this.getAction(this.bindingsProviders.map(it => it.getRoomBindings), ev); } getNavigationAction(ev: KeyboardEvent | React.KeyboardEvent): NavigationAction | undefined { - const bindings = navigationBindings(); - return this.getAction(bindings, ev); + return this.getAction(this.bindingsProviders.map(it => it.getNavigationBindings), ev); } } From 0214397e27c9df533775b7039d659cbbc3a4ee89 Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Wed, 3 Mar 2021 22:06:36 +1300 Subject: [PATCH 013/104] Fix handling of the platform onKeyDown Only call it if the event hasn't been handled yet. --- src/components/structures/LoggedInView.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index ce5df47138..b3607ec5b5 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -485,9 +485,10 @@ class LoggedInView extends React.Component { unread: true, }); break; + default: + // if we do not have a handler for it, pass it to the platform which might + handled = PlatformPeg.get().onKeyDown(ev); } - // 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(); From 7b740857084a89f1b201874acf4690c21444d92e Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Wed, 3 Mar 2021 22:07:44 +1300 Subject: [PATCH 014/104] Add missing binding + remove invalid note HOME and END are going back to the start/end of the same line, i.e. they are different to the other bindings. --- src/KeyBindingsDefaults.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index ed98a06c7f..f777f2c5f6 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -68,7 +68,6 @@ const messageComposerBindings = (): KeyBinding[] => { ctrlOrCmd: true, }, }, - // Note: the following two bindings also work with just HOME and END, add them here? { action: MessageComposerAction.MoveCursorToStart, keyCombo: { @@ -165,6 +164,14 @@ const autocompleteBindings = (): KeyBinding[] => { shiftKey: true, }, }, + { + action: AutocompleteAction.ApplySelection, + keyCombo: { + key: Key.TAB, + ctrlKey: true, + shiftKey: true, + }, + }, { action: AutocompleteAction.Cancel, keyCombo: { From dadeb68bbfceade031150be13f3bfdb6e9ae0f6d Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Fri, 5 Mar 2021 22:02:18 +1300 Subject: [PATCH 015/104] Fix spelling --- src/KeyBindingsDefaults.ts | 2 +- src/KeyBindingsManager.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index f777f2c5f6..847867ae4f 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -382,7 +382,7 @@ const navigationBindings = (): KeyBinding[] => { ] } -export const defaultBindingProvider: IKeyBindingsProvider = { +export const defaultBindingsProvider: IKeyBindingsProvider = { getMessageComposerBindings: messageComposerBindings, getAutocompleteBindings: autocompleteBindings, getRoomListBindings: roomListBindings, diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index cf11fc711f..725bfd65f1 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -1,4 +1,4 @@ -import { defaultBindingProvider } from './KeyBindingsDefaults'; +import { defaultBindingsProvider } from './KeyBindingsDefaults'; import { isMac } from './Keyboard'; /** Actions for the chat message composer component */ @@ -197,7 +197,7 @@ export class KeyBindingsManager { * customized key bindings. */ bindingsProviders: IKeyBindingsProvider[] = [ - defaultBindingProvider, + defaultBindingsProvider, ]; /** From efc5d413c48162687a8e21688f628646ccdb49a4 Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Fri, 5 Mar 2021 22:13:47 +1300 Subject: [PATCH 016/104] Fix missing import (from earlier merge conflict) --- src/components/views/rooms/SendMessageComposer.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 0c0495fe20..1902498914 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -47,6 +47,7 @@ import CountlyAnalytics from "../../../CountlyAnalytics"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import EMOJI_REGEX from 'emojibase-regex'; import {getKeyBindingsManager, MessageComposerAction} from '../../../KeyBindingsManager'; +import SettingsStore from '../../../settings/SettingsStore'; function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); From 71d63f016a94439e8604c3fcdebc1245a29bd92e Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Sat, 6 Mar 2021 14:17:53 +1300 Subject: [PATCH 017/104] Fix tests that mock incomplete key events --- src/KeyBindingsManager.ts | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index 725bfd65f1..681dc7d879 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -147,30 +147,35 @@ export function isKeyComboMatch(ev: KeyboardEvent | React.KeyboardEvent, combo: const comboAlt = combo.altKey ?? false; const comboShift = combo.shiftKey ?? false; const comboMeta = combo.metaKey ?? false; + // Tests mock events may keep the modifiers undefined; convert them to booleans + const evCtrl = ev.ctrlKey ?? false; + const evAlt = ev.altKey ?? false; + const evShift = ev.shiftKey ?? false; + const evMeta = ev.metaKey ?? false; // When ctrlOrCmd is set, the keys need do evaluated differently on PC and Mac if (combo.ctrlOrCmd) { if (onMac) { - if (!ev.metaKey - || ev.ctrlKey !== comboCtrl - || ev.altKey !== comboAlt - || ev.shiftKey !== comboShift) { + if (!evMeta + || evCtrl !== comboCtrl + || evAlt !== comboAlt + || evShift !== comboShift) { return false; } } else { - if (!ev.ctrlKey - || ev.metaKey !== comboMeta - || ev.altKey !== comboAlt - || ev.shiftKey !== comboShift) { + if (!evCtrl + || evMeta !== comboMeta + || evAlt !== comboAlt + || evShift !== comboShift) { return false; } } return true; } - if (ev.metaKey !== comboMeta - || ev.ctrlKey !== comboCtrl - || ev.altKey !== comboAlt - || ev.shiftKey !== comboShift) { + if (evMeta !== comboMeta + || evCtrl !== comboCtrl + || evAlt !== comboAlt + || evShift !== comboShift) { return false; } From 06181221a143ead1d73ee6d5d0d49362f3883682 Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Sun, 7 Mar 2021 19:05:36 +1300 Subject: [PATCH 018/104] Add copyright headers --- src/KeyBindingsDefaults.ts | 16 ++++++++++++++++ src/KeyBindingsManager.ts | 16 ++++++++++++++++ test/KeyBindingsManager-test.ts | 16 ++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index 847867ae4f..fd00a2ff53 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -1,3 +1,19 @@ +/* +Copyright 2021 Clemens Zeidler + +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 { AutocompleteAction, IKeyBindingsProvider, KeyBinding, MessageComposerAction, NavigationAction, RoomAction, RoomListAction } from "./KeyBindingsManager"; import { isMac, Key } from "./Keyboard"; diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index 681dc7d879..7e996b2730 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -1,3 +1,19 @@ +/* +Copyright 2021 Clemens Zeidler + +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 { defaultBindingsProvider } from './KeyBindingsDefaults'; import { isMac } from './Keyboard'; diff --git a/test/KeyBindingsManager-test.ts b/test/KeyBindingsManager-test.ts index 28204be9c8..41614b61fa 100644 --- a/test/KeyBindingsManager-test.ts +++ b/test/KeyBindingsManager-test.ts @@ -1,3 +1,19 @@ +/* +Copyright 2021 Clemens Zeidler + +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 { isKeyComboMatch, KeyCombo } from '../src/KeyBindingsManager'; const assert = require('assert'); From a8a8741c06a9942038fe1f40b75b708b28410732 Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Fri, 12 Mar 2021 19:40:28 +1300 Subject: [PATCH 019/104] Make FocusRoomSearch a NavigationAction --- src/KeyBindingsDefaults.ts | 18 +++++++++--------- src/KeyBindingsManager.ts | 4 ++-- src/components/structures/LoggedInView.tsx | 12 ++++++------ 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index fd00a2ff53..0e9d14ea8f 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -251,14 +251,7 @@ const roomListBindings = (): KeyBinding[] => { } const roomBindings = (): KeyBinding[] => { - const bindings = [ - { - action: RoomAction.FocusRoomSearch, - keyCombo: { - key: Key.K, - ctrlOrCmd: true, - }, - }, + const bindings: KeyBinding[] = [ { action: RoomAction.ScrollUp, keyCombo: { @@ -323,6 +316,13 @@ const roomBindings = (): KeyBinding[] => { const navigationBindings = (): KeyBinding[] => { return [ + { + action: NavigationAction.FocusRoomSearch, + keyCombo: { + key: Key.K, + ctrlOrCmd: true, + }, + }, { action: NavigationAction.ToggleRoomSidePanel, keyCombo: { @@ -395,7 +395,7 @@ const navigationBindings = (): KeyBinding[] => { shiftKey: true, }, }, - ] + ]; } export const defaultBindingsProvider: IKeyBindingsProvider = { diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index 7e996b2730..73940e0371 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -78,8 +78,6 @@ export enum RoomListAction { /** Actions for the current room view */ export enum RoomAction { - /** Jump to room search (search for a room)*/ - FocusRoomSearch = 'FocusRoomSearch', // TODO: move to NavigationAction? /** Scroll up in the timeline */ ScrollUp = 'ScrollUp', /** Scroll down in the timeline */ @@ -100,6 +98,8 @@ export enum RoomAction { /** Actions for navigating do various menus / dialogs / screens */ export enum NavigationAction { + /** Jump to room search (search for a room)*/ + FocusRoomSearch = 'FocusRoomSearch', /** Toggle the room side panel */ ToggleRoomSidePanel = 'ToggleRoomSidePanel', /** Toggle the user menu */ diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index dcc140148d..9360ab4e9e 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -449,12 +449,6 @@ class LoggedInView extends React.Component { const roomAction = getKeyBindingsManager().getRoomAction(ev); switch (roomAction) { - case RoomAction.FocusRoomSearch: - dis.dispatch({ - action: 'focus_room_filter', - }); - handled = true; - break; case RoomAction.ScrollUp: case RoomAction.RoomScrollDown: case RoomAction.JumpToFirstMessage: @@ -477,6 +471,12 @@ class LoggedInView extends React.Component { const navAction = getKeyBindingsManager().getNavigationAction(ev); switch (navAction) { + case NavigationAction.FocusRoomSearch: + dis.dispatch({ + action: 'focus_room_filter', + }); + handled = true; + break; case NavigationAction.ToggleUserMenu: dis.fire(Action.ToggleUserMenu); handled = true; From 228070f53377c8e9f0cc45be8b782c36980fcc3d Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Sat, 13 Mar 2021 21:53:58 +1300 Subject: [PATCH 020/104] Fix comment style + improve comments --- src/KeyBindingsManager.ts | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index 73940e0371..45ef97b121 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -29,7 +29,7 @@ export enum MessageComposerAction { EditPrevMessage = 'EditPrevMessage', /** Start editing the user's next sent message */ EditNextMessage = 'EditNextMessage', - /** Cancel editing a message or cancel replying to a message*/ + /** Cancel editing a message or cancel replying to a message */ CancelEditing = 'CancelEditing', /** Set bold format the current selection */ @@ -44,7 +44,9 @@ export enum MessageComposerAction { EditRedo = 'EditRedo', /** Insert new line */ NewLine = 'NewLine', + /** Move the cursor to the start of the message */ MoveCursorToStart = 'MoveCursorToStart', + /** Move the cursor to the end of the message */ MoveCursorToEnd = 'MoveCursorToEnd', } @@ -60,7 +62,7 @@ export enum AutocompleteAction { NextSelection = 'NextSelection', } -/** Actions for the left room list sidebar */ +/** Actions for the room list sidebar */ export enum RoomListAction { /** Clear room list filter field */ ClearSearch = 'ClearSearch', @@ -86,35 +88,35 @@ export enum RoomAction { DismissReadMarker = 'DismissReadMarker', /** Jump to oldest unread message */ JumpToOldestUnread = 'JumpToOldestUnread', - /* Upload a file */ + /** Upload a file */ UploadFile = 'UploadFile', - /* Focus search message in a room (must be enabled) */ + /** Focus search message in a room (must be enabled) */ FocusSearch = 'FocusSearch', - /* Jump to the first (downloaded) message in the room */ + /** Jump to the first (downloaded) message in the room */ JumpToFirstMessage = 'JumpToFirstMessage', - /* Jump to the latest message in the room */ + /** Jump to the latest message in the room */ JumpToLatestMessage = 'JumpToLatestMessage', } -/** Actions for navigating do various menus / dialogs / screens */ +/** Actions for navigating do various menus, dialogs or screens */ export enum NavigationAction { - /** Jump to room search (search for a room)*/ + /** Jump to room search (search for a room) */ FocusRoomSearch = 'FocusRoomSearch', /** Toggle the room side panel */ ToggleRoomSidePanel = 'ToggleRoomSidePanel', /** Toggle the user menu */ ToggleUserMenu = 'ToggleUserMenu', - /* Toggle the short cut help dialog */ + /** Toggle the short cut help dialog */ ToggleShortCutDialog = 'ToggleShortCutDialog', - /* Got to the Element home screen */ + /** Got to the Element home screen */ GoToHome = 'GoToHome', - /* Select prev room */ + /** Select prev room */ SelectPrevRoom = 'SelectPrevRoom', - /* Select next room */ + /** Select next room */ SelectNextRoom = 'SelectNextRoom', - /* Select prev room with unread messages*/ + /** Select prev room with unread messages */ SelectPrevUnreadRoom = 'SelectPrevUnreadRoom', - /* Select next room with unread messages*/ + /** Select next room with unread messages */ SelectNextUnreadRoom = 'SelectNextUnreadRoom', } From 7f141276fffd3a6873a3dfa7282b202a723b851c Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Mon, 15 Mar 2021 22:56:56 -0400 Subject: [PATCH 021/104] initial work on room history key sharing, take 2 --- src/components/views/dialogs/InviteDialog.tsx | 55 +++++++++++++++++-- src/i18n/strings/en_EN.json | 2 + src/settings/Settings.ts | 6 ++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 5b936e822c..fa87826c09 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -42,6 +42,7 @@ import {UIFeature} from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; import {Room} from "matrix-js-sdk/src/models/room"; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; +import {getAddressType} from "../../../UserAddress"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -676,14 +677,15 @@ export default class InviteDialog extends React.PureComponent { + _inviteUsers = async () => { const startTime = CountlyAnalytics.getTimestamp(); this.setState({busy: true}); this._convertFilter(); const targets = this._convertFilter(); const targetIds = targets.map(t => t.userId); - const room = MatrixClientPeg.get().getRoom(this.props.roomId); + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(this.props.roomId); if (!room) { console.error("Failed to find the room to invite users to"); this.setState({ @@ -693,12 +695,34 @@ export default class InviteDialog extends React.PureComponent { + try { + const result = await inviteMultipleToRoom(this.props.roomId, targetIds) CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length); if (!this._shouldAbortAfterInviteError(result)) { // handles setting error message too this.props.onFinished(); } - }).catch(err => { + + if (cli.isRoomEncrypted(this.props.roomId) && + SettingsStore.getValue("feature_room_history_key_sharing")) { + const visibilityEvent = room.currentState.getStateEvents( + "m.room.history_visibility", "", + ); + const visibility = visibilityEvent && visibilityEvent.getContent() && + visibilityEvent.getContent().history_visibility; + if (visibility == "world_readable" || visibility == "shared") { + const invitedUsers = []; + for (const [addr, state] of Object.entries(result.states)) { + if (state === "invited" && getAddressType(addr) === "mx-user-id") { + invitedUsers.push(addr); + } + } + console.log("Sharing history with", invitedUsers); + cli.sendSharedHistoryKeys( + this.props.roomId, invitedUsers, + ); + } + } + } catch (err) { console.error(err); this.setState({ busy: false, @@ -706,7 +730,7 @@ export default class InviteDialog extends React.PureComponent { @@ -1187,10 +1211,12 @@ export default class InviteDialog extends React.PureComponent; const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer); - const userId = MatrixClientPeg.get().getUserId(); + const cli = MatrixClientPeg.get(); + const userId = cli.getUserId(); if (this.props.kind === KIND_DM) { title = _t("Direct Messages"); @@ -1281,6 +1307,22 @@ export default class InviteDialog extends React.PureComponent + {_t("Note: Decryption keys for old messages will be shared with invited users.")} + ; + } + } } else if (this.props.kind === KIND_CALL_TRANSFER) { title = _t("Transfer"); buttonText = _t("Transfer"); @@ -1314,6 +1356,7 @@ export default class InviteDialog extends React.PureComponent + {keySharingWarning} {this._renderIdentityServerWarning()}
{this.state.errorText}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 38460a5f6e..dc808cb8bd 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -791,6 +791,7 @@ "Show message previews for reactions in DMs": "Show message previews for reactions in DMs", "Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms", "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices", + "Share decryption keys for room history when inviting users": "Share decryption keys for room history when inviting users", "Enable advanced debugging for the room list": "Enable advanced debugging for the room list", "Show info about bridges in room settings": "Show info about bridges in room settings", "Font size": "Font size", @@ -2153,6 +2154,7 @@ "Go": "Go", "Invite someone using their name, email address, username (like ) or share this room.": "Invite someone using their name, email address, username (like ) or share this room.", "Invite someone using their name, username (like ) or share this room.": "Invite someone using their name, username (like ) or share this room.", + "Note: Decryption keys for old messages will be shared with invited users.": "Note: Decryption keys for old messages will be shared with invited users.", "Transfer": "Transfer", "a new master key signature": "a new master key signature", "a new cross-signing key signature": "a new cross-signing key signature", diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index 43210021e5..77b0f187c7 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -214,6 +214,12 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_room_history_key_sharing": { + isFeature: true, + displayName: _td("Share decryption keys for room history when inviting users"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "advancedRoomListLogging": { // TODO: Remove flag before launch: https://github.com/vector-im/element-web/issues/14231 displayName: _td("Enable advanced debugging for the room list"), From 727c189456c6f953993cb72118aae0ba9340e503 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Fri, 19 Mar 2021 16:55:07 -0400 Subject: [PATCH 022/104] apply changes from review --- src/components/views/dialogs/InviteDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index fa87826c09..41fb1f5ab6 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -1316,7 +1316,7 @@ export default class InviteDialog extends React.PureComponent {_t("Note: Decryption keys for old messages will be shared with invited users.")} From 2818cdf3d4bf3ad990317c7629b063a40261642e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 22 Mar 2021 17:46:50 +0000 Subject: [PATCH 023/104] Fix space hierarchy exploding when encountering an empty subspace --- src/components/structures/SpaceRoomDirectory.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx index 0a53f38238..ab273887c2 100644 --- a/src/components/structures/SpaceRoomDirectory.tsx +++ b/src/components/structures/SpaceRoomDirectory.tsx @@ -260,7 +260,7 @@ export const HierarchyLevel = ({ const space = cli.getRoom(spaceId); const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()) - const sortedChildren = sortBy([...relations.get(spaceId)?.values()], ev => ev.content.order || null); + const sortedChildren = sortBy([...(relations.get(spaceId)?.values() || [])], ev => ev.content.order || null); const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => { const roomId = ev.state_key; if (!rooms.has(roomId)) return result; From 796bfd851de39878ae8d082a9a3eb72f3d53cd1a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 22 Mar 2021 17:47:48 +0000 Subject: [PATCH 024/104] Fix left spaces not disappearing from the space panel --- src/stores/SpaceStore.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index b82acfd0ed..dba3f1d8a9 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -196,13 +196,16 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }; public rebuild = throttle(() => { // exported for tests - const visibleRooms = this.matrixClient.getVisibleRooms(); + // get all most-upgraded rooms & spaces except spaces which have been left (historical) + const visibleRooms = this.matrixClient.getVisibleRooms().filter(r => { + return !r.isSpaceRoom() || r.getMyMembership() === "join"; + }); + + const unseenChildren = new Set(visibleRooms); + const backrefs = new EnhancedMap>(); // Sort spaces by room ID to force the loop breaking to be deterministic - const spaces = sortBy(this.getSpaces(), space => space.roomId); - const unseenChildren = new Set([...visibleRooms, ...spaces]); - - const backrefs = new EnhancedMap>(); + const spaces = sortBy(visibleRooms.filter(r => r.isSpaceRoom()), space => space.roomId); // TODO handle cleaning up links when a Space is removed spaces.forEach(space => { From 1f6f9ca9838b830f33a8e364c21e405f97eda434 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Tue, 23 Mar 2021 18:24:05 +0000 Subject: [PATCH 025/104] Only show the ask anyway modal for explicit user lookup failures --- src/utils/MultiInviter.js | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/utils/MultiInviter.js b/src/utils/MultiInviter.js index 63d3942b37..05fa06ee14 100644 --- a/src/utils/MultiInviter.js +++ b/src/utils/MultiInviter.js @@ -111,17 +111,10 @@ export default class MultiInviter { } if (!ignoreProfile && SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) { - try { - const profile = await MatrixClientPeg.get().getProfileInfo(addr); - if (!profile) { - // noinspection ExceptionCaughtLocallyJS - throw new Error("User has no profile"); - } - } catch (e) { - throw { - errcode: "RIOT.USER_NOT_FOUND", - error: "User does not have a profile or does not exist." - }; + const profile = await MatrixClientPeg.get().getProfileInfo(addr); + if (!profile) { + // noinspection ExceptionCaughtLocallyJS + throw new Error("User has no profile"); } } From b8692bdf175ce27e78bfb27102ef61445b947503 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Tue, 23 Mar 2021 18:25:03 +0000 Subject: [PATCH 026/104] Prevent state to be toggled whilst a request is pending --- src/components/views/dialogs/InviteDialog.tsx | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index de0b5b237b..4ea53349bd 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -886,19 +886,21 @@ export default class InviteDialog extends React.PureComponent { - let filterText = this.state.filterText; - const targets = this.state.targets.map(t => t); // cheap clone for mutation - const idx = targets.indexOf(member); - if (idx >= 0) { - targets.splice(idx, 1); - } else { - targets.push(member); - filterText = ""; // clear the filter when the user accepts a suggestion - } - this.setState({targets, filterText}); + if (!this.state.busy) { + let filterText = this.state.filterText; + const targets = this.state.targets.map(t => t); // cheap clone for mutation + const idx = targets.indexOf(member); + if (idx >= 0) { + targets.splice(idx, 1); + } else { + targets.push(member); + filterText = ""; // clear the filter when the user accepts a suggestion + } + this.setState({targets, filterText}); - if (this._editorRef && this._editorRef.current) { - this._editorRef.current.focus(); + if (this._editorRef && this._editorRef.current) { + this._editorRef.current.focus(); + } } }; From d836ca19d86fc65c4bea99a50de37f0958b036af Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Wed, 24 Mar 2021 08:58:08 +0000 Subject: [PATCH 027/104] remove references to disused RIOT.USER_NOT_FOUND error code --- src/utils/MultiInviter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/MultiInviter.js b/src/utils/MultiInviter.js index 05fa06ee14..3d3a5f4137 100644 --- a/src/utils/MultiInviter.js +++ b/src/utils/MultiInviter.js @@ -164,7 +164,7 @@ export default class MultiInviter { this._doInvite(address, ignoreProfile).then(resolve, reject); }, 5000); return; - } else if (['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'RIOT.USER_NOT_FOUND'].includes(err.errcode)) { + } else if (['M_NOT_FOUND', 'M_USER_NOT_FOUND'].includes(err.errcode)) { errorText = _t("User %(user_id)s does not exist", {user_id: address}); } else if (err.errcode === 'M_PROFILE_UNDISCLOSED') { errorText = _t("User %(user_id)s may or may not exist", {user_id: address}); @@ -205,7 +205,7 @@ export default class MultiInviter { if (Object.keys(this.errors).length > 0 && !this.groupId) { // There were problems inviting some people - see if we can invite them // without caring if they exist or not. - const unknownProfileErrors = ['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'M_PROFILE_UNDISCLOSED', 'M_PROFILE_NOT_FOUND', 'RIOT.USER_NOT_FOUND']; + const unknownProfileErrors = ['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'M_PROFILE_UNDISCLOSED', 'M_PROFILE_NOT_FOUND']; const unknownProfileUsers = Object.keys(this.errors).filter(a => unknownProfileErrors.includes(this.errors[a].errcode)); if (unknownProfileUsers.length > 0) { From 5104d7bed85f744f3cd6d72635712eed0e23b983 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Wed, 24 Mar 2021 11:51:39 +0000 Subject: [PATCH 028/104] Improve error reporting when EventIndex fails on a supported environment --- .../views/settings/EventIndexPanel.js | 19 ++++++++++++++++++- src/i18n/strings/en_EN.json | 1 + src/indexing/EventIndexPeg.js | 2 ++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/components/views/settings/EventIndexPanel.js b/src/components/views/settings/EventIndexPanel.js index d78b99fc5d..a48583b61d 100644 --- a/src/components/views/settings/EventIndexPanel.js +++ b/src/components/views/settings/EventIndexPanel.js @@ -190,7 +190,7 @@ export default class EventIndexPanel extends React.Component { }
); - } else { + } else if (!EventIndexPeg.platformHasSupport()) { eventIndexingSettings = (
{ @@ -208,6 +208,23 @@ export default class EventIndexPanel extends React.Component { }
); + } else { + eventIndexingSettings = ( +
+

+ {_t("Message search initilisation failed")} +

+ {EventIndexPeg.error && ( +
+ {_t("Advanced")} + + {EventIndexPeg.error.message} + +
+ )} + +
+ ); } return eventIndexingSettings; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 5f1003bf29..f0d7922836 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1081,6 +1081,7 @@ "Securely cache encrypted messages locally for them to appear in search results.": "Securely cache encrypted messages locally for them to appear in search results.", "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.": "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.", "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.": "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.", + "Message search initilisation failed": "Message search initilisation failed", "Connecting to integration manager...": "Connecting to integration manager...", "Cannot connect to integration manager": "Cannot connect to integration manager", "The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.", diff --git a/src/indexing/EventIndexPeg.js b/src/indexing/EventIndexPeg.js index 443daa8f43..7004efc554 100644 --- a/src/indexing/EventIndexPeg.js +++ b/src/indexing/EventIndexPeg.js @@ -31,6 +31,7 @@ class EventIndexPeg { constructor() { this.index = null; this._supportIsInstalled = false; + this.error = null; } /** @@ -96,6 +97,7 @@ class EventIndexPeg { await index.init(); } catch (e) { console.log("EventIndex: Error initializing the event index", e); + this.error = e; return false; } From fb46815b6a5bbc2b17286f67b3fef70af53111bc Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 13:15:31 +0000 Subject: [PATCH 029/104] Spaces move away from Form Buttons --- res/css/structures/_SpaceRoomView.scss | 2 +- .../views/dialogs/_SpaceSettingsDialog.scss | 2 +- res/css/views/spaces/_SpaceCreateMenu.scss | 2 +- src/components/structures/SpaceRoomView.tsx | 41 ++++++++++++------- .../dialogs/AddExistingToSpaceDialog.tsx | 9 ++-- .../views/dialogs/SpaceSettingsDialog.tsx | 12 +++--- .../views/spaces/SpaceCreateMenu.tsx | 9 ++-- 7 files changed, 44 insertions(+), 33 deletions(-) diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index a7ce630b96..080773b49b 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -89,7 +89,7 @@ $SpaceRoomViewInnerWidth: 428px; width: $SpaceRoomViewInnerWidth; text-align: right; // button alignment right - .mx_FormButton { + .mx_AccessibleButton_hasKind { padding: 8px 22px; margin-left: 16px; } diff --git a/res/css/views/dialogs/_SpaceSettingsDialog.scss b/res/css/views/dialogs/_SpaceSettingsDialog.scss index c1fa539e9b..6e5fd9c8c8 100644 --- a/res/css/views/dialogs/_SpaceSettingsDialog.scss +++ b/res/css/views/dialogs/_SpaceSettingsDialog.scss @@ -49,7 +49,7 @@ limitations under the License. } } - .mx_FormButton { + .mx_AccessibleButton_hasKind { padding: 8px 22px; } } diff --git a/res/css/views/spaces/_SpaceCreateMenu.scss b/res/css/views/spaces/_SpaceCreateMenu.scss index bea39e2389..ef3fea351b 100644 --- a/res/css/views/spaces/_SpaceCreateMenu.scss +++ b/res/css/views/spaces/_SpaceCreateMenu.scss @@ -79,7 +79,7 @@ $spacePanelWidth: 71px; } } - .mx_FormButton { + .mx_AccessibleButton_kind_primary { padding: 8px 22px; margin-left: auto; display: block; diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 95846d8e21..46ff37dc14 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -26,7 +26,6 @@ import AccessibleButton from "../views/elements/AccessibleButton"; import RoomName from "../views/elements/RoomName"; import RoomTopic from "../views/elements/RoomTopic"; import InlineSpinner from "../views/elements/InlineSpinner"; -import FormButton from "../views/elements/FormButton"; import {inviteMultipleToRoom, showRoomInviteDialog} from "../../RoomInvite"; import {useRoomMembers} from "../../hooks/useRoomMembers"; import createRoom, {IOpts, Preset} from "../../createRoom"; @@ -124,30 +123,36 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => } joinButtons = <> - { setBusy(true); onRejectButtonClicked(); - }} /> - + { _t("Reject") } + + { setBusy(true); onJoinButtonClicked(); }} - /> + > + { _t("Accept") } + ; } else { joinButtons = ( - { setBusy(true); onJoinButtonClicked(); }} - /> + > + { _t("Join") } + ) } @@ -407,11 +412,13 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { { fields }
- + > + { buttonLabel } +
; }; @@ -426,7 +433,9 @@ const SpaceSetupPublicShare = ({ space, onFinished }) => {
- + + { _t("Go to my first room") } +
; }; @@ -545,7 +554,9 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
- + + { buttonLabel } +
; }; diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 500637244a..e3e28e4fbe 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -22,7 +22,6 @@ import {MatrixClient} from "matrix-js-sdk/src/client"; import {_t} from '../../../languageHandler'; import {IDialogProps} from "./IDialogProps"; import BaseDialog from "./BaseDialog"; -import FormButton from "../elements/FormButton"; import Dropdown from "../elements/Dropdown"; import SearchBox from "../../structures/SearchBox"; import SpaceStore from "../../../stores/SpaceStore"; @@ -185,8 +184,8 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, - { setBusy(true); @@ -200,7 +199,9 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, } setBusy(false); }} - /> + > + { busy ? _t("Adding...") : _t("Add") } + ; }; diff --git a/src/components/views/dialogs/SpaceSettingsDialog.tsx b/src/components/views/dialogs/SpaceSettingsDialog.tsx index f6bf5b87e6..b016e320eb 100644 --- a/src/components/views/dialogs/SpaceSettingsDialog.tsx +++ b/src/components/views/dialogs/SpaceSettingsDialog.tsx @@ -28,7 +28,6 @@ import {getTopic} from "../elements/RoomTopic"; import {avatarUrlForRoom} from "../../../Avatar"; import ToggleSwitch from "../elements/ToggleSwitch"; import AccessibleButton from "../elements/AccessibleButton"; -import FormButton from "../elements/FormButton"; import Modal from "../../../Modal"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import {allSettled} from "../../../utils/promise"; @@ -134,16 +133,17 @@ const SpaceSettingsDialog: React.FC = ({ matrixClient: cli, space, onFin /> - { defaultDispatcher.dispatch({ action: "leave_room", room_id: space.roomId, }); }} - /> + > + { _t("Leave Space") } +
Modal.createDialog(DevtoolsDialog, {roomId: space.roomId})}> @@ -152,7 +152,9 @@ const SpaceSettingsDialog: React.FC = ({ matrixClient: cli, space, onFin { _t("Cancel") } - + + { busy ? _t("Saving...") : _t("Save Changes") } +
; diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx index 879cf929e0..6269de1c50 100644 --- a/src/components/views/spaces/SpaceCreateMenu.tsx +++ b/src/components/views/spaces/SpaceCreateMenu.tsx @@ -21,7 +21,6 @@ import {EventType, RoomType, RoomCreateTypeField} from "matrix-js-sdk/src/@types import {_t} from "../../../languageHandler"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import {ChevronFace, ContextMenu} from "../../structures/ContextMenu"; -import FormButton from "../elements/FormButton"; import createRoom, {IStateEvent, Preset} from "../../../createRoom"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import SpaceBasicSettings from "./SpaceBasicSettings"; @@ -148,11 +147,9 @@ const SpaceCreateMenu = ({ onFinished }) => { - + + { busy ? _t("Creating...") : _t("Create") } + ; } From 4e9a2df3b0689e11dfb18241a5c8a86a6d6b4a2f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 14:00:52 +0000 Subject: [PATCH 030/104] Spaces autofocus and prefill the search box --- src/components/structures/SearchBox.js | 5 ++++- src/components/structures/SpaceRoomDirectory.tsx | 2 ++ src/components/views/dialogs/AddExistingToSpaceDialog.tsx | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js index 6daa8526bc..abeb858274 100644 --- a/src/components/structures/SearchBox.js +++ b/src/components/structures/SearchBox.js @@ -32,6 +32,8 @@ export default class SearchBox extends React.Component { onKeyDown: PropTypes.func, className: PropTypes.string, placeholder: PropTypes.string.isRequired, + autoFocus: PropTypes.bool, + initialValue: PropTypes.string, // If true, the search box will focus and clear itself // on room search focus action (it would be nicer to take @@ -49,7 +51,7 @@ export default class SearchBox extends React.Component { this._search = createRef(); this.state = { - searchTerm: "", + searchTerm: this.props.initialValue || "", blurred: true, }; } @@ -158,6 +160,7 @@ export default class SearchBox extends React.Component { onBlur={this._onBlur} placeholder={ placeholder } autoComplete="off" + autoFocus={this.props.autoFocus} /> { clearButton } diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx index ab273887c2..2fb0101f88 100644 --- a/src/components/structures/SpaceRoomDirectory.tsx +++ b/src/components/structures/SpaceRoomDirectory.tsx @@ -570,6 +570,8 @@ const SpaceRoomDirectory: React.FC = ({ space, initialText = "", onFinis className="mx_textinput_icon mx_textinput_search" placeholder={ _t("Search names and description") } onSearch={setQuery} + autoFocus={true} + initialValue={initialText} /> { content } diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index e3e28e4fbe..fec1a178ca 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -127,6 +127,7 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, className="mx_textinput_icon mx_textinput_search" placeholder={ _t("Filter your rooms and spaces") } onSearch={setQuery} + autoComplete={true} /> { spaces.length > 0 ? ( From d8737913693551141841100554527e7515423e8f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 14:01:24 +0000 Subject: [PATCH 031/104] update comments --- src/stores/SpaceStore.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index dba3f1d8a9..e4b537169e 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -195,7 +195,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set(); }; - public rebuild = throttle(() => { // exported for tests + private rebuild = throttle(() => { // get all most-upgraded rooms & spaces except spaces which have been left (historical) const visibleRooms = this.matrixClient.getVisibleRooms().filter(r => { return !r.isSpaceRoom() || r.getMyMembership() === "join"; @@ -204,7 +204,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const unseenChildren = new Set(visibleRooms); const backrefs = new EnhancedMap>(); - // Sort spaces by room ID to force the loop breaking to be deterministic + // Sort spaces by room ID to force the cycle breaking to be deterministic const spaces = sortBy(visibleRooms.filter(r => r.isSpaceRoom()), space => space.roomId); // TODO handle cleaning up links when a Space is removed @@ -219,7 +219,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const [rootSpaces, orphanedRooms] = partitionSpacesAndRooms(Array.from(unseenChildren)); - // untested algorithm to handle full-cycles + // somewhat algorithm to handle full-cycles const detachedNodes = new Set(spaces); const markTreeChildren = (rootSpace: Room, unseen: Set) => { From f7a3805eed1b987d644f81cbdcd3d3c14fc32709 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 14:02:25 +0000 Subject: [PATCH 032/104] Fix styling inconsistency in space room view --- res/css/structures/_SpaceRoomView.scss | 2 +- src/components/structures/SpaceRoomView.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index 080773b49b..3d3b5d1bb8 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -22,7 +22,7 @@ $SpaceRoomViewInnerWidth: 428px; width: 432px; box-sizing: border-box; border-radius: 8px; - border: 1px solid $input-darker-bg-color; + border: 1px solid $space-button-outline-color; font-size: $font-15px; margin: 20px 0; diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 46ff37dc14..0c2f1638d1 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -426,7 +426,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { const SpaceSetupPublicShare = ({ space, onFinished }) => { return

{ _t("Share %(name)s", { name: space.name }) }

-
+
{ _t("It's just you at the moment, it will be even better with others.") }
From 6e0ab8616866e0d37a9998837e847e131cab3cd3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 14:10:26 +0000 Subject: [PATCH 033/104] Small usability tweaks to the add existing to space dialog --- .../dialogs/_AddExistingToSpaceDialog.scss | 25 ++++++++--- .../dialogs/AddExistingToSpaceDialog.tsx | 44 +++++++++---------- 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss index 0c9d8e3840..a7cfd7bde6 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -28,22 +28,23 @@ limitations under the License. flex-direction: column; flex-wrap: nowrap; min-height: 0; + height: 80vh; .mx_Dialog_title { display: flex; - .mx_BaseAvatar { - display: inline-flex; - margin: 5px 16px 5px 5px; - vertical-align: middle; - } - .mx_BaseAvatar_image { border-radius: 8px; margin: 0; vertical-align: unset; } + .mx_BaseAvatar { + display: inline-flex; + margin: 5px 16px 5px 5px; + vertical-align: middle; + } + > div { > h1 { font-weight: $font-semi-bold; @@ -101,6 +102,7 @@ limitations under the License. .mx_SearchBox { margin: 0; + flex-grow: 0; } .mx_AddExistingToSpaceDialog_errorText { @@ -112,7 +114,10 @@ limitations under the License. } .mx_AddExistingToSpaceDialog_content { + flex-grow: 1; + .mx_AddExistingToSpaceDialog_noResults { + display: block; margin-top: 24px; } } @@ -162,8 +167,14 @@ limitations under the License. > span { flex-grow: 1; - font-size: $font-12px; + font-size: $font-14px; line-height: $font-15px; + font-weight: $font-semi-bold; + + .mx_AccessibleButton { + font-size: inherit; + display: inline-block; + } > * { vertical-align: middle; diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index fec1a178ca..04bec39238 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -109,7 +109,7 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, const title =
-

{ _t("Add existing spaces/rooms") }

+

{ _t("Add existing rooms") }

{ spaceOptionSection }
; @@ -130,27 +130,6 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, autoComplete={true} /> - { spaces.length > 0 ? ( -
-

{ _t("Spaces") }

- { spaces.map(space => { - return { - if (checked) { - selectedToAdd.add(space); - } else { - selectedToAdd.delete(space); - } - setSelectedToAdd(new Set(selectedToAdd)); - }} - />; - }) } -
- ) : null } - { rooms.length > 0 ? (

{ _t("Rooms") }

@@ -172,6 +151,27 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space,
) : undefined } + { spaces.length > 0 ? ( +
+

{ _t("Spaces") }

+ { spaces.map(space => { + return { + if (checked) { + selectedToAdd.add(space); + } else { + selectedToAdd.delete(space); + } + setSelectedToAdd(new Set(selectedToAdd)); + }} + />; + }) } +
+ ) : null } + { spaces.length + rooms.length < 1 ? { _t("No results") } : undefined } From a2a1e37fa3cb3011508e93043ce507b6a0b64249 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 14:18:06 +0000 Subject: [PATCH 034/104] Add prompt to bottom of room list to invite to space --- res/css/views/rooms/_RoomList.scss | 24 +++++++--- res/img/element-icons/roomlist/browse.svg | 4 ++ src/components/views/rooms/RoomList.tsx | 55 +++++++++++++++++++++-- 3 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 res/img/element-icons/roomlist/browse.svg diff --git a/res/css/views/rooms/_RoomList.scss b/res/css/views/rooms/_RoomList.scss index d49ed4b736..641b434af4 100644 --- a/res/css/views/rooms/_RoomList.scss +++ b/res/css/views/rooms/_RoomList.scss @@ -35,28 +35,32 @@ limitations under the License. margin: 4px 12px 4px; padding-top: 12px; border-top: 1px solid $tertiary-fg-color; - font-size: $font-13px; + font-size: $font-14px; div:first-child { font-weight: $font-semi-bold; + line-height: $font-18px; + color: $primary-fg-color; } .mx_AccessibleButton { - color: $secondary-fg-color; + color: $primary-fg-color; position: relative; - padding: 0 0 0 24px; + padding: 8px 8px 8px 32px; font-size: inherit; - margin-top: 8px; + margin-top: 12px; display: block; text-align: start; + background-color: $roomlist-button-bg-color; + border-radius: 4px; &::before { content: ''; width: 16px; height: 16px; position: absolute; - top: 0; - left: 0; + top: 8px; + left: 8px; background: $secondary-fg-color; mask-position: center; mask-size: contain; @@ -70,5 +74,13 @@ limitations under the License. &.mx_RoomList_explorePrompt_explore::before { mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); } + + &.mx_RoomList_explorePrompt_spaceInvite::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } + + &.mx_RoomList_explorePrompt_spaceExplore::before { + mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); + } } } diff --git a/res/img/element-icons/roomlist/browse.svg b/res/img/element-icons/roomlist/browse.svg new file mode 100644 index 0000000000..04714e2881 --- /dev/null +++ b/res/img/element-icons/roomlist/browse.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 4378154d8f..01affc8b2f 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -20,6 +20,7 @@ import React, { ReactComponentElement } from "react"; import { Dispatcher } from "flux"; import { Room } from "matrix-js-sdk/src/models/room"; import * as fbEmitter from "fbemitter"; +import { EventType } from "matrix-js-sdk/src/@types/event"; import { _t, _td } from "../../../languageHandler"; import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; @@ -48,12 +49,15 @@ import { IconizedContextMenuOption, IconizedContextMenuOptionList } from "../con import AccessibleButton from "../elements/AccessibleButton"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; import CallHandler from "../../../CallHandler"; -import SpaceStore, { SUGGESTED_ROOMS } from "../../../stores/SpaceStore"; +import SpaceStore, {SUGGESTED_ROOMS, UPDATE_SELECTED_SPACE} from "../../../stores/SpaceStore"; import { showAddExistingRooms, showCreateNewRoom } from "../../../utils/space"; -import { EventType } from "matrix-js-sdk/src/@types/event"; import {replaceableComponent} from "../../../utils/replaceableComponent"; import RoomAvatar from "../avatars/RoomAvatar"; import { ISpaceSummaryRoom } from "../../structures/SpaceRoomDirectory"; +import { showRoomInviteDialog } from "../../../RoomInvite"; +import Modal from "../../../Modal"; +import SpacePublicShare from "../spaces/SpacePublicShare"; +import InfoDialog from "../dialogs/InfoDialog"; interface IProps { onKeyDown: (ev: React.KeyboardEvent) => void; @@ -68,6 +72,7 @@ interface IState { sublists: ITagMap; isNameFiltering: boolean; currentRoomId?: string; + activeSpace: Room; suggestedRooms: ISpaceSummaryRoom[]; } @@ -194,7 +199,7 @@ const TAG_AESTHETICS: ITagAestheticsMap = { : _t("You do not have permissions to add rooms to this space")} /> { e.preventDefault(); @@ -282,6 +287,7 @@ export default class RoomList extends React.PureComponent { this.state = { sublists: {}, isNameFiltering: !!RoomListStore.instance.getFirstNameFilterCondition(), + activeSpace: SpaceStore.instance.activeSpace, suggestedRooms: SpaceStore.instance.suggestedRooms, }; @@ -294,6 +300,7 @@ export default class RoomList extends React.PureComponent { } public componentDidMount(): void { + SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace); SpaceStore.instance.on(SUGGESTED_ROOMS, this.updateSuggestedRooms); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists); this.customTagStoreRef = CustomRoomTagStore.addListener(this.updateLists); @@ -301,6 +308,7 @@ export default class RoomList extends React.PureComponent { } public componentWillUnmount() { + SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace); SpaceStore.instance.off(SUGGESTED_ROOMS, this.updateSuggestedRooms); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists); defaultDispatcher.unregister(this.dispatcherRef); @@ -366,6 +374,10 @@ export default class RoomList extends React.PureComponent { return room; }; + private updateActiveSpace = (activeSpace: Room) => { + this.setState({ activeSpace }); + }; + private updateSuggestedRooms = (suggestedRooms: ISpaceSummaryRoom[]) => { this.setState({ suggestedRooms }); }; @@ -424,6 +436,25 @@ export default class RoomList extends React.PureComponent { dis.dispatch({ action: Action.ViewRoomDirectory, initialText }); }; + private onSpaceInviteClick = () => { + const initialText = RoomListStore.instance.getFirstNameFilterCondition()?.search; + if (this.state.activeSpace.getJoinRule() === "public") { + const modal = Modal.createTrackedDialog("Space Invite", "User Menu", InfoDialog, { + title: _t("Invite to %(spaceName)s", { spaceName: this.state.activeSpace.name }), + description: + { _t("Share your public space") } + modal.close()} /> + , + fixedWidth: false, + button: false, + className: "mx_SpacePanel_sharePublicSpace", + hasCloseButton: true, + }); + } else { + showRoomInviteDialog(this.state.activeSpace.roomId, initialText); + } + }; + private renderSuggestedRooms(): ReactComponentElement[] { return this.state.suggestedRooms.map(room => { const name = room.name || room.canonical_alias || room.aliases.pop() || _t("Empty room"); @@ -569,7 +600,23 @@ export default class RoomList extends React.PureComponent { kind="link" onClick={this.onExplore} > - {_t("Explore all public rooms")} + { this.state.activeSpace ? _t("Explore rooms") : _t("Explore all public rooms") } + +
; + } else if (this.state.activeSpace) { + explorePrompt =
+
{ _t("Quick actions") }
+ { this.state.activeSpace.canInvite(MatrixClientPeg.get().getUserId()) && + {_t("Invite people")} + } + + {_t("Explore rooms")}
; } else if (Object.values(this.state.sublists).some(list => list.length > 0)) { From 3df3baea14f931251b1a76cc995a52f0fb2ea7bf Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 14:19:10 +0000 Subject: [PATCH 035/104] Tweak behaviour during space creation --- src/components/structures/SpaceRoomView.tsx | 2 +- src/components/views/spaces/SpacePublicShare.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 0c2f1638d1..16028f0975 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -430,7 +430,7 @@ const SpaceSetupPublicShare = ({ space, onFinished }) => { { _t("It's just you at the moment, it will be even better with others.") }
- +
diff --git a/src/components/views/spaces/SpacePublicShare.tsx b/src/components/views/spaces/SpacePublicShare.tsx index b2d3b7ce29..fa81b75525 100644 --- a/src/components/views/spaces/SpacePublicShare.tsx +++ b/src/components/views/spaces/SpacePublicShare.tsx @@ -26,7 +26,7 @@ import {showRoomInviteDialog} from "../../../RoomInvite"; interface IProps { space: Room; - onFinished(): void; + onFinished?(): void; } const SpacePublicShare = ({ space, onFinished }: IProps) => { @@ -54,7 +54,7 @@ const SpacePublicShare = ({ space, onFinished }: IProps) => { className="mx_SpacePublicShare_inviteButton" onClick={() => { showRoomInviteDialog(space.roomId); - onFinished(); + if (onFinished) onFinished(); }} >

{ _t("Invite people") }

From ea760e8f296cfbcf0b8acc172c7a3b16e8a54adf Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 15:26:56 +0000 Subject: [PATCH 036/104] Fix space room directory behaviour --- src/components/structures/SpaceRoomDirectory.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx index 2fb0101f88..877a4283f1 100644 --- a/src/components/structures/SpaceRoomDirectory.tsx +++ b/src/components/structures/SpaceRoomDirectory.tsx @@ -77,7 +77,6 @@ export interface ISpaceSummaryEvent { interface ITileProps { room: ISpaceSummaryRoom; - editing?: boolean; suggested?: boolean; selected?: boolean; numChildRooms?: number; @@ -88,7 +87,6 @@ interface ITileProps { const Tile: React.FC = ({ room, - editing, suggested, selected, hasPermissions, @@ -170,12 +168,6 @@ const Tile: React.FC = ({
; - if (editing) { - return
- { content } -
- } - let childToggle; let childSection; if (children) { @@ -201,7 +193,7 @@ const Tile: React.FC = ({ className={classNames("mx_SpaceRoomDirectory_roomTile", { mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space, })} - onClick={hasPermissions ? onToggleClick : onPreviewClick} + onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick} > { content } { childToggle } From 65a7d0621d5b14b340a9647049cc85b350ba7381 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 15:30:03 +0000 Subject: [PATCH 037/104] Add invite to space button to room intro --- res/css/views/rooms/_NewRoomIntro.scss | 7 +++- src/components/views/rooms/NewRoomIntro.tsx | 46 +++++++++++++++++---- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/res/css/views/rooms/_NewRoomIntro.scss b/res/css/views/rooms/_NewRoomIntro.scss index 4322ba341c..9c2a428cb3 100644 --- a/res/css/views/rooms/_NewRoomIntro.scss +++ b/res/css/views/rooms/_NewRoomIntro.scss @@ -33,8 +33,13 @@ limitations under the License. .mx_AccessibleButton { line-height: $font-24px; + display: inline-block; - &::before { + & + .mx_AccessibleButton { + margin-left: 12px; + } + + &:not(.mx_AccessibleButton_kind_primary_outline)::before { content: ''; display: inline-block; background-color: $button-fg-color; diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index ce426a64ed..c85b9d7868 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -28,6 +28,7 @@ import defaultDispatcher from "../../../dispatcher/dispatcher"; import {ViewUserPayload} from "../../../dispatcher/payloads/ViewUserPayload"; import {Action} from "../../../dispatcher/actions"; import dis from "../../../dispatcher/dispatcher"; +import SpaceStore from "../../../stores/SpaceStore"; const NewRoomIntro = () => { const cli = useContext(MatrixClientContext); @@ -100,17 +101,48 @@ const NewRoomIntro = () => { }); } - let buttons; - if (room.canInvite(cli.getUserId())) { - const onInviteClick = () => { - dis.dispatch({ action: "view_invite", roomId }); - }; + let parentSpace; + if ( + SpaceStore.instance.activeSpace?.canInvite(cli.getUserId()) && + SpaceStore.instance.getSpaceFilteredRoomIds(SpaceStore.instance.activeSpace).has(room.roomId) + ) { + parentSpace = SpaceStore.instance.activeSpace; + } + let buttons; + if (parentSpace) { buttons =
- + { + dis.dispatch({ action: "view_invite", roomId }); + }} + > + {_t("Invite to %(spaceName)s", { spaceName: parentSpace.name })} + + { room.canInvite(cli.getUserId()) && { + dis.dispatch({ action: "view_invite", roomId }); + }} + > + {_t("Invite to just this room")} + } +
; + } else if (room.canInvite(cli.getUserId())) { + buttons =
+ { + dis.dispatch({ action: "view_invite", roomId }); + }} + > {_t("Invite to this room")} -
+ ; } const avatarUrl = room.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url; From 11fbd081f146bce1ce53c07a064300afacf8036f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 15:30:36 +0000 Subject: [PATCH 038/104] Iterate space panel context menu --- .../views/spaces/SpaceTreeLevel.tsx | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index 83bc2296e7..1b86bb7898 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -30,7 +30,12 @@ import IconizedContextMenu, { import {_t} from "../../../languageHandler"; import {ContextMenuTooltipButton} from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import {toRightOf} from "../../structures/ContextMenu"; -import {shouldShowSpaceSettings, showCreateNewRoom, showSpaceSettings} from "../../../utils/space"; +import { + shouldShowSpaceSettings, + showAddExistingRooms, + showCreateNewRoom, + showSpaceSettings, +} from "../../../utils/space"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {ButtonEvent} from "../elements/AccessibleButton"; import defaultDispatcher from "../../../dispatcher/dispatcher"; @@ -127,7 +132,7 @@ export class SpaceItem extends React.PureComponent { if (this.props.space.getJoinRule() === "public") { const modal = Modal.createTrackedDialog("Space Invite", "User Menu", InfoDialog, { - title: _t("Invite members"), + title: _t("Invite to %(spaceName)s", { spaceName: this.props.space.name }), description: { _t("Share your public space") } modal.close()} /> @@ -170,6 +175,14 @@ export class SpaceItem extends React.PureComponent { this.setState({contextMenuPosition: null}); // also close the menu }; + private onAddExistingRoomClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showAddExistingRooms(this.context, this.props.space); + this.setState({contextMenuPosition: null}); // also close the menu + }; + private onMembersClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); @@ -236,15 +249,20 @@ export class SpaceItem extends React.PureComponent { ; } - let newRoomOption; + let newRoomSection; if (this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { - newRoomOption = ( + newRoomSection = - ); + + ; } contextMenu = { label={_t("Explore rooms")} onClick={this.onExploreRoomsClick} /> - { newRoomOption } + { newRoomSection } { leaveSection } ; } From 31dd224cc98c4fc7d44ac1296abe338593ca0751 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 15:36:20 +0000 Subject: [PATCH 039/104] Wire up passing through initialText for room invite dialog helper method --- src/RoomInvite.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 9ae41b851a..aa758ecbdc 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -49,11 +49,12 @@ export function showStartChatInviteDialog(initialText) { ); } -export function showRoomInviteDialog(roomId) { +export function showRoomInviteDialog(roomId, initialText = "") { // This dialog handles the room creation internally - we don't need to worry about it. Modal.createTrackedDialog( "Invite Users", "", InviteDialog, { kind: KIND_INVITE, + initialText, roomId, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, From f340b8f7edfb339cf4c168bdeaf5e73c27d273b4 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 16:42:21 +0000 Subject: [PATCH 040/104] Set invite PL requirement for public spaces to 0 explicitly --- src/components/views/spaces/SpaceCreateMenu.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx index 6269de1c50..9ee6edc489 100644 --- a/src/components/views/spaces/SpaceCreateMenu.tsx +++ b/src/components/views/spaces/SpaceCreateMenu.tsx @@ -88,6 +88,7 @@ const SpaceCreateMenu = ({ onFinished }) => { power_level_content_override: { // Only allow Admins to write to the timeline to prevent hidden sync spam events_default: 100, + ...Visibility.Public ? { invite: 0 } : {}, }, }, spinner: false, From 56dbd5f628713816cfdc7773716b41dc5bcb6eb5 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 16:45:53 +0000 Subject: [PATCH 041/104] Remove unused autoJoin prop and move viaServers logic into RVS this fixes the issue where autoJoining ignored viaServers --- src/components/structures/LoggedInView.tsx | 6 ------ src/components/structures/MatrixChat.tsx | 2 -- src/components/structures/RoomView.tsx | 10 ++-------- src/stores/RoomViewStore.tsx | 5 ++++- 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 60a2bf4ada..20a3b811c5 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -74,7 +74,6 @@ function canElementReceiveInput(el) { interface IProps { matrixClient: MatrixClient; onRegistered: (credentials: IMatrixClientCreds) => Promise; - viaServers?: string[]; hideToSRUsers: boolean; resizeNotifier: ResizeNotifier; // eslint-disable-next-line camelcase @@ -143,9 +142,6 @@ class LoggedInView extends React.Component { // transitioned to PWLU) onRegistered: PropTypes.func, - // Used by the RoomView to handle joining rooms - viaServers: PropTypes.arrayOf(PropTypes.string), - // and lots and lots of other stuff. }; @@ -625,11 +621,9 @@ class LoggedInView extends React.Component { case PageTypes.RoomView: pageElement = { page_type: PageTypes.RoomView, threepidInvite: roomInfo.threepid_invite, roomOobData: roomInfo.oob_data, - viaServers: roomInfo.via_servers, ready: true, roomJustCreatedOpts: roomInfo.justCreatedOpts, }, () => { diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 706cd5ded8..8a9c7cabd9 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -112,10 +112,6 @@ interface IProps { inviterName?: string; }; - // Servers the RoomView can use to try and assist joins - viaServers?: string[]; - - autoJoin?: boolean; resizeNotifier: ResizeNotifier; justCreatedOpts?: IOpts; @@ -450,9 +446,7 @@ export default class RoomView extends React.Component { // now not joined because the js-sdk peeking API will clobber our historical room, // making it impossible to indicate a newly joined room. if (!joining && roomId) { - if (this.props.autoJoin) { - this.onJoinButtonClicked(); - } else if (!room && shouldPeek) { + if (!room && shouldPeek) { console.info("Attempting to peek into room %s", roomId); this.setState({ peekLoading: true, @@ -1123,7 +1117,7 @@ export default class RoomView extends React.Component { const signUrl = this.props.threepidInvite?.signUrl; dis.dispatch({ action: 'join_room', - opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers }, + opts: { inviteSignUrl: signUrl }, _type: "unknown", // TODO: instrumentation }); return Promise.resolve(); diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index f4c0c1b15c..601c77cdf3 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -273,7 +273,10 @@ class RoomViewStore extends Store { const cli = MatrixClientPeg.get(); const address = this.state.roomAlias || this.state.roomId; try { - await retry(() => cli.joinRoom(address, payload.opts), NUM_JOIN_RETRY, (err) => { + await retry(() => cli.joinRoom(address, { + viaServers: payload.via_servers, + ...payload.opts, + }), NUM_JOIN_RETRY, (err) => { // if we received a Gateway timeout then retry return err.httpStatus === 504; }); From d9f3e70b0bf165ff809d05e4294557cf6ea7ded5 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 16:46:30 +0000 Subject: [PATCH 042/104] Fix joining over federation from Space Home (via servers) --- src/components/structures/SpaceRoomView.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 16028f0975..06b4fe5983 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -258,20 +258,26 @@ const SpaceLanding = ({ space }) => { ; } - const [loading, roomsMap, relations, numRooms] = useAsyncMemo(async () => { + const [loading, roomsMap, relations, viaMap, numRooms] = useAsyncMemo(async () => { try { const data = await cli.getSpaceSummary(space.roomId, undefined, myMembership !== "join"); const parentChildRelations = new EnhancedMap>(); + const viaMap = new EnhancedMap>(); data.events.map((ev: ISpaceSummaryEvent) => { if (ev.type === EventType.SpaceChild) { parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev); } + + if (Array.isArray(ev.content["via"])) { + const set = viaMap.getOrCreate(ev.state_key, new Set()); + ev.content["via"].forEach(via => set.add(via)); + } }); const roomsMap = new Map(data.rooms.map(r => [r.room_id, r])); const numRooms = data.rooms.filter(r => r.room_type !== RoomType.Space).length; - return [false, roomsMap, parentChildRelations, numRooms]; + return [false, roomsMap, parentChildRelations, viaMap, numRooms]; } catch (e) { console.error(e); // TODO } @@ -292,7 +298,7 @@ const SpaceLanding = ({ space }) => { relations={relations} parents={new Set()} onViewRoomClick={(roomId, autoJoin) => { - showRoom(roomsMap.get(roomId), [], autoJoin); + showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin); }} />
; From 6d9496cc224fbfe23c29800b9fe24aadc7e14906 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 17:02:12 +0000 Subject: [PATCH 043/104] Consolidate space summary api logic between space room view and directory --- .../structures/SpaceRoomDirectory.tsx | 51 +++++++++++-------- src/components/structures/SpaceRoomView.tsx | 43 +++++----------- 2 files changed, 44 insertions(+), 50 deletions(-) diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx index 877a4283f1..0dfb33379d 100644 --- a/src/components/structures/SpaceRoomDirectory.tsx +++ b/src/components/structures/SpaceRoomDirectory.tsx @@ -15,7 +15,8 @@ limitations under the License. */ import React, {useMemo, useState} from "react"; -import Room from "matrix-js-sdk/src/models/room"; +import {Room} from "matrix-js-sdk/src/models/room"; +import {MatrixClient} from "matrix-js-sdk/src/client"; import {EventType, RoomType} from "matrix-js-sdk/src/@types/event"; import classNames from "classnames"; import {sortBy} from "lodash"; @@ -232,7 +233,7 @@ export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoi interface IHierarchyLevelProps { spaceId: string; rooms: Map; - relations: EnhancedMap>; + relations: Map>; parents: Set; selectedMap?: Map>; onViewRoomClick(roomId: string, autoJoin: boolean): void; @@ -308,23 +309,15 @@ export const HierarchyLevel = ({ }; -const SpaceRoomDirectory: React.FC = ({ space, initialText = "", onFinished }) => { +// mutate argument refreshToken to force a reload +export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [ + ISpaceSummaryRoom[], + Map>, + Map>, + Map>, +] | [] => { // TODO pagination - const cli = MatrixClientPeg.get(); - const userId = cli.getUserId(); - const [query, setQuery] = useState(initialText); - - const onCreateRoomClick = () => { - dis.dispatch({ - action: 'view_create_room', - public: true, - }); - onFinished(); - }; - - const [selected, setSelected] = useState(new Map>()); // Map> - - const [rooms, parentChildMap, childParentMap, viaMap] = useAsyncMemo(async () => { + return useAsyncMemo(async () => { try { const data = await cli.getSpaceSummary(space.roomId); @@ -342,13 +335,31 @@ const SpaceRoomDirectory: React.FC = ({ space, initialText = "", onFinis } }); - return [data.rooms as ISpaceSummaryRoom[], parentChildRelations, childParentRelations, viaMap]; + return [data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations]; } catch (e) { console.error(e); // TODO } return []; - }, [space], []); + }, [space, refreshToken], []); +}; + +const SpaceRoomDirectory: React.FC = ({ space, initialText = "", onFinished }) => { + const cli = MatrixClientPeg.get(); + const userId = cli.getUserId(); + const [query, setQuery] = useState(initialText); + + const onCreateRoomClick = () => { + dis.dispatch({ + action: 'view_create_room', + public: true, + }); + onFinished(); + }; + + const [selected, setSelected] = useState(new Map>()); // Map> + + const [rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space); const roomsMap = useMemo(() => { if (!rooms) return null; diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 06b4fe5983..3ef10363b9 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {RefObject, useContext, useRef, useState} from "react"; +import React, {RefObject, useContext, useMemo, useRef, useState} from "react"; import {EventType, RoomType} from "matrix-js-sdk/src/@types/event"; import {Room} from "matrix-js-sdk/src/models/room"; import {EventSubscription} from "fbemitter"; @@ -46,7 +46,7 @@ import {SetRightPanelPhasePayload} from "../../dispatcher/payloads/SetRightPanel import {useStateArray} from "../../hooks/useStateArray"; import SpacePublicShare from "../views/spaces/SpacePublicShare"; import {showAddExistingRooms, showCreateNewRoom, shouldShowSpaceSettings, showSpaceSettings} from "../../utils/space"; -import {HierarchyLevel, ISpaceSummaryEvent, ISpaceSummaryRoom, showRoom} from "./SpaceRoomDirectory"; +import {HierarchyLevel, ISpaceSummaryEvent, ISpaceSummaryRoom, showRoom, useSpaceSummary} from "./SpaceRoomDirectory"; import {useAsyncMemo} from "../../hooks/useAsyncMemo"; import {EnhancedMap} from "../../utils/maps"; import AutoHideScrollbar from "./AutoHideScrollbar"; @@ -228,7 +228,7 @@ const SpaceLanding = ({ space }) => { const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId); - const [_, forceUpdate] = useStateToggle(false); // TODO + const [refreshToken, forceUpdate] = useStateToggle(false); let addRoomButtons; if (canAddRooms) { @@ -258,32 +258,13 @@ const SpaceLanding = ({ space }) => { ; } - const [loading, roomsMap, relations, viaMap, numRooms] = useAsyncMemo(async () => { - try { - const data = await cli.getSpaceSummary(space.roomId, undefined, myMembership !== "join"); - - const parentChildRelations = new EnhancedMap>(); - const viaMap = new EnhancedMap>(); - data.events.map((ev: ISpaceSummaryEvent) => { - if (ev.type === EventType.SpaceChild) { - parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev); - } - - if (Array.isArray(ev.content["via"])) { - const set = viaMap.getOrCreate(ev.state_key, new Set()); - ev.content["via"].forEach(via => set.add(via)); - } - }); - - const roomsMap = new Map(data.rooms.map(r => [r.room_id, r])); - const numRooms = data.rooms.filter(r => r.room_type !== RoomType.Space).length; - return [false, roomsMap, parentChildRelations, viaMap, numRooms]; - } catch (e) { - console.error(e); // TODO - } - - return [false]; - }, [space, _], [true]); + const [rooms, relations, viaMap] = useSpaceSummary(cli, space, refreshToken); + const [roomsMap, numRooms] = useMemo(() => { + if (!rooms) return []; + const roomsMap = new Map(rooms.map(r => [r.room_id, r])); + const numRooms = rooms.filter(r => r.room_type !== RoomType.Space).length; + return [roomsMap, numRooms]; + }, [rooms]); let previewRooms; if (roomsMap) { @@ -302,7 +283,7 @@ const SpaceLanding = ({ space }) => { }} /> ; - } else if (loading) { + } else if (!rooms) { previewRooms = ; } else { previewRooms =

{_t("Your server does not support showing space hierarchies.")}

; @@ -647,6 +628,8 @@ export default class SpaceRoomView extends React.PureComponent { }; private goToFirstRoom = async () => { + // TODO actually go to the first room + const childRooms = SpaceStore.instance.getChildRooms(this.props.space.roomId); if (childRooms.length) { const room = childRooms[0]; From ee5d0d68421f0510d8a084f77954a9869e9e67a4 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Mar 2021 17:05:21 +0000 Subject: [PATCH 044/104] Fix alignment bug with space panel on spaces with subspaces in Chrome --- res/css/structures/_SpacePanel.scss | 3 --- src/components/views/spaces/SpaceTreeLevel.tsx | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index ffe67ce6ab..33f4dc0588 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -146,9 +146,6 @@ $activeBorderColor: $secondary-fg-color; .mx_SpaceButton_toggleCollapse { width: $gutterSize; - // negative margin to place it correctly even with the complex - // 4px selection border each space button has when active - margin-right: -4px; height: 20px; mask-position: center; mask-size: 20px; diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index 1b86bb7898..1da6720eea 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -37,7 +37,7 @@ import { showSpaceSettings, } from "../../../utils/space"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {ButtonEvent} from "../elements/AccessibleButton"; +import AccessibleButton, {ButtonEvent} from "../elements/AccessibleButton"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import Modal from "../../../Modal"; import SpacePublicShare from "./SpacePublicShare"; @@ -353,7 +353,7 @@ export class SpaceItem extends React.PureComponent { const avatarSize = isNested ? 24 : 32; const toggleCollapseButton = childSpaces && childSpaces.length ? -