0
0
Fork 0

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

@ -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();
}
}

View file

@ -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>
);
}

View file

@ -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);

View file

@ -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;
}

View file

@ -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}
/>
<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}
/>
<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>

View file

@ -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} />
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}>
<div className='ui' ref={this.setRef}>
<TabsBar />
<WrappedRoute path='/notifications' component={Notifications} content={children} />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<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='/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='/notifications' component={Notifications} content={children} />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} 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='/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='/follow_requests' component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' component={Blocks} content={children} />
<WrappedRoute path='/mutes' component={Mutes} 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 component={GenericNotFound} content={children} />
</WrappedSwitch>
</ColumnsAreaContainer>
<NotificationsContainer />
<LoadingBarContainer className='loading-bar' />
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
</div>
<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>
);
}