nyastodon/app/javascript/mastodon/features/status/index.js
Eugen Rochko 7db0f8dcb2 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
2017-10-06 01:07:59 +02:00

322 lines
8.8 KiB
JavaScript

import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { fetchStatus } from '../../actions/statuses';
import MissingIndicator from '../../components/missing_indicator';
import DetailedStatus from './components/detailed_status';
import ActionBar from './components/action_bar';
import Column from '../ui/components/column';
import {
favourite,
unfavourite,
reblog,
unreblog,
pin,
unpin,
} from '../../actions/interactions';
import {
replyCompose,
mentionCompose,
} from '../../actions/compose';
import { deleteStatus } from '../../actions/statuses';
import { initReport } from '../../actions/reports';
import { makeGetStatus } from '../../selectors';
import { ScrollContainer } from 'react-router-scroll';
import ColumnBackButton from '../../components/column_back_button';
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' },
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
});
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, props) => ({
status: getStatus(state, props.params.statusId),
ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
me: state.getIn(['meta', 'me']),
boostModal: state.getIn(['meta', 'boost_modal']),
deleteModal: state.getIn(['meta', 'delete_modal']),
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
});
return mapStateToProps;
};
@injectIntl
@connect(makeMapStateToProps)
export default class Status extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
status: ImmutablePropTypes.map,
ancestorsIds: ImmutablePropTypes.list,
descendantsIds: ImmutablePropTypes.list,
me: PropTypes.string,
boostModal: PropTypes.bool,
deleteModal: PropTypes.bool,
autoPlayGif: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
componentWillMount () {
this.props.dispatch(fetchStatus(this.props.params.statusId));
}
componentWillReceiveProps (nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this.props.dispatch(fetchStatus(nextProps.params.statusId));
}
}
handleFavouriteClick = (status) => {
if (status.get('favourited')) {
this.props.dispatch(unfavourite(status));
} else {
this.props.dispatch(favourite(status));
}
}
handlePin = (status) => {
if (status.get('pinned')) {
this.props.dispatch(unpin(status));
} else {
this.props.dispatch(pin(status));
}
}
handleReplyClick = (status) => {
this.props.dispatch(replyCompose(status, this.context.router.history));
}
handleModalReblog = (status) => {
this.props.dispatch(reblog(status));
}
handleReblogClick = (status, e) => {
if (status.get('reblogged')) {
this.props.dispatch(unreblog(status));
} else {
if (e.shiftKey || !this.props.boostModal) {
this.handleModalReblog(status);
} else {
this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog }));
}
}
}
handleDeleteClick = (status) => {
const { dispatch, intl } = this.props;
if (!this.props.deleteModal) {
dispatch(deleteStatus(status.get('id')));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'))),
}));
}
}
handleMentionClick = (account, router) => {
this.props.dispatch(mentionCompose(account, router));
}
handleOpenMedia = (media, index) => {
this.props.dispatch(openModal('MEDIA', { media, index }));
}
handleOpenVideo = (media, time) => {
this.props.dispatch(openModal('VIDEO', { media, time }));
}
handleReport = (status) => {
this.props.dispatch(initReport(status.get('account'), status));
}
handleEmbed = (status) => {
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}
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 () {
let ancestors, descendants;
const { status, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props;
if (status === null) {
return (
<Column>
<ColumnBackButton />
<MissingIndicator />
</Column>
);
}
if (ancestorsIds && ancestorsIds.size > 0) {
ancestors = <div>{this.renderChildren(ancestorsIds)}</div>;
}
if (descendantsIds && descendantsIds.size > 0) {
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' ref={this.setRef}>
{ancestors}
<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>
</ScrollContainer>
</Column>
);
}
}