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
This commit is contained in:
Eugen Rochko 2017-10-06 01:07:59 +02:00 committed by GitHub
parent 49cc0eb3e7
commit 7db0f8dcb2
16 changed files with 627 additions and 150 deletions

View File

@ -16,6 +16,7 @@ export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
export const COMPOSE_REPLY = 'COMPOSE_REPLY'; export const COMPOSE_REPLY = 'COMPOSE_REPLY';
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
export const COMPOSE_MENTION = 'COMPOSE_MENTION'; export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_RESET = 'COMPOSE_RESET';
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; 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) { export function mentionCompose(account, router) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ dispatch({

View File

@ -125,6 +125,16 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
this.props.onKeyDown(e); 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 = () => { onBlur = () => {
this.setState({ suggestionsHidden: true }); this.setState({ suggestionsHidden: true });
} }
@ -173,7 +183,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
} }
render () { render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props; const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
const { suggestionsHidden } = this.state; const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' }; const style = { direction: 'ltr' };
@ -195,7 +205,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
value={value} value={value}
onChange={this.onChange} onChange={this.onChange}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp} onKeyUp={this.onKeyUp}
onBlur={this.onBlur} onBlur={this.onBlur}
onPaste={this.onPaste} onPaste={this.onPaste}
style={style} style={style}

View File

@ -145,32 +145,6 @@ export default class ScrollableList extends PureComponent {
return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600); 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 () { render () {
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
const { fullscreen } = this.state; const { fullscreen } = this.state;
@ -182,7 +156,7 @@ export default class ScrollableList extends PureComponent {
if (isLoading || childrenCount > 0 || !emptyMessage) { if (isLoading || childrenCount > 0 || !emptyMessage) {
scrollableArea = ( scrollableArea = (
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}> <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} {prepend}
{React.Children.map(this.props.children, (child, index) => ( {React.Children.map(this.props.children, (child, index) => (

View File

@ -10,6 +10,8 @@ import StatusActionBar from './status_action_bar';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video } from '../features/ui/util/async-components'; 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 // We use the component (and not the container) since we do not want
// to use the progress bar to show download progress // to use the progress bar to show download progress
@ -39,6 +41,8 @@ export default class Status extends ImmutablePureComponent {
autoPlayGif: PropTypes.bool, autoPlayGif: PropTypes.bool,
muted: PropTypes.bool, muted: PropTypes.bool,
hidden: PropTypes.bool, hidden: PropTypes.bool,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
}; };
state = { state = {
@ -89,16 +93,62 @@ export default class Status extends ImmutablePureComponent {
} }
handleOpenVideo = startTime => { 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 () { render () {
let media = null; let media = null;
let statusAvatar; let statusAvatar, prepend;
const { status, account, hidden, ...other } = this.props; const { hidden } = this.props;
const { isExpanded } = this.state; const { isExpanded } = this.state;
let { status, account, ...other } = this.props;
if (status === null) { if (status === null) {
return null; return null;
} }
@ -115,16 +165,15 @@ export default class Status extends ImmutablePureComponent {
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
return ( prepend = (
<div className='status__wrapper' data-id={status.get('id')} >
<div className='status__prepend'> <div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> <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> }} /> <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> </div>
<Status {...other} status={status.get('reblog')} account={status.get('account')} />
</div>
); );
account = status.get('account');
status = status.get('reblog');
} }
if (status.get('media_attachments').size > 0 && !this.props.muted) { if (status.get('media_attachments').size > 0 && !this.props.muted) {
@ -160,8 +209,23 @@ export default class Status extends ImmutablePureComponent {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />; 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 ( return (
<div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}> <HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}>
{prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}>
<div className='status__info'> <div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
@ -178,8 +242,10 @@ export default class Status extends ImmutablePureComponent {
{media} {media}
<StatusActionBar {...this.props} /> <StatusActionBar status={status} account={account} {...other} />
</div> </div>
</div>
</HotKeys>
); );
} }

View File

@ -25,18 +25,45 @@ export default class StatusList extends ImmutablePureComponent {
trackScroll: true, 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 () { render () {
const { statusIds, ...other } = this.props; const { statusIds, ...other } = this.props;
const { isLoading } = other; const { isLoading } = other;
const scrollableContent = (isLoading || statusIds.size > 0) ? ( const scrollableContent = (isLoading || statusIds.size > 0) ? (
statusIds.map((statusId) => ( statusIds.map((statusId) => (
<StatusContainer key={statusId} id={statusId} /> <StatusContainer
key={statusId}
id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
/>
)) ))
) : null; ) : null;
return ( return (
<ScrollableList {...other}> <ScrollableList {...other} ref={this.setRef}>
{scrollableContent} {scrollableContent}
</ScrollableList> </ScrollableList>
); );

View File

@ -74,6 +74,8 @@ export default class Search extends React.PureComponent {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
this.props.onSubmit(); this.props.onSubmit();
} else if (e.key === 'Escape') {
document.querySelector('.ui').parentElement.focus();
} }
} }

View File

@ -6,17 +6,69 @@ import AccountContainer from '../../../containers/account_container';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Permalink from '../../../components/permalink'; import Permalink from '../../../components/permalink';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
export default class Notification extends ImmutablePureComponent { export default class Notification extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = { static propTypes = {
notification: ImmutablePropTypes.map.isRequired, notification: ImmutablePropTypes.map.isRequired,
hidden: PropTypes.bool, 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) { renderFollow (account, link) {
return ( return (
<div className='notification notification-follow'> <HotKeys handlers={this.getHandlers()}>
<div className='notification notification-follow focusable' tabIndex='0'>
<div className='notification__message'> <div className='notification__message'>
<div className='notification__favourite-icon-wrapper'> <div className='notification__favourite-icon-wrapper'>
<i className='fa fa-fw fa-user-plus' /> <i className='fa fa-fw fa-user-plus' />
@ -27,16 +79,26 @@ export default class Notification extends ImmutablePureComponent {
<AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} /> <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
</div> </div>
</HotKeys>
); );
} }
renderMention (notification) { 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) { renderFavourite (notification, link) {
return ( return (
<div className='notification notification-favourite'> <HotKeys handlers={this.getHandlers()}>
<div className='notification notification-favourite focusable' tabIndex='0'>
<div className='notification__message'> <div className='notification__message'>
<div className='notification__favourite-icon-wrapper'> <div className='notification__favourite-icon-wrapper'>
<i className='fa fa-fw fa-star star-icon' /> <i className='fa fa-fw fa-star star-icon' />
@ -46,12 +108,14 @@ export default class Notification extends ImmutablePureComponent {
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} /> <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
</div> </div>
</HotKeys>
); );
} }
renderReblog (notification, link) { renderReblog (notification, link) {
return ( return (
<div className='notification notification-reblog'> <HotKeys handlers={this.getHandlers()}>
<div className='notification notification-reblog focusable' tabIndex='0'>
<div className='notification__message'> <div className='notification__message'>
<div className='notification__favourite-icon-wrapper'> <div className='notification__favourite-icon-wrapper'>
<i className='fa fa-fw fa-retweet' /> <i className='fa fa-fw fa-retweet' />
@ -61,6 +125,7 @@ export default class Notification extends ImmutablePureComponent {
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} /> <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
</div> </div>
</HotKeys>
); );
} }

View File

@ -1,6 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { makeGetNotification } from '../../../selectors'; import { makeGetNotification } from '../../../selectors';
import Notification from '../components/notification'; import Notification from '../components/notification';
import { mentionCompose } from '../../../actions/compose';
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getNotification = makeGetNotification(); const getNotification = makeGetNotification();
@ -12,4 +13,10 @@ const makeMapStateToProps = () => {
return mapStateToProps; return mapStateToProps;
}; };
export default connect(makeMapStateToProps)(Notification); const mapDispatchToProps = dispatch => ({
onMention: (account, router) => {
dispatch(mentionCompose(account, router));
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);

View File

@ -86,6 +86,24 @@ export default class Notifications extends React.PureComponent {
this.column = c; 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 () { render () {
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props; const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
@ -96,7 +114,15 @@ export default class Notifications extends React.PureComponent {
if (isLoading && this.scrollableContent) { if (isLoading && this.scrollableContent) {
scrollableContent = this.scrollableContent; scrollableContent = this.scrollableContent;
} else if (notifications.size > 0 || hasMore) { } 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 { } else {
scrollableContent = null; scrollableContent = null;
} }

View File

@ -28,6 +28,7 @@ import StatusContainer from '../../containers/status_container';
import { openModal } from '../../actions/modal'; import { openModal } from '../../actions/modal';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
const messages = defineMessages({ const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, 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') })); 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) { 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 () { render () {
@ -176,14 +269,26 @@ export default class Status extends ImmutablePureComponent {
descendants = <div>{this.renderChildren(descendantsIds)}</div>; 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 ( return (
<Column> <Column>
<ColumnBackButton /> <ColumnBackButton />
<ScrollContainer scrollKey='thread'> <ScrollContainer scrollKey='thread'>
<div className='scrollable detailed-status__wrapper'> <div className='scrollable detailed-status__wrapper' ref={this.setRef}>
{ancestors} {ancestors}
<HotKeys handlers={handlers}>
<div className='focusable' tabIndex='0'>
<DetailedStatus <DetailedStatus
status={status} status={status}
autoPlayGif={autoPlayGif} autoPlayGif={autoPlayGif}
@ -204,6 +309,8 @@ export default class Status extends ImmutablePureComponent {
onPin={this.handlePin} onPin={this.handlePin}
onEmbed={this.handleEmbed} onEmbed={this.handleEmbed}
/> />
</div>
</HotKeys>
{descendants} {descendants}
</div> </div>

View File

@ -8,7 +8,7 @@ import { connect } from 'react-redux';
import { Redirect, withRouter } from 'react-router-dom'; import { Redirect, withRouter } from 'react-router-dom';
import { isMobile } from '../../is_mobile'; import { isMobile } from '../../is_mobile';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { uploadCompose } from '../../actions/compose'; import { uploadCompose, resetCompose } from '../../actions/compose';
import { refreshHomeTimeline } from '../../actions/timelines'; import { refreshHomeTimeline } from '../../actions/timelines';
import { refreshNotifications } from '../../actions/notifications'; import { refreshNotifications } from '../../actions/notifications';
import { clearHeight } from '../../actions/height_cache'; import { clearHeight } from '../../actions/height_cache';
@ -37,15 +37,43 @@ import {
Mutes, Mutes,
PinnedStatuses, PinnedStatuses,
} from './util/async-components'; } from './util/async-components';
import { HotKeys } from 'react-hotkeys';
// Dummy import, to make sure that <Status /> ends up in the application bundle. // Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles. // Without this it ends up in ~8 very commonly used bundles.
import '../../components/status'; import '../../components/status';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
me: state.getIn(['meta', 'me']),
isComposing: state.getIn(['compose', 'is_composing']), 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) @connect(mapStateToProps)
@withRouter @withRouter
export default class UI extends React.Component { export default class UI extends React.Component {
@ -58,6 +86,7 @@ export default class UI extends React.Component {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
children: PropTypes.node, children: PropTypes.node,
isComposing: PropTypes.bool, isComposing: PropTypes.bool,
me: PropTypes.string,
location: PropTypes.object, location: PropTypes.object,
}; };
@ -155,6 +184,12 @@ export default class UI extends React.Component {
this.props.dispatch(refreshNotifications()); 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) { shouldComponentUpdate (nextProps) {
if (nextProps.isComposing !== this.props.isComposing) { if (nextProps.isComposing !== this.props.isComposing) {
// Avoid expensive update just to toggle a class // Avoid expensive update just to toggle a class
@ -191,17 +226,123 @@ export default class UI extends React.Component {
this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance(); this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
} }
setOverlayRef = c => { handleHotkeyNew = e => {
this.overlay = c; 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 () { render () {
const { width, draggingOver } = this.state; const { width, draggingOver } = this.state;
const { children } = this.props; 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 ( return (
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}>
<div className='ui' ref={this.setRef}> <div className='ui' ref={this.setRef}>
<TabsBar /> <TabsBar />
<ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width)}> <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width)}>
<WrappedSwitch> <WrappedSwitch>
<Redirect from='/' to='/getting-started' exact /> <Redirect from='/' to='/getting-started' exact />
@ -232,11 +373,13 @@ export default class UI extends React.Component {
<WrappedRoute component={GenericNotFound} content={children} /> <WrappedRoute component={GenericNotFound} content={children} />
</WrappedSwitch> </WrappedSwitch>
</ColumnsAreaContainer> </ColumnsAreaContainer>
<NotificationsContainer /> <NotificationsContainer />
<LoadingBarContainer className='loading-bar' /> <LoadingBarContainer className='loading-bar' />
<ModalContainer /> <ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} /> <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
</div> </div>
</HotKeys>
); );
} }

View File

@ -25,6 +25,7 @@ import {
COMPOSE_UPLOAD_CHANGE_REQUEST, COMPOSE_UPLOAD_CHANGE_REQUEST,
COMPOSE_UPLOAD_CHANGE_SUCCESS, COMPOSE_UPLOAD_CHANGE_SUCCESS,
COMPOSE_UPLOAD_CHANGE_FAIL, COMPOSE_UPLOAD_CHANGE_FAIL,
COMPOSE_RESET,
} from '../actions/compose'; } from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
import { STORE_HYDRATE } from '../actions/store'; import { STORE_HYDRATE } from '../actions/store';
@ -214,6 +215,7 @@ export default function compose(state = initialState, action) {
} }
}); });
case COMPOSE_REPLY_CANCEL: case COMPOSE_REPLY_CANCEL:
case COMPOSE_RESET:
return state.withMutations(map => { return state.withMutations(map => {
map.set('in_reply_to', null); map.set('in_reply_to', null);
map.set('text', ''); map.set('text', '');

View File

@ -94,9 +94,12 @@ button {
} }
.app-holder { .app-holder {
&,
& > div {
display: flex; display: flex;
width: 100%; width: 100%;
height: 100%; height: 100%;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
}

View File

@ -587,6 +587,22 @@
position: absolute; 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 { .status {
padding: 8px 10px; padding: 8px 10px;
padding-left: 68px; padding-left: 68px;
@ -1046,13 +1062,13 @@
strong { strong {
color: $primary-text-color; color: $primary-text-color;
} }
}
&.muted { .muted {
.emojione { .emojione {
opacity: 0.5; opacity: 0.5;
} }
} }
}
.status__display-name, .status__display-name,
.reply-indicator__display-name, .reply-indicator__display-name,

View File

@ -80,6 +80,7 @@
"rails-ujs": "^5.1.2", "rails-ujs": "^5.1.2",
"react": "^16.0.0", "react": "^16.0.0",
"react-dom": "^16.0.0", "react-dom": "^16.0.0",
"react-hotkeys": "^0.10.0",
"react-immutable-proptypes": "^2.1.0", "react-immutable-proptypes": "^2.1.0",
"react-immutable-pure-component": "^1.0.0", "react-immutable-pure-component": "^1.0.0",
"react-intl": "^2.4.0", "react-intl": "^2.4.0",

View File

@ -1684,6 +1684,14 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
sha.js "^2.4.8" 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: cross-env@^5.0.1:
version "5.0.5" version "5.0.5"
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.0.5.tgz#4383d364d9660873dd185b398af3bfef5efffef3" 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" mkdirp "0.5.1"
supports-color "3.1.2" 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: ms@2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" 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" prop-types "^15.5.10"
warning "^3.0.0" 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: react-immutable-proptypes@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/react-immutable-proptypes/-/react-immutable-proptypes-2.1.0.tgz#023d6f39bb15c97c071e9e60d00d136eac5fa0b4" resolved "https://registry.yarnpkg.com/react-immutable-proptypes/-/react-immutable-proptypes-2.1.0.tgz#023d6f39bb15c97c071e9e60d00d136eac5fa0b4"