diff --git a/res/css/views/dialogs/_KeyboardShortcutsDialog.scss b/res/css/views/dialogs/_KeyboardShortcutsDialog.scss index f529b11059..638cacd41f 100644 --- a/res/css/views/dialogs/_KeyboardShortcutsDialog.scss +++ b/res/css/views/dialogs/_KeyboardShortcutsDialog.scss @@ -21,7 +21,7 @@ limitations under the License. -webkit-box-direction: normal; flex-direction: column; margin-bottom: -50px; - max-height: 700px; // XXX: this may need adjusting when adding new shortcuts + max-height: 1100px; // XXX: this may need adjusting when adding new shortcuts .mx_KeyboardShortcutsDialog_category { width: 33.3333%; // 3 columns diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx index f2bbb629ef..c2739beefa 100644 --- a/src/accessibility/KeyboardShortcuts.tsx +++ b/src/accessibility/KeyboardShortcuts.tsx @@ -87,12 +87,6 @@ const shortcuts: Record = { key: Key.GREATER_THAN, }], description: _td("Toggle Quote"), - }, { - keybinds: [{ - modifiers: [CMD_OR_CTRL], - key: Key.M, - }], - description: _td("Toggle Markdown"), }, { keybinds: [{ modifiers: [Modifiers.SHIFT], @@ -115,6 +109,15 @@ const shortcuts: Record = { key: Key.END, }], description: _td("Jump to start/end of the composer"), + }, { + keybinds: [{ + modifiers: [Modifiers.CONTROL, Modifiers.ALT], + key: Key.ARROW_UP, + }, { + modifiers: [Modifiers.CONTROL, Modifiers.ALT], + key: Key.ARROW_DOWN, + }], + description: _td("Navigate composer history"), }, ], @@ -179,6 +182,24 @@ const shortcuts: Record = { key: Key.PAGE_DOWN, }], description: _td("Scroll up/down in the timeline"), + }, { + keybinds: [{ + modifiers: [Modifiers.ALT, Modifiers.SHIFT], + key: Key.ARROW_UP, + }, { + modifiers: [Modifiers.ALT, Modifiers.SHIFT], + key: Key.ARROW_DOWN, + }], + description: _td("Previous/next unread room or DM"), + }, { + keybinds: [{ + modifiers: [Modifiers.ALT], + key: Key.ARROW_UP, + }, { + modifiers: [Modifiers.ALT], + key: Key.ARROW_DOWN, + }], + description: _td("Previous/next room or DM"), }, { keybinds: [{ modifiers: [CMD_OR_CTRL], @@ -229,6 +250,14 @@ const shortcuts: Record = { ], }; +const categoryOrder = [ + Categories.COMPOSER, + Categories.CALLS, + Categories.ROOM_LIST, + Categories.AUTOCOMPLETE, + Categories.NAVIGATION, +]; + interface IModal { close: () => void; finished: Promise; @@ -295,7 +324,8 @@ export const toggleDialog = () => { return; } - const sections = Object.entries(shortcuts).map(([category, list]) => { + const sections = categoryOrder.map(category => { + const list = shortcuts[category]; return

{_t(category)}

{list.map(shortcut => )}
diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 734a8dde88..e7a6f4c1a9 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -380,12 +380,24 @@ const LoggedInView = createReactClass({ break; case Key.SLASH: - if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { + if (ctrlCmdOnly) { KeyboardShortcuts.toggleDialog(); handled = true; } break; + case Key.ARROW_UP: + case Key.ARROW_DOWN: + if (ev.altKey && !ev.ctrlKey && !ev.metaKey) { + dis.dispatch({ + action: 'view_room_delta', + 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")) { dis.dispatch({ diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js index fa2231328c..9428de3e22 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -111,21 +111,30 @@ export default class RoomSubList extends React.PureComponent { } onAction = (payload) => { - // XXX: Previously RoomList would forceUpdate whenever on_room_read is dispatched, - // but this is no longer true, so we must do it here (and can apply the small - // optimisation of checking that we care about the room being read). - // - // Ultimately we need to transition to a state pushing flow where something - // explicitly notifies the components concerned that the notif count for a room - // has change (e.g. a Flux store). - if (payload.action === 'on_room_read' && - this.props.list.some((r) => r.roomId === payload.roomId) - ) { - this.forceUpdate(); + switch (payload.action) { + case 'on_room_read': + // XXX: Previously RoomList would forceUpdate whenever on_room_read is dispatched, + // but this is no longer true, so we must do it here (and can apply the small + // optimisation of checking that we care about the room being read). + // + // Ultimately we need to transition to a state pushing flow where something + // explicitly notifies the components concerned that the notif count for a room + // has change (e.g. a Flux store). + if (this.props.list.some((r) => r.roomId === payload.roomId)) { + this.forceUpdate(); + } + break; + + case 'view_room': + if (this.state.hidden && !this.props.forceExpand && + this.props.list.some((r) => r.roomId === payload.room_id) + ) { + this.toggle(); + } } }; - onClick = (ev) => { + toggle = () => { if (this.isCollapsibleOnClick()) { // The header isCollapsible, so the click is to be interpreted as collapse and truncation logic const isHidden = !this.state.hidden; @@ -138,6 +147,10 @@ export default class RoomSubList extends React.PureComponent { } }; + onClick = (ev) => { + this.toggle(); + }; + onHeaderKeyDown = (ev) => { switch (ev.key) { case Key.ARROW_LEFT: diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 2143c7f1a8..c00f0c920b 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017, 2018 Vector Creations Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -40,6 +41,8 @@ import * as Receipt from "../../../utils/Receipt"; import {Resizer} from '../../../resizer'; import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2'; import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex"; +import * as Unread from "../../../Unread"; +import RoomViewStore from "../../../stores/RoomViewStore"; const HIDE_CONFERENCE_CHANS = true; const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/; @@ -242,6 +245,54 @@ export default createReactClass({ }); } break; + case 'view_room_delta': { + const currentRoomId = RoomViewStore.getRoomId(); + const { + "im.vector.fake.invite": inviteRooms, + "m.favourite": favouriteRooms, + [TAG_DM]: dmRooms, + "im.vector.fake.recent": recentRooms, + "m.lowpriority": lowPriorityRooms, + "im.vector.fake.archived": historicalRooms, + "m.server_notice": serverNoticeRooms, + ...tags + } = this.state.lists; + + const shownCustomTagRooms = Object.keys(tags).filter(tagName => { + return (!this.state.customTags || this.state.customTags[tagName]) && + !tagName.match(STANDARD_TAGS_REGEX); + }).map(tagName => tags[tagName]); + + // this order matches the one when generating the room sublists below. + let rooms = this._applySearchFilter([ + ...inviteRooms, + ...favouriteRooms, + ...dmRooms, + ...recentRooms, + ...[].concat.apply([], shownCustomTagRooms), // eslint-disable-line prefer-spread + ...lowPriorityRooms, + ...historicalRooms, + ...serverNoticeRooms, + ], this.props.searchFilter); + + if (payload.unread) { + // filter to only notification rooms (and our current active room so we can index properly) + rooms = rooms.filter(room => { + return room.roomId === currentRoomId || Unread.doesRoomHaveUnreadMessages(room); + }); + } + + const currentIndex = rooms.findIndex(room => room.roomId === currentRoomId); + // use slice to account for looping around the start + const [room] = rooms.slice((currentIndex + payload.delta) % rooms.length); + if (room) { + dis.dispatch({ + action: 'view_room', + room_id: room.roomId, + }); + } + break; + } } }, diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index e1624602f1..0f44f5077a 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -17,7 +17,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import classNames from 'classnames'; @@ -225,15 +225,34 @@ export default createReactClass({ case 'feature_custom_status_changed': this.forceUpdate(); break; + + case 'view_room': + // when the room is selected make sure its tile is visible, for breadcrumbs/keyboard shortcut access + if (payload.room_id === this.props.room.roomId) { + this._scrollIntoView(); + } + break; } }, + _scrollIntoView: function() { + if (!this._roomTile.current) return; + this._roomTile.current.scrollIntoView({ + block: "nearest", + behavior: "auto", + }); + }, + _onActiveRoomChange: function() { this.setState({ selected: this.props.room.roomId === RoomViewStore.getRoomId(), }); }, + UNSAFE_componentWillMount: function() { + this._roomTile = createRef(); + }, + componentDidMount: function() { /* We bind here rather than in the definition because otherwise we wind up with the method only being callable once every 500ms across all instances, which would be wrong */ @@ -257,6 +276,11 @@ export default createReactClass({ statusUser.on("User._unstable_statusMessage", this._onStatusMessageCommitted); } } + + // when we're first rendered (or our sublist is expanded) make sure we are visible if we're active + if (this.state.selected) { + this._scrollIntoView(); + } }, componentWillUnmount: function() { @@ -538,7 +562,7 @@ export default createReactClass({ } return - + {({onFocus, isActive, ref}) =>