diff --git a/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap b/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap index 6b6307012..67ca647eb 100644 --- a/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap +++ b/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap @@ -2,55 +2,80 @@ exports[` renders correctly 1`] = `
-
`; diff --git a/app/soapbox/components/__tests__/emoji_selector-test.js b/app/soapbox/components/__tests__/emoji_selector-test.js index c8083c3aa..e907a8a82 100644 --- a/app/soapbox/components/__tests__/emoji_selector-test.js +++ b/app/soapbox/components/__tests__/emoji_selector-test.js @@ -4,7 +4,10 @@ import EmojiSelector from '../emoji_selector'; describe('', () => { it('renders correctly', () => { - const component = createComponent(); + const children = ; + children.__proto__.addEventListener = () => {}; + + const component = createComponent(children, {}, true); const tree = component.toJSON(); expect(tree).toMatchSnapshot(); }); diff --git a/app/soapbox/components/emoji_selector.js b/app/soapbox/components/emoji_selector.js index 4cd0395e3..7fd52d965 100644 --- a/app/soapbox/components/emoji_selector.js +++ b/app/soapbox/components/emoji_selector.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { HotKeys } from 'react-hotkeys'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; import emojify from 'soapbox/features/emoji/emoji'; @@ -15,28 +16,89 @@ class EmojiSelector extends ImmutablePureComponent { static propTypes = { onReact: PropTypes.func.isRequired, + onUnfocus: PropTypes.func, visible: PropTypes.bool, + focused: PropTypes.bool, } static defaultProps = { onReact: () => {}, + onUnfocus: () => {}, visible: false, } + handleBlur = e => { + const { focused, onUnfocus } = this.props; + + if (focused && (!e.relatedTarget || !e.relatedTarget.classList.contains('emoji-react-selector__emoji'))) { + onUnfocus(); + } + } + + handleKeyUp = i => e => { + const { onUnfocus } = this.props; + + switch (e.key) { + case 'Left': + case 'ArrowLeft': + if (i !== 0) { + this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`).focus(); + } + break; + case 'Right': + case 'ArrowRight': + if (i !== this.props.allowedEmoji.size - 1) { + this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`).focus(); + } + break; + case 'Escape': + onUnfocus(); + break; + } + } + + handleReact = emoji => () => { + const { onReact, focused, onUnfocus } = this.props; + + onReact(emoji)(); + + if (focused) { + onUnfocus(); + } + } + + handlers = { + open: () => {}, + }; + + setRef = c => { + this.node = c; + } + render() { - const { onReact, visible, allowedEmoji } = this.props; + const { visible, focused, allowedEmoji } = this.props; return ( -
- {allowedEmoji.map((emoji, i) => ( -
+ +
+ {allowedEmoji.map((emoji, i) => ( +
+
); } diff --git a/app/soapbox/components/status.js b/app/soapbox/components/status.js index 1ff0b1e03..fcdddccb2 100644 --- a/app/soapbox/components/status.js +++ b/app/soapbox/components/status.js @@ -108,6 +108,7 @@ class Status extends ImmutablePureComponent { state = { showMedia: defaultMediaVisibility(this.props.status, this.props.displayMedia), statusId: undefined, + emojiSelectorFocused: false, }; // Track height changes we know about to compensate scrolling @@ -255,6 +256,27 @@ class Status extends ImmutablePureComponent { this.handleToggleMediaVisibility(); } + handleHotkeyReact = () => { + this._expandEmojiSelector(); + } + + handleEmojiSelectorExpand = e => { + if (e.key === 'Enter') { + this._expandEmojiSelector(); + } + e.preventDefault(); + } + + handleEmojiSelectorUnfocus = () => { + this.setState({ emojiSelectorFocused: false }); + } + + _expandEmojiSelector = () => { + this.setState({ emojiSelectorFocused: true }); + const firstEmoji = this.node.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); + firstEmoji.focus(); + }; + _properStatus() { const { status } = this.props; @@ -278,6 +300,7 @@ class Status extends ImmutablePureComponent { let { status, account, ...other } = this.props; + if (status === null) { return null; } @@ -443,6 +466,7 @@ class Status extends ImmutablePureComponent { moveDown: this.handleHotkeyMoveDown, toggleHidden: this.handleHotkeyToggleHidden, toggleSensitive: this.handleHotkeyToggleSensitive, + react: this.handleHotkeyReact, }; const statusUrl = `/@${status.getIn(['account', 'acct'])}/posts/${status.get('id')}`; @@ -506,7 +530,13 @@ class Status extends ImmutablePureComponent { )} - + diff --git a/app/soapbox/components/status_action_bar.js b/app/soapbox/components/status_action_bar.js index b58991ea3..206170e24 100644 --- a/app/soapbox/components/status_action_bar.js +++ b/app/soapbox/components/status_action_bar.js @@ -91,6 +91,10 @@ class StatusActionBar extends ImmutablePureComponent { isStaff: PropTypes.bool.isRequired, isAdmin: PropTypes.bool.isRequired, allowedEmoji: ImmutablePropTypes.list, + emojiSelectorFocused: PropTypes.bool, + handleEmojiSelectorUnfocus: PropTypes.func.isRequired, + emojiSelectorFocused: PropTypes.bool, + handleEmojiSelectorUnfocus: PropTypes.func.isRequired, }; static defaultProps = { @@ -106,6 +110,7 @@ class StatusActionBar extends ImmutablePureComponent { updateOnProps = [ 'status', 'withDismiss', + 'emojiSelectorFocused', ] handleReplyClick = () => { @@ -359,7 +364,7 @@ class StatusActionBar extends ImmutablePureComponent { } render() { - const { status, intl, allowedEmoji } = this.props; + const { status, intl, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorUnfocus } = this.props; const { emojiSelectorVisible } = this.state; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); @@ -422,7 +427,12 @@ class StatusActionBar extends ImmutablePureComponent { onMouseLeave={this.handleLikeButtonLeave} ref={this.setRef} > - + { if (featured) { - return this.props.featuredStatusIds.indexOf(id); + return this.props.featuredStatusIds.keySeq().findIndex(key => key === id); } else { - return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount(); + return this.props.statusIds.keySeq().findIndex(key => key === id) + this.getFeaturedStatusCount(); } } diff --git a/app/soapbox/features/status/components/action_bar.js b/app/soapbox/features/status/components/action_bar.js index 1336186d9..8cc300e5d 100644 --- a/app/soapbox/features/status/components/action_bar.js +++ b/app/soapbox/features/status/components/action_bar.js @@ -48,6 +48,7 @@ const messages = defineMessages({ reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' }, reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' }, + emojiPickerExpand: { id: 'status.reactions_expand', defaultMessage: 'Select emoji' }, }); const mapStateToProps = state => { @@ -99,6 +100,9 @@ class ActionBar extends React.PureComponent { isStaff: PropTypes.bool.isRequired, isAdmin: PropTypes.bool.isRequired, allowedEmoji: ImmutablePropTypes.list, + emojiSelectorFocused: PropTypes.bool, + handleEmojiSelectorExpand: PropTypes.func.isRequired, + handleEmojiSelectorUnfocus: PropTypes.func.isRequired, }; static defaultProps = { @@ -107,6 +111,7 @@ class ActionBar extends React.PureComponent { state = { emojiSelectorVisible: false, + emojiSelectorFocused: false, } handleReplyClick = () => { @@ -169,10 +174,16 @@ class ActionBar extends React.PureComponent { } else { this.props.onOpenUnauthorizedModal(); } - this.setState({ emojiSelectorVisible: false }); + this.setState({ emojiSelectorVisible: false, emojiSelectorFocused: false }); }; } + handleHotkeyEmoji = () => { + const { emojiSelectorVisible } = this.state; + + this.setState({ emojiSelectorVisible: !emojiSelectorVisible }); + } + handleDeleteClick = () => { this.props.onDelete(this.props.status, this.context.router.history); } @@ -262,12 +273,12 @@ class ActionBar extends React.PureComponent { componentDidMount() { document.addEventListener('click', e => { if (this.node && !this.node.contains(e.target)) - this.setState({ emojiSelectorVisible: false }); + this.setState({ emojiSelectorVisible: false, emojiSelectorFocused: false }); }); } render() { - const { status, intl, me, isStaff, isAdmin, allowedEmoji } = this.props; + const { status, intl, me, isStaff, isAdmin, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorExpand, handleEmojiSelectorUnfocus } = this.props; const { emojiSelectorVisible } = this.state; const ownAccount = status.getIn(['account', 'id']) === me; @@ -351,6 +362,7 @@ class ActionBar extends React.PureComponent { let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private'); + return (
@@ -377,7 +389,12 @@ class ActionBar extends React.PureComponent { onMouseLeave={this.handleLikeButtonLeave} ref={this.setRef} > - + +
{shareButton} @@ -400,4 +425,5 @@ class ActionBar extends React.PureComponent { } -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ActionBar)); +export default injectIntl( + connect(mapStateToProps, mapDispatchToProps)(ActionBar)); diff --git a/app/soapbox/features/status/index.js b/app/soapbox/features/status/index.js index 08135b7f9..0ecf98e8c 100644 --- a/app/soapbox/features/status/index.js +++ b/app/soapbox/features/status/index.js @@ -146,6 +146,7 @@ class Status extends ImmutablePureComponent { fullscreen: false, showMedia: defaultMediaVisibility(this.props.status, this.props.displayMedia), loadedStatusId: undefined, + emojiSelectorFocused: false, }; componentDidMount() { @@ -363,6 +364,10 @@ class Status extends ImmutablePureComponent { this.handleToggleMediaVisibility(); } + handleHotkeyReact = () => { + this._expandEmojiSelector(); + } + handleMoveUp = id => { const { status, ancestorsIds, descendantsIds } = this.props; @@ -397,6 +402,23 @@ class Status extends ImmutablePureComponent { } } + handleEmojiSelectorExpand = e => { + if (e.key === 'Enter') { + this._expandEmojiSelector(); + } + e.preventDefault(); + } + + handleEmojiSelectorUnfocus = () => { + this.setState({ emojiSelectorFocused: false }); + } + + _expandEmojiSelector = () => { + this.setState({ emojiSelectorFocused: true }); + const firstEmoji = this.status.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); + firstEmoji.focus(); + }; + _selectChild(index, align_top) { const container = this.node; const element = container.querySelectorAll('.focusable')[index]; @@ -445,6 +467,10 @@ class Status extends ImmutablePureComponent { this.node = c; } + setStatusRef = c => { + this.status = c; + } + componentDidUpdate(prevProps, prevState) { const { params, status } = this.props; const { ancestorsIds } = prevProps; @@ -510,6 +536,7 @@ class Status extends ImmutablePureComponent { openProfile: this.handleHotkeyOpenProfile, toggleHidden: this.handleHotkeyToggleHidden, toggleSensitive: this.handleHotkeyToggleSensitive, + react: this.handleHotkeyReact, }; return ( @@ -537,7 +564,7 @@ class Status extends ImmutablePureComponent { {ancestors} -
+
diff --git a/app/soapbox/features/ui/components/hotkeys_modal.js b/app/soapbox/features/ui/components/hotkeys_modal.js index 319e2e0e5..c646a0923 100644 --- a/app/soapbox/features/ui/components/hotkeys_modal.js +++ b/app/soapbox/features/ui/components/hotkeys_modal.js @@ -49,6 +49,10 @@ class HotkeysModal extends ImmutablePureComponent { f + + e + + b diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js index 2837cd902..fe6745b69 100644 --- a/app/soapbox/features/ui/index.js +++ b/app/soapbox/features/ui/index.js @@ -130,6 +130,7 @@ const keyMap = { forceNew: 'option+n', reply: 'r', favourite: 'f', + react: 'e', boost: 'b', mention: 'm', open: ['enter', 'o'], diff --git a/app/soapbox/locales/pl.json b/app/soapbox/locales/pl.json index 28d24c990..b968895d7 100644 --- a/app/soapbox/locales/pl.json +++ b/app/soapbox/locales/pl.json @@ -403,6 +403,7 @@ "keyboard_shortcuts.notifications": "aby otworzyć kolumnę powiadomień", "keyboard_shortcuts.pinned": "aby przejść do listy przypiętych wpisów", "keyboard_shortcuts.profile": "aby przejść do profilu autora wpisu", + "keyboard_shortcuts.react": "aby zareagować na wpis", "keyboard_shortcuts.reply": "aby odpowiedzieć", "keyboard_shortcuts.requests": "aby przejść do listy próśb o możliwość śledzenia", "keyboard_shortcuts.search": "aby przejść do pola wyszukiwania", @@ -707,6 +708,7 @@ "status.reactions.like": "Lubię", "status.reactions.open_mouth": "Wow", "status.reactions.weary": "Nuda…", + "status.reactions_expand": "Wybierz emoji", "status.read_more": "Czytaj dalej", "status.reblog": "Podbij", "status.reblog_private": "Podbij dla odbiorców oryginalnego wpisu", diff --git a/app/styles/components/detailed-status.scss b/app/styles/components/detailed-status.scss index 985f1a079..3daac0805 100644 --- a/app/styles/components/detailed-status.scss +++ b/app/styles/components/detailed-status.scss @@ -94,6 +94,22 @@ transform: translateY(-1px); } } + + .emoji-picker-expand { + display: none; + } + + &:focus-within { + .emoji-picker-expand { + display: inline-flex; + width: 0; + overflow: hidden; + + &:focus-within { + width: unset; + } + } + } } .detailed-status__wrapper { diff --git a/app/styles/components/emoji-reacts.scss b/app/styles/components/emoji-reacts.scss index d9a4450c7..9b2311099 100644 --- a/app/styles/components/emoji-reacts.scss +++ b/app/styles/components/emoji-reacts.scss @@ -80,7 +80,8 @@ transition: 0.1s; z-index: 999; - &--visible { + &--visible, + &--focused { opacity: 1; pointer-events: all; } @@ -99,7 +100,8 @@ transition: 0.1s; } - &:hover { + &:hover, + &:focus { img { width: 36px; height: 36px; diff --git a/app/styles/components/status.scss b/app/styles/components/status.scss index 8c640e4d1..359ed2999 100644 --- a/app/styles/components/status.scss +++ b/app/styles/components/status.scss @@ -677,3 +677,22 @@ a.status-card.compact:hover { border-radius: 4px; } } + +.status__action-bar, +.detailed-status__action-bar { + .emoji-picker-expand { + display: none; + } + + &:focus-within { + .emoji-picker-expand { + display: inline-flex; + width: 0; + overflow: hidden; + + &:focus-within { + width: unset; + } + } + } +}