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 = 'COMPOSE_REPLY';
|
||||||
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
|
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
|
||||||
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
|
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
|
||||||
|
export const COMPOSE_RESET = 'COMPOSE_RESET';
|
||||||
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
|
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
|
||||||
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
|
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
|
||||||
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
||||||
|
@ -68,6 +69,12 @@ export function cancelReplyCompose() {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function resetCompose() {
|
||||||
|
return {
|
||||||
|
type: COMPOSE_RESET,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function mentionCompose(account, router) {
|
export function mentionCompose(account, router) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|
|
@ -125,6 +125,16 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
this.props.onKeyDown(e);
|
this.props.onKeyDown(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onKeyUp = e => {
|
||||||
|
if (e.key === 'Escape' && this.state.suggestionsHidden) {
|
||||||
|
document.querySelector('.ui').parentElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.onKeyUp) {
|
||||||
|
this.props.onKeyUp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onBlur = () => {
|
onBlur = () => {
|
||||||
this.setState({ suggestionsHidden: true });
|
this.setState({ suggestionsHidden: true });
|
||||||
}
|
}
|
||||||
|
@ -173,7 +183,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
|
const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
|
||||||
const { suggestionsHidden } = this.state;
|
const { suggestionsHidden } = this.state;
|
||||||
const style = { direction: 'ltr' };
|
const style = { direction: 'ltr' };
|
||||||
|
|
||||||
|
@ -195,7 +205,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
value={value}
|
value={value}
|
||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
onKeyDown={this.onKeyDown}
|
onKeyDown={this.onKeyDown}
|
||||||
onKeyUp={onKeyUp}
|
onKeyUp={this.onKeyUp}
|
||||||
onBlur={this.onBlur}
|
onBlur={this.onBlur}
|
||||||
onPaste={this.onPaste}
|
onPaste={this.onPaste}
|
||||||
style={style}
|
style={style}
|
||||||
|
|
|
@ -145,32 +145,6 @@ export default class ScrollableList extends PureComponent {
|
||||||
return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
|
return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKeyDown = (e) => {
|
|
||||||
if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
|
|
||||||
const article = (() => {
|
|
||||||
switch (e.key) {
|
|
||||||
case 'PageDown':
|
|
||||||
return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
|
|
||||||
case 'PageUp':
|
|
||||||
return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
|
|
||||||
case 'End':
|
|
||||||
return this.node.querySelector('[role="feed"] > article:last-of-type');
|
|
||||||
case 'Home':
|
|
||||||
return this.node.querySelector('[role="feed"] > article:first-of-type');
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
if (article) {
|
|
||||||
e.preventDefault();
|
|
||||||
article.focus();
|
|
||||||
article.scrollIntoView();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
|
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
|
||||||
const { fullscreen } = this.state;
|
const { fullscreen } = this.state;
|
||||||
|
@ -182,7 +156,7 @@ export default class ScrollableList extends PureComponent {
|
||||||
if (isLoading || childrenCount > 0 || !emptyMessage) {
|
if (isLoading || childrenCount > 0 || !emptyMessage) {
|
||||||
scrollableArea = (
|
scrollableArea = (
|
||||||
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
|
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
|
||||||
<div role='feed' className='item-list' onKeyDown={this.handleKeyDown}>
|
<div role='feed' className='item-list'>
|
||||||
{prepend}
|
{prepend}
|
||||||
|
|
||||||
{React.Children.map(this.props.children, (child, index) => (
|
{React.Children.map(this.props.children, (child, index) => (
|
||||||
|
|
|
@ -10,6 +10,8 @@ import StatusActionBar from './status_action_bar';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { MediaGallery, Video } from '../features/ui/util/async-components';
|
import { MediaGallery, Video } from '../features/ui/util/async-components';
|
||||||
|
import { HotKeys } from 'react-hotkeys';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
// We use the component (and not the container) since we do not want
|
// We use the component (and not the container) since we do not want
|
||||||
// to use the progress bar to show download progress
|
// to use the progress bar to show download progress
|
||||||
|
@ -39,6 +41,8 @@ export default class Status extends ImmutablePureComponent {
|
||||||
autoPlayGif: PropTypes.bool,
|
autoPlayGif: PropTypes.bool,
|
||||||
muted: PropTypes.bool,
|
muted: PropTypes.bool,
|
||||||
hidden: PropTypes.bool,
|
hidden: PropTypes.bool,
|
||||||
|
onMoveUp: PropTypes.func,
|
||||||
|
onMoveDown: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -89,16 +93,62 @@ export default class Status extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleOpenVideo = startTime => {
|
handleOpenVideo = startTime => {
|
||||||
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
|
this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyReply = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.props.onReply(this._properStatus(), this.context.router.history);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyFavourite = () => {
|
||||||
|
this.props.onFavourite(this._properStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyBoost = e => {
|
||||||
|
this.props.onReblog(this._properStatus(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyMention = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.props.onMention(this._properStatus().get('account'), this.context.router.history);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyOpen = () => {
|
||||||
|
this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyOpenProfile = () => {
|
||||||
|
this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyMoveUp = () => {
|
||||||
|
this.props.onMoveUp(this.props.status.get('id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyMoveDown = () => {
|
||||||
|
this.props.onMoveDown(this.props.status.get('id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
_properStatus () {
|
||||||
|
const { status } = this.props;
|
||||||
|
|
||||||
|
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||||
|
return status.get('reblog');
|
||||||
|
} else {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
let media = null;
|
let media = null;
|
||||||
let statusAvatar;
|
let statusAvatar, prepend;
|
||||||
|
|
||||||
const { status, account, hidden, ...other } = this.props;
|
const { hidden } = this.props;
|
||||||
const { isExpanded } = this.state;
|
const { isExpanded } = this.state;
|
||||||
|
|
||||||
|
let { status, account, ...other } = this.props;
|
||||||
|
|
||||||
if (status === null) {
|
if (status === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -115,16 +165,15 @@ export default class Status extends ImmutablePureComponent {
|
||||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||||
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
|
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
|
||||||
|
|
||||||
return (
|
prepend = (
|
||||||
<div className='status__wrapper' data-id={status.get('id')} >
|
<div className='status__prepend'>
|
||||||
<div className='status__prepend'>
|
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
|
||||||
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
|
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
|
||||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Status {...other} status={status.get('reblog')} account={status.get('account')} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
account = status.get('account');
|
||||||
|
status = status.get('reblog');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.get('media_attachments').size > 0 && !this.props.muted) {
|
if (status.get('media_attachments').size > 0 && !this.props.muted) {
|
||||||
|
@ -160,26 +209,43 @@ export default class Status extends ImmutablePureComponent {
|
||||||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const handlers = this.props.muted ? {} : {
|
||||||
<div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}>
|
reply: this.handleHotkeyReply,
|
||||||
<div className='status__info'>
|
favourite: this.handleHotkeyFavourite,
|
||||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
boost: this.handleHotkeyBoost,
|
||||||
|
mention: this.handleHotkeyMention,
|
||||||
|
open: this.handleHotkeyOpen,
|
||||||
|
openProfile: this.handleHotkeyOpenProfile,
|
||||||
|
moveUp: this.handleHotkeyMoveUp,
|
||||||
|
moveDown: this.handleHotkeyMoveDown,
|
||||||
|
};
|
||||||
|
|
||||||
<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'>
|
return (
|
||||||
<div className='status__avatar'>
|
<HotKeys handlers={handlers}>
|
||||||
{statusAvatar}
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<DisplayName account={status.get('account')} />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DisplayName account={status.get('account')} />
|
<StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
|
||||||
</a>
|
|
||||||
|
{media}
|
||||||
|
|
||||||
|
<StatusActionBar status={status} account={account} {...other} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</HotKeys>
|
||||||
<StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
|
|
||||||
|
|
||||||
{media}
|
|
||||||
|
|
||||||
<StatusActionBar {...this.props} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,18 +25,45 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
trackScroll: true,
|
trackScroll: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleMoveUp = id => {
|
||||||
|
const elementIndex = this.props.statusIds.indexOf(id) - 1;
|
||||||
|
this._selectChild(elementIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMoveDown = id => {
|
||||||
|
const elementIndex = this.props.statusIds.indexOf(id) + 1;
|
||||||
|
this._selectChild(elementIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectChild (index) {
|
||||||
|
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setRef = c => {
|
||||||
|
this.node = c;
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { statusIds, ...other } = this.props;
|
const { statusIds, ...other } = this.props;
|
||||||
const { isLoading } = other;
|
const { isLoading } = other;
|
||||||
|
|
||||||
const scrollableContent = (isLoading || statusIds.size > 0) ? (
|
const scrollableContent = (isLoading || statusIds.size > 0) ? (
|
||||||
statusIds.map((statusId) => (
|
statusIds.map((statusId) => (
|
||||||
<StatusContainer key={statusId} id={statusId} />
|
<StatusContainer
|
||||||
|
key={statusId}
|
||||||
|
id={statusId}
|
||||||
|
onMoveUp={this.handleMoveUp}
|
||||||
|
onMoveDown={this.handleMoveDown}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollableList {...other}>
|
<ScrollableList {...other} ref={this.setRef}>
|
||||||
{scrollableContent}
|
{scrollableContent}
|
||||||
</ScrollableList>
|
</ScrollableList>
|
||||||
);
|
);
|
||||||
|
|
|
@ -74,6 +74,8 @@ export default class Search extends React.PureComponent {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.props.onSubmit();
|
this.props.onSubmit();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
document.querySelector('.ui').parentElement.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,61 +6,126 @@ import AccountContainer from '../../../containers/account_container';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import Permalink from '../../../components/permalink';
|
import Permalink from '../../../components/permalink';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
import { HotKeys } from 'react-hotkeys';
|
||||||
|
|
||||||
export default class Notification extends ImmutablePureComponent {
|
export default class Notification extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static contextTypes = {
|
||||||
|
router: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
notification: ImmutablePropTypes.map.isRequired,
|
notification: ImmutablePropTypes.map.isRequired,
|
||||||
hidden: PropTypes.bool,
|
hidden: PropTypes.bool,
|
||||||
|
onMoveUp: PropTypes.func.isRequired,
|
||||||
|
onMoveDown: PropTypes.func.isRequired,
|
||||||
|
onMention: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleMoveUp = () => {
|
||||||
|
const { notification, onMoveUp } = this.props;
|
||||||
|
onMoveUp(notification.get('id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMoveDown = () => {
|
||||||
|
const { notification, onMoveDown } = this.props;
|
||||||
|
onMoveDown(notification.get('id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOpen = () => {
|
||||||
|
const { notification } = this.props;
|
||||||
|
|
||||||
|
if (notification.get('status')) {
|
||||||
|
this.context.router.history.push(`/statuses/${notification.get('status')}`);
|
||||||
|
} else {
|
||||||
|
this.handleOpenProfile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOpenProfile = () => {
|
||||||
|
const { notification } = this.props;
|
||||||
|
this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMention = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const { notification, onMention } = this.props;
|
||||||
|
onMention(notification.get('account'), this.context.router.history);
|
||||||
|
}
|
||||||
|
|
||||||
|
getHandlers () {
|
||||||
|
return {
|
||||||
|
moveUp: this.handleMoveUp,
|
||||||
|
moveDown: this.handleMoveDown,
|
||||||
|
open: this.handleOpen,
|
||||||
|
openProfile: this.handleOpenProfile,
|
||||||
|
mention: this.handleMention,
|
||||||
|
reply: this.handleMention,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
renderFollow (account, link) {
|
renderFollow (account, link) {
|
||||||
return (
|
return (
|
||||||
<div className='notification notification-follow'>
|
<HotKeys handlers={this.getHandlers()}>
|
||||||
<div className='notification__message'>
|
<div className='notification notification-follow focusable' tabIndex='0'>
|
||||||
<div className='notification__favourite-icon-wrapper'>
|
<div className='notification__message'>
|
||||||
<i className='fa fa-fw fa-user-plus' />
|
<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>
|
</div>
|
||||||
|
|
||||||
<FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
|
<AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
|
||||||
</div>
|
</div>
|
||||||
|
</HotKeys>
|
||||||
<AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderMention (notification) {
|
renderMention (notification) {
|
||||||
return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />;
|
return (
|
||||||
|
<StatusContainer
|
||||||
|
id={notification.get('status')}
|
||||||
|
withDismiss
|
||||||
|
hidden={this.props.hidden}
|
||||||
|
onMoveDown={this.handleMoveDown}
|
||||||
|
onMoveUp={this.handleMoveUp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderFavourite (notification, link) {
|
renderFavourite (notification, link) {
|
||||||
return (
|
return (
|
||||||
<div className='notification notification-favourite'>
|
<HotKeys handlers={this.getHandlers()}>
|
||||||
<div className='notification__message'>
|
<div className='notification notification-favourite focusable' tabIndex='0'>
|
||||||
<div className='notification__favourite-icon-wrapper'>
|
<div className='notification__message'>
|
||||||
<i className='fa fa-fw fa-star star-icon' />
|
<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>
|
</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} />
|
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
|
||||||
</div>
|
</div>
|
||||||
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderReblog (notification, link) {
|
renderReblog (notification, link) {
|
||||||
return (
|
return (
|
||||||
<div className='notification notification-reblog'>
|
<HotKeys handlers={this.getHandlers()}>
|
||||||
<div className='notification__message'>
|
<div className='notification notification-reblog focusable' tabIndex='0'>
|
||||||
<div className='notification__favourite-icon-wrapper'>
|
<div className='notification__message'>
|
||||||
<i className='fa fa-fw fa-retweet' />
|
<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>
|
</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} />
|
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
|
||||||
</div>
|
</div>
|
||||||
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { makeGetNotification } from '../../../selectors';
|
import { makeGetNotification } from '../../../selectors';
|
||||||
import Notification from '../components/notification';
|
import Notification from '../components/notification';
|
||||||
|
import { mentionCompose } from '../../../actions/compose';
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
const getNotification = makeGetNotification();
|
const getNotification = makeGetNotification();
|
||||||
|
@ -12,4 +13,10 @@ const makeMapStateToProps = () => {
|
||||||
return mapStateToProps;
|
return mapStateToProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(makeMapStateToProps)(Notification);
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
onMention: (account, router) => {
|
||||||
|
dispatch(mentionCompose(account, router));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);
|
||||||
|
|
|
@ -86,6 +86,24 @@ export default class Notifications extends React.PureComponent {
|
||||||
this.column = c;
|
this.column = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleMoveUp = id => {
|
||||||
|
const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1;
|
||||||
|
this._selectChild(elementIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMoveDown = id => {
|
||||||
|
const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1;
|
||||||
|
this._selectChild(elementIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectChild (index) {
|
||||||
|
const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
|
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
|
||||||
const pinned = !!columnId;
|
const pinned = !!columnId;
|
||||||
|
@ -96,7 +114,15 @@ export default class Notifications extends React.PureComponent {
|
||||||
if (isLoading && this.scrollableContent) {
|
if (isLoading && this.scrollableContent) {
|
||||||
scrollableContent = this.scrollableContent;
|
scrollableContent = this.scrollableContent;
|
||||||
} else if (notifications.size > 0 || hasMore) {
|
} else if (notifications.size > 0 || hasMore) {
|
||||||
scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />);
|
scrollableContent = notifications.map((item) => (
|
||||||
|
<NotificationContainer
|
||||||
|
key={item.get('id')}
|
||||||
|
notification={item}
|
||||||
|
accountId={item.get('account')}
|
||||||
|
onMoveUp={this.handleMoveUp}
|
||||||
|
onMoveDown={this.handleMoveDown}
|
||||||
|
/>
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
scrollableContent = null;
|
scrollableContent = null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@ import StatusContainer from '../../containers/status_container';
|
||||||
import { openModal } from '../../actions/modal';
|
import { openModal } from '../../actions/modal';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
import { HotKeys } from 'react-hotkeys';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||||
|
@ -151,8 +152,100 @@ export default class Status extends ImmutablePureComponent {
|
||||||
this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
|
this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleHotkeyMoveUp = () => {
|
||||||
|
this.handleMoveUp(this.props.status.get('id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyMoveDown = () => {
|
||||||
|
this.handleMoveDown(this.props.status.get('id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyReply = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.handleReplyClick(this.props.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyFavourite = () => {
|
||||||
|
this.handleFavouriteClick(this.props.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyBoost = () => {
|
||||||
|
this.handleReblogClick(this.props.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyMention = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.handleMentionClick(this.props.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyOpenProfile = () => {
|
||||||
|
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMoveUp = id => {
|
||||||
|
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||||
|
|
||||||
|
if (id === status.get('id')) {
|
||||||
|
this._selectChild(ancestorsIds.size - 1);
|
||||||
|
} else {
|
||||||
|
let index = ancestorsIds.indexOf(id);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
index = descendantsIds.indexOf(id);
|
||||||
|
this._selectChild(ancestorsIds.size + index);
|
||||||
|
} else {
|
||||||
|
this._selectChild(index - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMoveDown = id => {
|
||||||
|
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||||
|
|
||||||
|
if (id === status.get('id')) {
|
||||||
|
this._selectChild(ancestorsIds.size + 1);
|
||||||
|
} else {
|
||||||
|
let index = ancestorsIds.indexOf(id);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
index = descendantsIds.indexOf(id);
|
||||||
|
this._selectChild(ancestorsIds.size + index + 2);
|
||||||
|
} else {
|
||||||
|
this._selectChild(index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectChild (index) {
|
||||||
|
const element = this.node.querySelectorAll('.focusable')[index];
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
renderChildren (list) {
|
renderChildren (list) {
|
||||||
return list.map(id => <StatusContainer key={id} id={id} />);
|
return list.map(id => (
|
||||||
|
<StatusContainer
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
onMoveUp={this.handleMoveUp}
|
||||||
|
onMoveDown={this.handleMoveDown}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
setRef = c => {
|
||||||
|
this.node = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate () {
|
||||||
|
const { ancestorsIds } = this.props;
|
||||||
|
|
||||||
|
if (ancestorsIds) {
|
||||||
|
const element = this.node.querySelectorAll('.focusable')[this.props.ancestorsIds.size];
|
||||||
|
element.scrollIntoView();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
@ -176,34 +269,48 @@ export default class Status extends ImmutablePureComponent {
|
||||||
descendants = <div>{this.renderChildren(descendantsIds)}</div>;
|
descendants = <div>{this.renderChildren(descendantsIds)}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
moveUp: this.handleHotkeyMoveUp,
|
||||||
|
moveDown: this.handleHotkeyMoveDown,
|
||||||
|
reply: this.handleHotkeyReply,
|
||||||
|
favourite: this.handleHotkeyFavourite,
|
||||||
|
boost: this.handleHotkeyBoost,
|
||||||
|
mention: this.handleHotkeyMention,
|
||||||
|
openProfile: this.handleHotkeyOpenProfile,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column>
|
<Column>
|
||||||
<ColumnBackButton />
|
<ColumnBackButton />
|
||||||
|
|
||||||
<ScrollContainer scrollKey='thread'>
|
<ScrollContainer scrollKey='thread'>
|
||||||
<div className='scrollable detailed-status__wrapper'>
|
<div className='scrollable detailed-status__wrapper' ref={this.setRef}>
|
||||||
{ancestors}
|
{ancestors}
|
||||||
|
|
||||||
<DetailedStatus
|
<HotKeys handlers={handlers}>
|
||||||
status={status}
|
<div className='focusable' tabIndex='0'>
|
||||||
autoPlayGif={autoPlayGif}
|
<DetailedStatus
|
||||||
me={me}
|
status={status}
|
||||||
onOpenVideo={this.handleOpenVideo}
|
autoPlayGif={autoPlayGif}
|
||||||
onOpenMedia={this.handleOpenMedia}
|
me={me}
|
||||||
/>
|
onOpenVideo={this.handleOpenVideo}
|
||||||
|
onOpenMedia={this.handleOpenMedia}
|
||||||
|
/>
|
||||||
|
|
||||||
<ActionBar
|
<ActionBar
|
||||||
status={status}
|
status={status}
|
||||||
me={me}
|
me={me}
|
||||||
onReply={this.handleReplyClick}
|
onReply={this.handleReplyClick}
|
||||||
onFavourite={this.handleFavouriteClick}
|
onFavourite={this.handleFavouriteClick}
|
||||||
onReblog={this.handleReblogClick}
|
onReblog={this.handleReblogClick}
|
||||||
onDelete={this.handleDeleteClick}
|
onDelete={this.handleDeleteClick}
|
||||||
onMention={this.handleMentionClick}
|
onMention={this.handleMentionClick}
|
||||||
onReport={this.handleReport}
|
onReport={this.handleReport}
|
||||||
onPin={this.handlePin}
|
onPin={this.handlePin}
|
||||||
onEmbed={this.handleEmbed}
|
onEmbed={this.handleEmbed}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</HotKeys>
|
||||||
|
|
||||||
{descendants}
|
{descendants}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { connect } from 'react-redux';
|
||||||
import { Redirect, withRouter } from 'react-router-dom';
|
import { Redirect, withRouter } from 'react-router-dom';
|
||||||
import { isMobile } from '../../is_mobile';
|
import { isMobile } from '../../is_mobile';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import { uploadCompose } from '../../actions/compose';
|
import { uploadCompose, resetCompose } from '../../actions/compose';
|
||||||
import { refreshHomeTimeline } from '../../actions/timelines';
|
import { refreshHomeTimeline } from '../../actions/timelines';
|
||||||
import { refreshNotifications } from '../../actions/notifications';
|
import { refreshNotifications } from '../../actions/notifications';
|
||||||
import { clearHeight } from '../../actions/height_cache';
|
import { clearHeight } from '../../actions/height_cache';
|
||||||
|
@ -37,15 +37,43 @@ import {
|
||||||
Mutes,
|
Mutes,
|
||||||
PinnedStatuses,
|
PinnedStatuses,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
|
import { HotKeys } from 'react-hotkeys';
|
||||||
|
|
||||||
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
||||||
// Without this it ends up in ~8 very commonly used bundles.
|
// Without this it ends up in ~8 very commonly used bundles.
|
||||||
import '../../components/status';
|
import '../../components/status';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
|
me: state.getIn(['meta', 'me']),
|
||||||
isComposing: state.getIn(['compose', 'is_composing']),
|
isComposing: state.getIn(['compose', 'is_composing']),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const keyMap = {
|
||||||
|
new: 'n',
|
||||||
|
search: 's',
|
||||||
|
forceNew: 'option+n',
|
||||||
|
focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
|
||||||
|
reply: 'r',
|
||||||
|
favourite: 'f',
|
||||||
|
boost: 'b',
|
||||||
|
mention: 'm',
|
||||||
|
open: ['enter', 'o'],
|
||||||
|
openProfile: 'p',
|
||||||
|
moveDown: ['down', 'j'],
|
||||||
|
moveUp: ['up', 'k'],
|
||||||
|
back: 'backspace',
|
||||||
|
goToHome: 'g h',
|
||||||
|
goToNotifications: 'g n',
|
||||||
|
goToLocal: 'g l',
|
||||||
|
goToFederated: 'g t',
|
||||||
|
goToStart: 'g s',
|
||||||
|
goToFavourites: 'g f',
|
||||||
|
goToPinned: 'g p',
|
||||||
|
goToProfile: 'g u',
|
||||||
|
goToBlocked: 'g b',
|
||||||
|
goToMuted: 'g m',
|
||||||
|
};
|
||||||
|
|
||||||
@connect(mapStateToProps)
|
@connect(mapStateToProps)
|
||||||
@withRouter
|
@withRouter
|
||||||
export default class UI extends React.Component {
|
export default class UI extends React.Component {
|
||||||
|
@ -58,6 +86,7 @@ export default class UI extends React.Component {
|
||||||
dispatch: PropTypes.func.isRequired,
|
dispatch: PropTypes.func.isRequired,
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
isComposing: PropTypes.bool,
|
isComposing: PropTypes.bool,
|
||||||
|
me: PropTypes.string,
|
||||||
location: PropTypes.object,
|
location: PropTypes.object,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -155,6 +184,12 @@ export default class UI extends React.Component {
|
||||||
this.props.dispatch(refreshNotifications());
|
this.props.dispatch(refreshNotifications());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
|
||||||
|
return !(e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) && ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
shouldComponentUpdate (nextProps) {
|
shouldComponentUpdate (nextProps) {
|
||||||
if (nextProps.isComposing !== this.props.isComposing) {
|
if (nextProps.isComposing !== this.props.isComposing) {
|
||||||
// Avoid expensive update just to toggle a class
|
// Avoid expensive update just to toggle a class
|
||||||
|
@ -191,52 +226,160 @@ export default class UI extends React.Component {
|
||||||
this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
|
this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
setOverlayRef = c => {
|
handleHotkeyNew = e => {
|
||||||
this.overlay = c;
|
e.preventDefault();
|
||||||
|
|
||||||
|
const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeySearch = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const element = this.node.querySelector('.search__input');
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyForceNew = e => {
|
||||||
|
this.handleHotkeyNew(e);
|
||||||
|
this.props.dispatch(resetCompose());
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyFocusColumn = e => {
|
||||||
|
const index = (e.key * 1) + 1; // First child is drawer, skip that
|
||||||
|
const column = this.node.querySelector(`.column:nth-child(${index})`);
|
||||||
|
|
||||||
|
if (column) {
|
||||||
|
const status = column.querySelector('.focusable');
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
status.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyBack = () => {
|
||||||
|
if (window.history && window.history.length === 1) {
|
||||||
|
this.context.router.history.push('/');
|
||||||
|
} else {
|
||||||
|
this.context.router.history.goBack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHotkeysRef = c => {
|
||||||
|
this.hotkeys = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyGoToHome = () => {
|
||||||
|
this.context.router.history.push('/timelines/home');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyGoToNotifications = () => {
|
||||||
|
this.context.router.history.push('/notifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyGoToLocal = () => {
|
||||||
|
this.context.router.history.push('/timelines/public/local');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyGoToFederated = () => {
|
||||||
|
this.context.router.history.push('/timelines/public');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyGoToStart = () => {
|
||||||
|
this.context.router.history.push('/getting-started');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyGoToFavourites = () => {
|
||||||
|
this.context.router.history.push('/favourites');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyGoToPinned = () => {
|
||||||
|
this.context.router.history.push('/pinned');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyGoToProfile = () => {
|
||||||
|
this.context.router.history.push(`/accounts/${this.props.me}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyGoToBlocked = () => {
|
||||||
|
this.context.router.history.push('/blocks');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyGoToMuted = () => {
|
||||||
|
this.context.router.history.push('/mutes');
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { width, draggingOver } = this.state;
|
const { width, draggingOver } = this.state;
|
||||||
const { children } = this.props;
|
const { children } = this.props;
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
new: this.handleHotkeyNew,
|
||||||
|
search: this.handleHotkeySearch,
|
||||||
|
forceNew: this.handleHotkeyForceNew,
|
||||||
|
focusColumn: this.handleHotkeyFocusColumn,
|
||||||
|
back: this.handleHotkeyBack,
|
||||||
|
goToHome: this.handleHotkeyGoToHome,
|
||||||
|
goToNotifications: this.handleHotkeyGoToNotifications,
|
||||||
|
goToLocal: this.handleHotkeyGoToLocal,
|
||||||
|
goToFederated: this.handleHotkeyGoToFederated,
|
||||||
|
goToStart: this.handleHotkeyGoToStart,
|
||||||
|
goToFavourites: this.handleHotkeyGoToFavourites,
|
||||||
|
goToPinned: this.handleHotkeyGoToPinned,
|
||||||
|
goToProfile: this.handleHotkeyGoToProfile,
|
||||||
|
goToBlocked: this.handleHotkeyGoToBlocked,
|
||||||
|
goToMuted: this.handleHotkeyGoToMuted,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='ui' ref={this.setRef}>
|
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}>
|
||||||
<TabsBar />
|
<div className='ui' ref={this.setRef}>
|
||||||
<ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width)}>
|
<TabsBar />
|
||||||
<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} />
|
<ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width)}>
|
||||||
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
|
<WrappedSwitch>
|
||||||
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
|
<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='/notifications' component={Notifications} content={children} />
|
||||||
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
|
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
|
||||||
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
|
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
|
||||||
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
|
|
||||||
|
|
||||||
<WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
|
<WrappedRoute path='/statuses/new' component={Compose} content={children} />
|
||||||
<WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
|
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
|
||||||
<WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
|
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
|
||||||
<WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
|
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
|
||||||
|
|
||||||
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
|
<WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
|
||||||
<WrappedRoute path='/blocks' component={Blocks} content={children} />
|
<WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
|
||||||
<WrappedRoute path='/mutes' component={Mutes} 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} />
|
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
|
||||||
</WrappedSwitch>
|
<WrappedRoute path='/blocks' component={Blocks} content={children} />
|
||||||
</ColumnsAreaContainer>
|
<WrappedRoute path='/mutes' component={Mutes} content={children} />
|
||||||
<NotificationsContainer />
|
|
||||||
<LoadingBarContainer className='loading-bar' />
|
<WrappedRoute component={GenericNotFound} content={children} />
|
||||||
<ModalContainer />
|
</WrappedSwitch>
|
||||||
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
</ColumnsAreaContainer>
|
||||||
</div>
|
|
||||||
|
<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_REQUEST,
|
||||||
COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
||||||
COMPOSE_UPLOAD_CHANGE_FAIL,
|
COMPOSE_UPLOAD_CHANGE_FAIL,
|
||||||
|
COMPOSE_RESET,
|
||||||
} from '../actions/compose';
|
} from '../actions/compose';
|
||||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||||
import { STORE_HYDRATE } from '../actions/store';
|
import { STORE_HYDRATE } from '../actions/store';
|
||||||
|
@ -214,6 +215,7 @@ export default function compose(state = initialState, action) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
case COMPOSE_REPLY_CANCEL:
|
case COMPOSE_REPLY_CANCEL:
|
||||||
|
case COMPOSE_RESET:
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.set('in_reply_to', null);
|
map.set('in_reply_to', null);
|
||||||
map.set('text', '');
|
map.set('text', '');
|
||||||
|
|
|
@ -94,9 +94,12 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-holder {
|
.app-holder {
|
||||||
display: flex;
|
&,
|
||||||
width: 100%;
|
& > div {
|
||||||
height: 100%;
|
display: flex;
|
||||||
align-items: center;
|
width: 100%;
|
||||||
justify-content: center;
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -587,6 +587,22 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.focusable {
|
||||||
|
&:focus {
|
||||||
|
outline: 0;
|
||||||
|
background: lighten($ui-base-color, 4%);
|
||||||
|
|
||||||
|
&.status-direct {
|
||||||
|
background: lighten($ui-base-color, 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-status,
|
||||||
|
.detailed-status__action-bar {
|
||||||
|
background: lighten($ui-base-color, 8%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
padding-left: 68px;
|
padding-left: 68px;
|
||||||
|
@ -1046,11 +1062,11 @@
|
||||||
strong {
|
strong {
|
||||||
color: $primary-text-color;
|
color: $primary-text-color;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.muted {
|
.muted {
|
||||||
.emojione {
|
.emojione {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -80,6 +80,7 @@
|
||||||
"rails-ujs": "^5.1.2",
|
"rails-ujs": "^5.1.2",
|
||||||
"react": "^16.0.0",
|
"react": "^16.0.0",
|
||||||
"react-dom": "^16.0.0",
|
"react-dom": "^16.0.0",
|
||||||
|
"react-hotkeys": "^0.10.0",
|
||||||
"react-immutable-proptypes": "^2.1.0",
|
"react-immutable-proptypes": "^2.1.0",
|
||||||
"react-immutable-pure-component": "^1.0.0",
|
"react-immutable-pure-component": "^1.0.0",
|
||||||
"react-intl": "^2.4.0",
|
"react-intl": "^2.4.0",
|
||||||
|
|
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"
|
safe-buffer "^5.0.1"
|
||||||
sha.js "^2.4.8"
|
sha.js "^2.4.8"
|
||||||
|
|
||||||
|
create-react-class@^15.5.2:
|
||||||
|
version "15.6.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.6.2.tgz#cf1ed15f12aad7f14ef5f2dfe05e6c42f91ef02a"
|
||||||
|
dependencies:
|
||||||
|
fbjs "^0.8.9"
|
||||||
|
loose-envify "^1.3.1"
|
||||||
|
object-assign "^4.1.1"
|
||||||
|
|
||||||
cross-env@^5.0.1:
|
cross-env@^5.0.1:
|
||||||
version "5.0.5"
|
version "5.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.0.5.tgz#4383d364d9660873dd185b398af3bfef5efffef3"
|
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.0.5.tgz#4383d364d9660873dd185b398af3bfef5efffef3"
|
||||||
|
@ -4209,6 +4217,10 @@ mocha@^3.4.1:
|
||||||
mkdirp "0.5.1"
|
mkdirp "0.5.1"
|
||||||
supports-color "3.1.2"
|
supports-color "3.1.2"
|
||||||
|
|
||||||
|
mousetrap@^1.5.2:
|
||||||
|
version "1.6.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.1.tgz#2a085f5c751294c75e7e81f6ec2545b29cbf42d9"
|
||||||
|
|
||||||
ms@2.0.0:
|
ms@2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
||||||
|
@ -5553,6 +5565,15 @@ react-event-listener@^0.5.0:
|
||||||
prop-types "^15.5.10"
|
prop-types "^15.5.10"
|
||||||
warning "^3.0.0"
|
warning "^3.0.0"
|
||||||
|
|
||||||
|
react-hotkeys@^0.10.0:
|
||||||
|
version "0.10.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-0.10.0.tgz#d1e78bd63f16d6db58d550d33c8eb071f35d94fb"
|
||||||
|
dependencies:
|
||||||
|
create-react-class "^15.5.2"
|
||||||
|
lodash "^4.13.1"
|
||||||
|
mousetrap "^1.5.2"
|
||||||
|
prop-types "^15.5.8"
|
||||||
|
|
||||||
react-immutable-proptypes@^2.1.0:
|
react-immutable-proptypes@^2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-immutable-proptypes/-/react-immutable-proptypes-2.1.0.tgz#023d6f39bb15c97c071e9e60d00d136eac5fa0b4"
|
resolved "https://registry.yarnpkg.com/react-immutable-proptypes/-/react-immutable-proptypes-2.1.0.tgz#023d6f39bb15c97c071e9e60d00d136eac5fa0b4"
|
||||||
|
|
Reference in a new issue