logo

mastofe

My custom branche(s) on git.pleroma.social/pleroma/mastofe
commit: 7db0f8dcb2110b4ec8815bedc965cfbd01a59798
parent: 49cc0eb3e7d1521079e33a60216df46679082547
Author: Eugen Rochko <eugen@zeonfederated.com>
Date:   Fri,  6 Oct 2017 01:07:59 +0200

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:

Mapp/javascript/mastodon/actions/compose.js7+++++++
Mapp/javascript/mastodon/components/autosuggest_textarea.js14++++++++++++--
Mapp/javascript/mastodon/components/scrollable_list.js28+---------------------------
Mapp/javascript/mastodon/components/status.js116++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mapp/javascript/mastodon/components/status_list.js31+++++++++++++++++++++++++++++--
Mapp/javascript/mastodon/features/compose/components/search.js2++
Mapp/javascript/mastodon/features/notifications/components/notification.js115++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mapp/javascript/mastodon/features/notifications/containers/notification_container.js9++++++++-
Mapp/javascript/mastodon/features/notifications/index.js28+++++++++++++++++++++++++++-
Mapp/javascript/mastodon/features/status/index.js151+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mapp/javascript/mastodon/features/ui/index.js223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mapp/javascript/mastodon/reducers/compose.js2++
Mapp/javascript/styles/basics.scss13++++++++-----
Mapp/javascript/styles/components.scss24++++++++++++++++++++----
Mpackage.json1+
Myarn.lock21+++++++++++++++++++++
16 files changed, 631 insertions(+), 154 deletions(-)

diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js @@ -16,6 +16,7 @@ export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; export const COMPOSE_REPLY = 'COMPOSE_REPLY'; export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; export const COMPOSE_MENTION = 'COMPOSE_MENTION'; +export const COMPOSE_RESET = 'COMPOSE_RESET'; export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; @@ -68,6 +69,12 @@ export function cancelReplyCompose() { }; }; +export function resetCompose() { + return { + type: COMPOSE_RESET, + }; +}; + export function mentionCompose(account, router) { return (dispatch, getState) => { dispatch({ diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -125,6 +125,16 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { this.props.onKeyDown(e); } + onKeyUp = e => { + if (e.key === 'Escape' && this.state.suggestionsHidden) { + document.querySelector('.ui').parentElement.focus(); + } + + if (this.props.onKeyUp) { + this.props.onKeyUp(e); + } + } + onBlur = () => { this.setState({ suggestionsHidden: true }); } @@ -173,7 +183,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } render () { - const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props; + const { value, suggestions, disabled, placeholder, autoFocus } = this.props; const { suggestionsHidden } = this.state; const style = { direction: 'ltr' }; @@ -195,7 +205,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { value={value} onChange={this.onChange} onKeyDown={this.onKeyDown} - onKeyUp={onKeyUp} + onKeyUp={this.onKeyUp} onBlur={this.onBlur} onPaste={this.onPaste} style={style} diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js @@ -145,32 +145,6 @@ export default class ScrollableList extends PureComponent { return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600); } - handleKeyDown = (e) => { - if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) { - const article = (() => { - switch (e.key) { - case 'PageDown': - return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling; - case 'PageUp': - return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling; - case 'End': - return this.node.querySelector('[role="feed"] > article:last-of-type'); - case 'Home': - return this.node.querySelector('[role="feed"] > article:first-of-type'); - default: - return null; - } - })(); - - - if (article) { - e.preventDefault(); - article.focus(); - article.scrollIntoView(); - } - } - } - render () { const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; const { fullscreen } = this.state; @@ -182,7 +156,7 @@ export default class ScrollableList extends PureComponent { if (isLoading || childrenCount > 0 || !emptyMessage) { scrollableArea = ( <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}> - <div role='feed' className='item-list' onKeyDown={this.handleKeyDown}> + <div role='feed' className='item-list'> {prepend} {React.Children.map(this.props.children, (child, index) => ( diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js @@ -10,6 +10,8 @@ import StatusActionBar from './status_action_bar'; import { FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { MediaGallery, Video } from '../features/ui/util/async-components'; +import { HotKeys } from 'react-hotkeys'; +import classNames from 'classnames'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress @@ -39,6 +41,8 @@ export default class Status extends ImmutablePureComponent { autoPlayGif: PropTypes.bool, muted: PropTypes.bool, hidden: PropTypes.bool, + onMoveUp: PropTypes.func, + onMoveDown: PropTypes.func, }; state = { @@ -89,16 +93,62 @@ export default class Status extends ImmutablePureComponent { } handleOpenVideo = startTime => { - this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); + this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime); + } + + handleHotkeyReply = e => { + e.preventDefault(); + this.props.onReply(this._properStatus(), this.context.router.history); + } + + handleHotkeyFavourite = () => { + this.props.onFavourite(this._properStatus()); + } + + handleHotkeyBoost = e => { + this.props.onReblog(this._properStatus(), e); + } + + handleHotkeyMention = e => { + e.preventDefault(); + this.props.onMention(this._properStatus().get('account'), this.context.router.history); + } + + handleHotkeyOpen = () => { + this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`); + } + + handleHotkeyOpenProfile = () => { + this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`); + } + + handleHotkeyMoveUp = () => { + this.props.onMoveUp(this.props.status.get('id')); + } + + handleHotkeyMoveDown = () => { + this.props.onMoveDown(this.props.status.get('id')); + } + + _properStatus () { + const { status } = this.props; + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + return status.get('reblog'); + } else { + return status; + } } render () { let media = null; - let statusAvatar; + let statusAvatar, prepend; - const { status, account, hidden, ...other } = this.props; + const { hidden } = this.props; const { isExpanded } = this.state; + let { status, account, ...other } = this.props; + if (status === null) { return null; } @@ -115,16 +165,15 @@ export default class Status extends ImmutablePureComponent { if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; - return ( - <div className='status__wrapper' data-id={status.get('id')} > - <div className='status__prepend'> - <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> - <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} /> - </div> - - <Status {...other} status={status.get('reblog')} account={status.get('account')} /> + prepend = ( + <div className='status__prepend'> + <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> + <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} /> </div> ); + + account = status.get('account'); + status = status.get('reblog'); } if (status.get('media_attachments').size > 0 && !this.props.muted) { @@ -160,26 +209,43 @@ export default class Status extends ImmutablePureComponent { statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />; } + const handlers = this.props.muted ? {} : { + reply: this.handleHotkeyReply, + favourite: this.handleHotkeyFavourite, + boost: this.handleHotkeyBoost, + mention: this.handleHotkeyMention, + open: this.handleHotkeyOpen, + openProfile: this.handleHotkeyOpenProfile, + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + }; + return ( - <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}> - <div className='status__info'> - <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> + <HotKeys handlers={handlers}> + <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}> + {prepend} - <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'> - <div className='status__avatar'> - {statusAvatar} - </div> + <div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}> + <div className='status__info'> + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> - <DisplayName account={status.get('account')} /> - </a> - </div> + <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'> + <div className='status__avatar'> + {statusAvatar} + </div> - <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} /> + <DisplayName account={status.get('account')} /> + </a> + </div> + + <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} /> - {media} + {media} - <StatusActionBar {...this.props} /> - </div> + <StatusActionBar status={status} account={account} {...other} /> + </div> + </div> + </HotKeys> ); } diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js @@ -25,18 +25,45 @@ export default class StatusList extends ImmutablePureComponent { trackScroll: true, }; + handleMoveUp = id => { + const elementIndex = this.props.statusIds.indexOf(id) - 1; + this._selectChild(elementIndex); + } + + handleMoveDown = id => { + const elementIndex = this.props.statusIds.indexOf(id) + 1; + this._selectChild(elementIndex); + } + + _selectChild (index) { + const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + + if (element) { + element.focus(); + } + } + + setRef = c => { + this.node = c; + } + render () { const { statusIds, ...other } = this.props; const { isLoading } = other; const scrollableContent = (isLoading || statusIds.size > 0) ? ( statusIds.map((statusId) => ( - <StatusContainer key={statusId} id={statusId} /> + <StatusContainer + key={statusId} + id={statusId} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + /> )) ) : null; return ( - <ScrollableList {...other}> + <ScrollableList {...other} ref={this.setRef}> {scrollableContent} </ScrollableList> ); diff --git 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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> ); } diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js @@ -25,6 +25,7 @@ import { COMPOSE_UPLOAD_CHANGE_REQUEST, COMPOSE_UPLOAD_CHANGE_SUCCESS, COMPOSE_UPLOAD_CHANGE_FAIL, + COMPOSE_RESET, } from '../actions/compose'; import { TIMELINE_DELETE } from '../actions/timelines'; import { STORE_HYDRATE } from '../actions/store'; @@ -214,6 +215,7 @@ export default function compose(state = initialState, action) { } }); case COMPOSE_REPLY_CANCEL: + case COMPOSE_RESET: return state.withMutations(map => { map.set('in_reply_to', null); map.set('text', ''); diff --git a/app/javascript/styles/basics.scss b/app/javascript/styles/basics.scss @@ -94,9 +94,12 @@ button { } .app-holder { - display: flex; - width: 100%; - height: 100%; - align-items: center; - justify-content: center; + &, + & > div { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + } } diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss @@ -587,6 +587,22 @@ position: absolute; } +.focusable { + &:focus { + outline: 0; + background: lighten($ui-base-color, 4%); + + &.status-direct { + background: lighten($ui-base-color, 12%); + } + + .detailed-status, + .detailed-status__action-bar { + background: lighten($ui-base-color, 8%); + } + } +} + .status { padding: 8px 10px; padding-left: 68px; @@ -1046,11 +1062,11 @@ strong { color: $primary-text-color; } +} - &.muted { - .emojione { - opacity: 0.5; - } +.muted { + .emojione { + opacity: 0.5; } } diff --git a/package.json b/package.json @@ -80,6 +80,7 @@ "rails-ujs": "^5.1.2", "react": "^16.0.0", "react-dom": "^16.0.0", + "react-hotkeys": "^0.10.0", "react-immutable-proptypes": "^2.1.0", "react-immutable-pure-component": "^1.0.0", "react-intl": "^2.4.0", diff --git a/yarn.lock b/yarn.lock @@ -1684,6 +1684,14 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: safe-buffer "^5.0.1" sha.js "^2.4.8" +create-react-class@^15.5.2: + version "15.6.2" + resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.6.2.tgz#cf1ed15f12aad7f14ef5f2dfe05e6c42f91ef02a" + dependencies: + fbjs "^0.8.9" + loose-envify "^1.3.1" + object-assign "^4.1.1" + cross-env@^5.0.1: version "5.0.5" resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.0.5.tgz#4383d364d9660873dd185b398af3bfef5efffef3" @@ -4209,6 +4217,10 @@ mocha@^3.4.1: mkdirp "0.5.1" supports-color "3.1.2" +mousetrap@^1.5.2: + version "1.6.1" + resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.1.tgz#2a085f5c751294c75e7e81f6ec2545b29cbf42d9" + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -5553,6 +5565,15 @@ react-event-listener@^0.5.0: prop-types "^15.5.10" warning "^3.0.0" +react-hotkeys@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-0.10.0.tgz#d1e78bd63f16d6db58d550d33c8eb071f35d94fb" + dependencies: + create-react-class "^15.5.2" + lodash "^4.13.1" + mousetrap "^1.5.2" + prop-types "^15.5.8" + react-immutable-proptypes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/react-immutable-proptypes/-/react-immutable-proptypes-2.1.0.tgz#023d6f39bb15c97c071e9e60d00d136eac5fa0b4"