* 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 = '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 ( | |||||
<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> | </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} />; | ||||
} | } | ||||
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 ( | 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, | 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'> | |||||
<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> | </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> | ||||
<AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} /> | |||||
</div> | |||||
</HotKeys> | |||||
); | ); | ||||
} | } | ||||
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'> | |||||
<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> | </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) { | renderReblog (notification, link) { | ||||
return ( | 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> | </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 { 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 | |||||
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} | {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 => { | |||||
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 () { | 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}> | |||||
<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_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%; | |||||
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; | 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 { | |||||
.emojione { | |||||
opacity: 0.5; | |||||
} | |||||
.muted { | |||||
.emojione { | |||||
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", | ||||
@@ -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" | ||||