diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2017-10-06 01:07:59 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-10-06 01:07:59 +0200 |
commit | 7db0f8dcb2110b4ec8815bedc965cfbd01a59798 (patch) | |
tree | ff3153c334c12a75aa2875284012cded2a82d49d /app/javascript/mastodon/features | |
parent | 49cc0eb3e7d1521079e33a60216df46679082547 (diff) |
Implement hotkeys for web UI (#5164)
* Fix #2102 - Implement hotkeys Hotkeys on status list: - r to reply - m to mention author - f to favourite - b to boost - enter to open status - p to open author's profile - up or k to move up in the list - down or j to move down in the list - 1-9 to focus a status in one of the columns - n to focus the compose textarea - alt+n to start a brand new toot - backspace to navigate back * Add navigational hotkeys The key g followed by: - s: start - h: home - n: notifications - l: local timeline - t: federated timeline - f: favourites - u: own profile - p: pinned toots - b: blocked users - m: muted users * Add hotkey for focusing search, make escape un-focus compose/search * Fix focusing notifications column, fix hotkeys in compose textarea
Diffstat (limited to 'app/javascript/mastodon/features')
6 files changed, 439 insertions, 89 deletions
diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js index 79abffad8..4c3f0dcb5 100644 --- a/app/javascript/mastodon/features/compose/components/search.js +++ b/app/javascript/mastodon/features/compose/components/search.js @@ -74,6 +74,8 @@ export default class Search extends React.PureComponent { if (e.key === 'Enter') { e.preventDefault(); this.props.onSubmit(); + } else if (e.key === 'Escape') { + document.querySelector('.ui').parentElement.focus(); } } diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index a608a5223..9d170cad5 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -6,61 +6,126 @@ import AccountContainer from '../../../containers/account_container'; import { FormattedMessage } from 'react-intl'; import Permalink from '../../../components/permalink'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { HotKeys } from 'react-hotkeys'; export default class Notification extends ImmutablePureComponent { + static contextTypes = { + router: PropTypes.object, + }; + static propTypes = { notification: ImmutablePropTypes.map.isRequired, hidden: PropTypes.bool, + onMoveUp: PropTypes.func.isRequired, + onMoveDown: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, }; + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + } + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + } + + handleOpen = () => { + const { notification } = this.props; + + if (notification.get('status')) { + this.context.router.history.push(`/statuses/${notification.get('status')}`); + } else { + this.handleOpenProfile(); + } + } + + handleOpenProfile = () => { + const { notification } = this.props; + this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`); + } + + handleMention = e => { + e.preventDefault(); + + const { notification, onMention } = this.props; + onMention(notification.get('account'), this.context.router.history); + } + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + renderFollow (account, link) { return ( - <div className='notification notification-follow'> - <div className='notification__message'> - <div className='notification__favourite-icon-wrapper'> - <i className='fa fa-fw fa-user-plus' /> + <HotKeys handlers={this.getHandlers()}> + <div className='notification notification-follow focusable' tabIndex='0'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-user-plus' /> + </div> + + <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> </div> - <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> + <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} /> </div> - - <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} /> - </div> + </HotKeys> ); } renderMention (notification) { - return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />; + return ( + <StatusContainer + id={notification.get('status')} + withDismiss + hidden={this.props.hidden} + onMoveDown={this.handleMoveDown} + onMoveUp={this.handleMoveUp} + /> + ); } renderFavourite (notification, link) { return ( - <div className='notification notification-favourite'> - <div className='notification__message'> - <div className='notification__favourite-icon-wrapper'> - <i className='fa fa-fw fa-star star-icon' /> + <HotKeys handlers={this.getHandlers()}> + <div className='notification notification-favourite focusable' tabIndex='0'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-star star-icon' /> + </div> + <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> </div> - <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> - </div> - <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} /> - </div> + <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} /> + </div> + </HotKeys> ); } renderReblog (notification, link) { return ( - <div className='notification notification-reblog'> - <div className='notification__message'> - <div className='notification__favourite-icon-wrapper'> - <i className='fa fa-fw fa-retweet' /> + <HotKeys handlers={this.getHandlers()}> + <div className='notification notification-reblog focusable' tabIndex='0'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-retweet' /> + </div> + <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> </div> - <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> - </div> - <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} /> - </div> + <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} /> + </div> + </HotKeys> ); } diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js index 786222967..921aa460f 100644 --- a/app/javascript/mastodon/features/notifications/containers/notification_container.js +++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js @@ -1,6 +1,7 @@ import { connect } from 'react-redux'; import { makeGetNotification } from '../../../selectors'; import Notification from '../components/notification'; +import { mentionCompose } from '../../../actions/compose'; const makeMapStateToProps = () => { const getNotification = makeGetNotification(); @@ -12,4 +13,10 @@ const makeMapStateToProps = () => { return mapStateToProps; }; -export default connect(makeMapStateToProps)(Notification); +const mapDispatchToProps = dispatch => ({ + onMention: (account, router) => { + dispatch(mentionCompose(account, router)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(Notification); diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index b74473b9f..35b430bfb 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -86,6 +86,24 @@ export default class Notifications extends React.PureComponent { this.column = c; } + handleMoveUp = id => { + const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1; + this._selectChild(elementIndex); + } + + handleMoveDown = id => { + const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1; + this._selectChild(elementIndex); + } + + _selectChild (index) { + const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + + if (element) { + element.focus(); + } + } + render () { const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props; const pinned = !!columnId; @@ -96,7 +114,15 @@ export default class Notifications extends React.PureComponent { if (isLoading && this.scrollableContent) { scrollableContent = this.scrollableContent; } else if (notifications.size > 0 || hasMore) { - scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />); + scrollableContent = notifications.map((item) => ( + <NotificationContainer + key={item.get('id')} + notification={item} + accountId={item.get('account')} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + /> + )); } else { scrollableContent = null; } diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index 8da6e743c..83e83540a 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -28,6 +28,7 @@ import StatusContainer from '../../containers/status_container'; import { openModal } from '../../actions/modal'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { HotKeys } from 'react-hotkeys'; const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, @@ -151,8 +152,100 @@ export default class Status extends ImmutablePureComponent { this.props.dispatch(openModal('EMBED', { url: status.get('url') })); } + handleHotkeyMoveUp = () => { + this.handleMoveUp(this.props.status.get('id')); + } + + handleHotkeyMoveDown = () => { + this.handleMoveDown(this.props.status.get('id')); + } + + handleHotkeyReply = e => { + e.preventDefault(); + this.handleReplyClick(this.props.status); + } + + handleHotkeyFavourite = () => { + this.handleFavouriteClick(this.props.status); + } + + handleHotkeyBoost = () => { + this.handleReblogClick(this.props.status); + } + + handleHotkeyMention = e => { + e.preventDefault(); + this.handleMentionClick(this.props.status); + } + + handleHotkeyOpenProfile = () => { + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + + handleMoveUp = id => { + const { status, ancestorsIds, descendantsIds } = this.props; + + if (id === status.get('id')) { + this._selectChild(ancestorsIds.size - 1); + } else { + let index = ancestorsIds.indexOf(id); + + if (index === -1) { + index = descendantsIds.indexOf(id); + this._selectChild(ancestorsIds.size + index); + } else { + this._selectChild(index - 1); + } + } + } + + handleMoveDown = id => { + const { status, ancestorsIds, descendantsIds } = this.props; + + if (id === status.get('id')) { + this._selectChild(ancestorsIds.size + 1); + } else { + let index = ancestorsIds.indexOf(id); + + if (index === -1) { + index = descendantsIds.indexOf(id); + this._selectChild(ancestorsIds.size + index + 2); + } else { + this._selectChild(index + 1); + } + } + } + + _selectChild (index) { + const element = this.node.querySelectorAll('.focusable')[index]; + + if (element) { + element.focus(); + } + } + renderChildren (list) { - return list.map(id => <StatusContainer key={id} id={id} />); + return list.map(id => ( + <StatusContainer + key={id} + id={id} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + /> + )); + } + + setRef = c => { + this.node = c; + } + + componentDidUpdate () { + const { ancestorsIds } = this.props; + + if (ancestorsIds) { + const element = this.node.querySelectorAll('.focusable')[this.props.ancestorsIds.size]; + element.scrollIntoView(); + } } render () { @@ -176,34 +269,48 @@ export default class Status extends ImmutablePureComponent { descendants = <div>{this.renderChildren(descendantsIds)}</div>; } + const handlers = { + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + reply: this.handleHotkeyReply, + favourite: this.handleHotkeyFavourite, + boost: this.handleHotkeyBoost, + mention: this.handleHotkeyMention, + openProfile: this.handleHotkeyOpenProfile, + }; + return ( <Column> <ColumnBackButton /> <ScrollContainer scrollKey='thread'> - <div className='scrollable detailed-status__wrapper'> + <div className='scrollable detailed-status__wrapper' ref={this.setRef}> {ancestors} - <DetailedStatus - status={status} - autoPlayGif={autoPlayGif} - me={me} - onOpenVideo={this.handleOpenVideo} - onOpenMedia={this.handleOpenMedia} - /> - - <ActionBar - status={status} - me={me} - onReply={this.handleReplyClick} - onFavourite={this.handleFavouriteClick} - onReblog={this.handleReblogClick} - onDelete={this.handleDeleteClick} - onMention={this.handleMentionClick} - onReport={this.handleReport} - onPin={this.handlePin} - onEmbed={this.handleEmbed} - /> + <HotKeys handlers={handlers}> + <div className='focusable' tabIndex='0'> + <DetailedStatus + status={status} + autoPlayGif={autoPlayGif} + me={me} + onOpenVideo={this.handleOpenVideo} + onOpenMedia={this.handleOpenMedia} + /> + + <ActionBar + status={status} + me={me} + onReply={this.handleReplyClick} + onFavourite={this.handleFavouriteClick} + onReblog={this.handleReblogClick} + onDelete={this.handleDeleteClick} + onMention={this.handleMentionClick} + onReport={this.handleReport} + onPin={this.handlePin} + onEmbed={this.handleEmbed} + /> + </div> + </HotKeys> {descendants} </div> diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 0e4796fcb..21f2395ba 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -8,7 +8,7 @@ import { connect } from 'react-redux'; import { Redirect, withRouter } from 'react-router-dom'; import { isMobile } from '../../is_mobile'; import { debounce } from 'lodash'; -import { uploadCompose } from '../../actions/compose'; +import { uploadCompose, resetCompose } from '../../actions/compose'; import { refreshHomeTimeline } from '../../actions/timelines'; import { refreshNotifications } from '../../actions/notifications'; import { clearHeight } from '../../actions/height_cache'; @@ -37,15 +37,43 @@ import { Mutes, PinnedStatuses, } from './util/async-components'; +import { HotKeys } from 'react-hotkeys'; // Dummy import, to make sure that <Status /> ends up in the application bundle. // Without this it ends up in ~8 very commonly used bundles. import '../../components/status'; const mapStateToProps = state => ({ + me: state.getIn(['meta', 'me']), isComposing: state.getIn(['compose', 'is_composing']), }); +const keyMap = { + new: 'n', + search: 's', + forceNew: 'option+n', + focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'], + reply: 'r', + favourite: 'f', + boost: 'b', + mention: 'm', + open: ['enter', 'o'], + openProfile: 'p', + moveDown: ['down', 'j'], + moveUp: ['up', 'k'], + back: 'backspace', + goToHome: 'g h', + goToNotifications: 'g n', + goToLocal: 'g l', + goToFederated: 'g t', + goToStart: 'g s', + goToFavourites: 'g f', + goToPinned: 'g p', + goToProfile: 'g u', + goToBlocked: 'g b', + goToMuted: 'g m', +}; + @connect(mapStateToProps) @withRouter export default class UI extends React.Component { @@ -58,6 +86,7 @@ export default class UI extends React.Component { dispatch: PropTypes.func.isRequired, children: PropTypes.node, isComposing: PropTypes.bool, + me: PropTypes.string, location: PropTypes.object, }; @@ -155,6 +184,12 @@ export default class UI extends React.Component { this.props.dispatch(refreshNotifications()); } + componentDidMount () { + this.hotkeys.__mousetrap__.stopCallback = (e, element) => { + return !(e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) && ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); + }; + } + shouldComponentUpdate (nextProps) { if (nextProps.isComposing !== this.props.isComposing) { // Avoid expensive update just to toggle a class @@ -191,52 +226,160 @@ export default class UI extends React.Component { this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance(); } - setOverlayRef = c => { - this.overlay = c; + handleHotkeyNew = e => { + e.preventDefault(); + + const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea'); + + if (element) { + element.focus(); + } + } + + handleHotkeySearch = e => { + e.preventDefault(); + + const element = this.node.querySelector('.search__input'); + + if (element) { + element.focus(); + } + } + + handleHotkeyForceNew = e => { + this.handleHotkeyNew(e); + this.props.dispatch(resetCompose()); + } + + handleHotkeyFocusColumn = e => { + const index = (e.key * 1) + 1; // First child is drawer, skip that + const column = this.node.querySelector(`.column:nth-child(${index})`); + + if (column) { + const status = column.querySelector('.focusable'); + + if (status) { + status.focus(); + } + } + } + + handleHotkeyBack = () => { + if (window.history && window.history.length === 1) { + this.context.router.history.push('/'); + } else { + this.context.router.history.goBack(); + } + } + + setHotkeysRef = c => { + this.hotkeys = c; + } + + handleHotkeyGoToHome = () => { + this.context.router.history.push('/timelines/home'); + } + + handleHotkeyGoToNotifications = () => { + this.context.router.history.push('/notifications'); + } + + handleHotkeyGoToLocal = () => { + this.context.router.history.push('/timelines/public/local'); + } + + handleHotkeyGoToFederated = () => { + this.context.router.history.push('/timelines/public'); + } + + handleHotkeyGoToStart = () => { + this.context.router.history.push('/getting-started'); + } + + handleHotkeyGoToFavourites = () => { + this.context.router.history.push('/favourites'); + } + + handleHotkeyGoToPinned = () => { + this.context.router.history.push('/pinned'); + } + + handleHotkeyGoToProfile = () => { + this.context.router.history.push(`/accounts/${this.props.me}`); + } + + handleHotkeyGoToBlocked = () => { + this.context.router.history.push('/blocks'); + } + + handleHotkeyGoToMuted = () => { + this.context.router.history.push('/mutes'); } render () { const { width, draggingOver } = this.state; const { children } = this.props; + const handlers = { + new: this.handleHotkeyNew, + search: this.handleHotkeySearch, + forceNew: this.handleHotkeyForceNew, + focusColumn: this.handleHotkeyFocusColumn, + back: this.handleHotkeyBack, + goToHome: this.handleHotkeyGoToHome, + goToNotifications: this.handleHotkeyGoToNotifications, + goToLocal: this.handleHotkeyGoToLocal, + goToFederated: this.handleHotkeyGoToFederated, + goToStart: this.handleHotkeyGoToStart, + goToFavourites: this.handleHotkeyGoToFavourites, + goToPinned: this.handleHotkeyGoToPinned, + goToProfile: this.handleHotkeyGoToProfile, + goToBlocked: this.handleHotkeyGoToBlocked, + goToMuted: this.handleHotkeyGoToMuted, + }; + return ( - <div className='ui' ref={this.setRef}> - <TabsBar /> - <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width)}> - <WrappedSwitch> - <Redirect from='/' to='/getting-started' exact /> - <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> - <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} /> - <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} /> - <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} /> - <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} /> - - <WrappedRoute path='/notifications' component={Notifications} content={children} /> - <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> - <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> - - <WrappedRoute path='/statuses/new' component={Compose} content={children} /> - <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> - <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} /> - <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} /> - - <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} /> - <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} /> - <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} /> - <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} /> - - <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} /> - <WrappedRoute path='/blocks' component={Blocks} content={children} /> - <WrappedRoute path='/mutes' component={Mutes} content={children} /> - - <WrappedRoute component={GenericNotFound} content={children} /> - </WrappedSwitch> - </ColumnsAreaContainer> - <NotificationsContainer /> - <LoadingBarContainer className='loading-bar' /> - <ModalContainer /> - <UploadArea active={draggingOver} onClose={this.closeUploadModal} /> - </div> + <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}> + <div className='ui' ref={this.setRef}> + <TabsBar /> + + <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width)}> + <WrappedSwitch> + <Redirect from='/' to='/getting-started' exact /> + <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> + <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} /> + <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} /> + <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} /> + <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} /> + + <WrappedRoute path='/notifications' component={Notifications} content={children} /> + <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> + <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> + + <WrappedRoute path='/statuses/new' component={Compose} content={children} /> + <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> + <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} /> + <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} /> + + <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} /> + <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} /> + <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} /> + <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} /> + + <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} /> + <WrappedRoute path='/blocks' component={Blocks} content={children} /> + <WrappedRoute path='/mutes' component={Mutes} content={children} /> + + <WrappedRoute component={GenericNotFound} content={children} /> + </WrappedSwitch> + </ColumnsAreaContainer> + + <NotificationsContainer /> + <LoadingBarContainer className='loading-bar' /> + <ModalContainer /> + <UploadArea active={draggingOver} onClose={this.closeUploadModal} /> + </div> + </HotKeys> ); } |