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:
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"