* 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 textareamaster
@@ -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')} > | |||
<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> | |||
); | |||
} | |||
@@ -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,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> | |||
); | |||
} | |||
@@ -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,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> | |||
@@ -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> | |||
); | |||
} | |||
@@ -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 { | |||
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; | |||
} | |||
} |
@@ -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; | |||
} | |||
} | |||
@@ -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", | |||
@@ -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" | |||