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:
parent
49cc0eb3e7
commit
7db0f8dcb2
|
@ -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({
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) => (
|
||||
|
|
|
@ -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')} >
|
||||
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>
|
||||
|
||||
<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) {
|
||||
|
@ -160,8 +209,23 @@ 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')}>
|
||||
<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'>
|
||||
<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}
|
||||
|
||||
<StatusActionBar {...this.props} />
|
||||
<StatusActionBar status={status} account={account} {...other} />
|
||||
</div>
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,17 +6,69 @@ 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'>
|
||||
<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' />
|
||||
|
@ -27,16 +79,26 @@ export default class Notification extends ImmutablePureComponent {
|
|||
|
||||
<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'>
|
||||
<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' />
|
||||
|
@ -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} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
renderReblog (notification, link) {
|
||||
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__favourite-icon-wrapper'>
|
||||
<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} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,14 +269,26 @@ 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}
|
||||
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className='focusable' tabIndex='0'>
|
||||
<DetailedStatus
|
||||
status={status}
|
||||
autoPlayGif={autoPlayGif}
|
||||
|
@ -204,6 +309,8 @@ export default class Status extends ImmutablePureComponent {
|
|||
onPin={this.handlePin}
|
||||
onEmbed={this.handleEmbed}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
||||
{descendants}
|
||||
</div>
|
||||
|
|
|
@ -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,17 +226,123 @@ 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 (
|
||||
<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 />
|
||||
|
@ -232,11 +373,13 @@ export default class UI extends React.Component {
|
|||
<WrappedRoute component={GenericNotFound} content={children} />
|
||||
</WrappedSwitch>
|
||||
</ColumnsAreaContainer>
|
||||
|
||||
<NotificationsContainer />
|
||||
<LoadingBarContainer className='loading-bar' />
|
||||
<ModalContainer />
|
||||
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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', '');
|
||||
|
|
|
@ -94,9 +94,12 @@ button {
|
|||
}
|
||||
|
||||
.app-holder {
|
||||
&,
|
||||
& > div {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,13 +1062,13 @@
|
|||
strong {
|
||||
color: $primary-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
&.muted {
|
||||
.muted {
|
||||
.emojione {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status__display-name,
|
||||
.reply-indicator__display-name,
|
||||
|
|
|
@ -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",
|
||||
|
|
21
yarn.lock
21
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"
|
||||
|
|
Reference in a new issue