Browse Source

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
pull/4/head
Eugen Rochko 6 years ago
committed by GitHub
parent
commit
7db0f8dcb2
16 changed files with 631 additions and 154 deletions
  1. +7
    -0
      app/javascript/mastodon/actions/compose.js
  2. +12
    -2
      app/javascript/mastodon/components/autosuggest_textarea.js
  3. +1
    -27
      app/javascript/mastodon/components/scrollable_list.js
  4. +91
    -25
      app/javascript/mastodon/components/status.js
  5. +29
    -2
      app/javascript/mastodon/components/status_list.js
  6. +2
    -0
      app/javascript/mastodon/features/compose/components/search.js
  7. +90
    -25
      app/javascript/mastodon/features/notifications/components/notification.js
  8. +8
    -1
      app/javascript/mastodon/features/notifications/containers/notification_container.js
  9. +27
    -1
      app/javascript/mastodon/features/notifications/index.js
  10. +129
    -22
      app/javascript/mastodon/features/status/index.js
  11. +183
    -40
      app/javascript/mastodon/features/ui/index.js
  12. +2
    -0
      app/javascript/mastodon/reducers/compose.js
  13. +8
    -5
      app/javascript/styles/basics.scss
  14. +20
    -4
      app/javascript/styles/components.scss
  15. +1
    -0
      package.json
  16. +21
    -0
      yarn.lock

+ 7
- 0
app/javascript/mastodon/actions/compose.js View File

@ -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({

+ 12
- 2
app/javascript/mastodon/components/autosuggest_textarea.js View File

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

+ 1
- 27
app/javascript/mastodon/components/scrollable_list.js View File

@ -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) => (

+ 91
- 25
app/javascript/mastodon/components/status.js View File

@ -10,6 +10,8 @@ import StatusActionBar from './status_action_bar';
import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video } from '../features/ui/util/async-components';
import { HotKeys } from 'react-hotkeys';
import classNames from 'classnames';
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
@ -39,6 +41,8 @@ export default class Status extends ImmutablePureComponent {
autoPlayGif: PropTypes.bool,
muted: PropTypes.bool,
hidden: PropTypes.bool,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
};
state = {
@ -89,16 +93,62 @@ export default class Status extends ImmutablePureComponent {
}
handleOpenVideo = startTime => {
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime);
}
handleHotkeyReply = e => {
e.preventDefault();
this.props.onReply(this._properStatus(), this.context.router.history);
}
handleHotkeyFavourite = () => {
this.props.onFavourite(this._properStatus());
}
handleHotkeyBoost = e => {
this.props.onReblog(this._properStatus(), e);
}
handleHotkeyMention = e => {
e.preventDefault();
this.props.onMention(this._properStatus().get('account'), this.context.router.history);
}
handleHotkeyOpen = () => {
this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`);
}
handleHotkeyOpenProfile = () => {
this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`);
}
handleHotkeyMoveUp = () => {
this.props.onMoveUp(this.props.status.get('id'));
}
handleHotkeyMoveDown = () => {
this.props.onMoveDown(this.props.status.get('id'));
}
_properStatus () {
const { status } = this.props;
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
return status.get('reblog');
} else {
return status;
}
}
render () {
let media = null;
let statusAvatar;
let statusAvatar, prepend;
const { status, account, hidden, ...other } = this.props;
const { hidden } = this.props;
const { isExpanded } = this.state;
let { status, account, ...other } = this.props;
if (status === null) {
return null;
}
@ -115,16 +165,15 @@ export default class Status extends ImmutablePureComponent {
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
return (
<div className='status__wrapper' data-id={status.get('id')} >
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
</div>
<Status {...other} status={status.get('reblog')} account={status.get('account')} />
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
</div>
);
account = status.get('account');
status = status.get('reblog');
}
if (status.get('media_attachments').size > 0 && !this.props.muted) {
@ -160,26 +209,43 @@ export default class Status extends ImmutablePureComponent {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
}
const handlers = this.props.muted ? {} : {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost,
mention: this.handleHotkeyMention,
open: this.handleHotkeyOpen,
openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
};
return (
<div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}>
<div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}>
{prepend}
<a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'>
<div className='status__avatar'>
{statusAvatar}
</div>
<div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}>
<div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
<DisplayName account={status.get('account')} />
</a>
</div>
<a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'>
<div className='status__avatar'>
{statusAvatar}
</div>
<StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
<DisplayName account={status.get('account')} />
</a>
</div>
<StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
{media}
{media}
<StatusActionBar {...this.props} />
</div>
<StatusActionBar status={status} account={account} {...other} />
</div>
</div>
</HotKeys>
);
}

+ 29
- 2
app/javascript/mastodon/components/status_list.js View File

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

+ 2
- 0
app/javascript/mastodon/features/compose/components/search.js 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();
}
}

+ 90
- 25
app/javascript/mastodon/features/notifications/components/notification.js 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>
);
}

+ 8
- 1
app/javascript/mastodon/features/notifications/containers/notification_container.js 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);

+ 27
- 1
app/javascript/mastodon/features/notifications/index.js 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;
}

+ 129
- 22
app/javascript/mastodon/features/status/index.js 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}
/>
<ActionBar
status={status}
me={me}
onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick}
onDelete={this.handleDeleteClick}
onMention={this.handleMentionClick}
onReport={this.handleReport}
onPin={this.handlePin}
onEmbed={this.handleEmbed}
/>
<HotKeys handlers={handlers}>
<div className='focusable' tabIndex='0'>
<DetailedStatus
status={status}
autoPlayGif={autoPlayGif}
me={me}
onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia}
/>
<ActionBar
status={status}
me={me}
onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick}
onDelete={this.handleDeleteClick}
onMention={this.handleMentionClick}
onReport={this.handleReport}
onPin={this.handlePin}
onEmbed={this.handleEmbed}
/>
</div>
</HotKeys>
{descendants}
</div>

+ 183
- 40
app/javascript/mastodon/features/ui/index.js 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} />
<WrappedRoute path='/notifications' component={Notifications} content={children} />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/statuses/new' component={Compose} content={children} />
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
<WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
<WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
<WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
<WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' component={Blocks} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute component={GenericNotFound} content={children} />
</WrappedSwitch>
</ColumnsAreaContainer>
<NotificationsContainer />
<LoadingBarContainer className='loading-bar' />
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
</div>
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}>
<div className='ui' ref={this.setRef}>
<TabsBar />
<ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width)}>
<WrappedSwitch>
<Redirect from='/' to='/getting-started' exact />
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
<WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
<WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
<WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/statuses/new' component={Compose} content={children} />
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
<WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
<WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
<WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
<WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' component={Blocks} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute component={GenericNotFound} content={children} />
</WrappedSwitch>
</ColumnsAreaContainer>
<NotificationsContainer />
<LoadingBarContainer className='loading-bar' />
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
</div>
</HotKeys>
);
}

+ 2
- 0
app/javascript/mastodon/reducers/compose.js View File

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

+ 8
- 5
app/javascript/styles/basics.scss View File

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

+ 20
- 4
app/javascript/styles/components.scss View File

@ -587,6 +587,22 @@
position: absolute;
}
.focusable {
&:focus {
outline: 0;
background: lighten($ui-base-color, 4%);
&.status-direct {
background: lighten($ui-base-color, 12%);
}
.detailed-status,
.detailed-status__action-bar {
background: lighten($ui-base-color, 8%);
}
}
}
.status {
padding: 8px 10px;
padding-left: 68px;
@ -1046,11 +1062,11 @@
strong {
color: $primary-text-color;
}
}
&.muted {
.emojione {
opacity: 0.5;
}
.muted {
.emojione {
opacity: 0.5;
}
}

+ 1
- 0
package.json View File

@ -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
- 0
yarn.lock 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"
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"

Loading…
Cancel
Save