From 50d38d7605b8998463b1428b8da886f33e0714da Mon Sep 17 00:00:00 2001 From: Sorin Davidoi Date: Thu, 27 Jul 2017 22:31:59 +0200 Subject: [PATCH] fix(dropdown_menu): Open as modal on mobile (#4295) * fix(dropdown_menu): Open as modal on mobile * fix(dropdown_menu): Open modal on touch * fix(dropdown_menu): Show status * fix(dropdown_menu): Max dimensions and reduce padding * chore(dropdown_menu): Test new functionality * refactor: Use DropdownMenuContainer instead of DropdownMenu * feat(privacy_dropdown): Open as modal on touch devices * feat(modal_root): Do not load actions-modal async --- .../mastodon/components/dropdown_menu.js | 39 ++++++++-- .../mastodon/components/status_action_bar.js | 4 +- .../containers/dropdown_menu_container.js | 16 +++++ .../features/account/components/action_bar.js | 4 +- .../compose/components/privacy_dropdown.js | 50 +++++++++---- .../containers/privacy_dropdown_container.js | 7 ++ .../features/status/components/action_bar.js | 4 +- .../features/ui/components/actions_modal.js | 72 +++++++++++++++++++ .../features/ui/components/modal_root.js | 2 + app/javascript/mastodon/is_mobile.js | 9 +++ app/javascript/styles/components.scss | 62 +++++++++++++--- .../components/dropdown_menu.test.js | 47 ++++++++++-- 12 files changed, 276 insertions(+), 40 deletions(-) create mode 100644 app/javascript/mastodon/containers/dropdown_menu_container.js create mode 100644 app/javascript/mastodon/features/ui/components/actions_modal.js diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index 98323b069..8e9e6ab94 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -1,4 +1,5 @@ import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; import PropTypes from 'prop-types'; @@ -9,16 +10,23 @@ export default class DropdownMenu extends React.PureComponent { }; static propTypes = { + isUserTouching: PropTypes.func, + isModalOpen: PropTypes.bool.isRequired, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, icon: PropTypes.string.isRequired, items: PropTypes.array.isRequired, size: PropTypes.number.isRequired, direction: PropTypes.string, + status: ImmutablePropTypes.map, ariaLabel: PropTypes.string, disabled: PropTypes.bool, }; static defaultProps = { ariaLabel: 'Menu', + isModalOpen: false, + isUserTouching: () => false, }; state = { @@ -34,6 +42,10 @@ export default class DropdownMenu extends React.PureComponent { const i = Number(e.currentTarget.getAttribute('data-index')); const { action, to } = this.props.items[i]; + if (this.props.isModalOpen) { + this.props.onModalClose(); + } + // Don't call e.preventDefault() when the item uses 'href' property. // ex. "Edit profile" on the account action bar @@ -48,7 +60,17 @@ export default class DropdownMenu extends React.PureComponent { this.dropdown.hide(); } - handleShow = () => this.setState({ expanded: true }) + handleShow = () => { + if (this.props.isUserTouching()) { + this.props.onModalOpen({ + status: this.props.status, + actions: this.props.items, + onClick: this.handleClick, + }); + } else { + this.setState({ expanded: true }); + } + } handleHide = () => this.setState({ expanded: false }) @@ -71,6 +93,7 @@ export default class DropdownMenu extends React.PureComponent { render () { const { icon, items, size, direction, ariaLabel, disabled } = this.props; const { expanded } = this.state; + const isUserTouching = this.props.isUserTouching(); const directionClass = (direction === 'left') ? 'dropdown__left' : 'dropdown__right'; const iconStyle = { fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }; const iconClassname = `fa fa-fw fa-${icon} dropdown__icon`; @@ -89,15 +112,21 @@ export default class DropdownMenu extends React.PureComponent { ); + // No need to render the actual dropdown if we use the modal. If we + // don't render anything breaks, so we just put an empty div. + const dropdownContent = !isUserTouching ? ( + + {dropdownItems} + + ) :
; + return ( - + - - {dropdownItems} - + {dropdownContent} ); } diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 4e02e6fad..5c83d626e 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -2,7 +2,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import IconButton from './icon_button'; -import DropdownMenu from './dropdown_menu'; +import DropdownMenuContainer from '../containers/dropdown_menu_container'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -156,7 +156,7 @@ export default class StatusActionBar extends ImmutablePureComponent { {shareButton}
- +
); diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js new file mode 100644 index 000000000..151f25390 --- /dev/null +++ b/app/javascript/mastodon/containers/dropdown_menu_container.js @@ -0,0 +1,16 @@ +import { openModal, closeModal } from '../actions/modal'; +import { connect } from 'react-redux'; +import DropdownMenu from '../components/dropdown_menu'; +import { isUserTouching } from '../is_mobile'; + +const mapStateToProps = state => ({ + isModalOpen: state.get('modal').modalType === 'ACTIONS', +}); + +const mapDispatchToProps = dispatch => ({ + isUserTouching, + onModalOpen: props => dispatch(openModal('ACTIONS', props)), + onModalClose: () => dispatch(closeModal()), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu); diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js index b8df724c6..b773045fb 100644 --- a/app/javascript/mastodon/features/account/components/action_bar.js +++ b/app/javascript/mastodon/features/account/components/action_bar.js @@ -1,7 +1,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import DropdownMenu from '../../../components/dropdown_menu'; +import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; import Link from 'react-router-dom/Link'; import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; @@ -96,7 +96,7 @@ export default class ActionBar extends React.PureComponent {
- +
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js index 9524f7501..33ce7db46 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -24,6 +24,10 @@ const iconStyle = { export default class PrivacyDropdown extends React.PureComponent { static propTypes = { + isUserTouching: PropTypes.func, + isModalOpen: PropTypes.bool.isRequired, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, value: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, @@ -34,7 +38,25 @@ export default class PrivacyDropdown extends React.PureComponent { }; handleToggle = () => { - this.setState({ open: !this.state.open }); + if (this.props.isUserTouching()) { + if (this.state.open) { + this.props.onModalClose(); + } else { + this.props.onModalOpen({ + actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })), + onClick: this.handleModalActionClick, + }); + } + } else { + this.setState({ open: !this.state.open }); + } + } + + handleModalActionClick = (e) => { + e.preventDefault(); + const { value } = this.options[e.currentTarget.getAttribute('data-index')]; + this.props.onModalClose(); + this.props.onChange(value); } handleClick = (e) => { @@ -50,6 +72,17 @@ export default class PrivacyDropdown extends React.PureComponent { } } + componentWillMount () { + const { intl: { formatMessage } } = this.props; + + this.options = [ + { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, + { icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) }, + { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, + { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, + ]; + } + componentDidMount () { window.addEventListener('click', this.onGlobalClick); window.addEventListener('touchstart', this.onGlobalClick); @@ -68,25 +101,18 @@ export default class PrivacyDropdown extends React.PureComponent { const { value, intl } = this.props; const { open } = this.state; - const options = [ - { icon: 'globe', value: 'public', shortText: intl.formatMessage(messages.public_short), longText: intl.formatMessage(messages.public_long) }, - { icon: 'unlock-alt', value: 'unlisted', shortText: intl.formatMessage(messages.unlisted_short), longText: intl.formatMessage(messages.unlisted_long) }, - { icon: 'lock', value: 'private', shortText: intl.formatMessage(messages.private_short), longText: intl.formatMessage(messages.private_long) }, - { icon: 'envelope', value: 'direct', shortText: intl.formatMessage(messages.direct_short), longText: intl.formatMessage(messages.direct_long) }, - ]; - - const valueOption = options.find(item => item.value === value); + const valueOption = this.options.find(item => item.value === value); return (
- {open && options.map(item => + {open && this.options.map(item =>
- {item.shortText} - {item.longText} + {item.text} + {item.meta}
)} diff --git a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js index 9c05e054e..0ddf531d3 100644 --- a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js +++ b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js @@ -1,8 +1,11 @@ import { connect } from 'react-redux'; import PrivacyDropdown from '../components/privacy_dropdown'; import { changeComposeVisibility } from '../../../actions/compose'; +import { openModal, closeModal } from '../../../actions/modal'; +import { isUserTouching } from '../../../is_mobile'; const mapStateToProps = state => ({ + isModalOpen: state.get('modal').modalType === 'ACTIONS', value: state.getIn(['compose', 'privacy']), }); @@ -12,6 +15,10 @@ const mapDispatchToProps = dispatch => ({ dispatch(changeComposeVisibility(value)); }, + isUserTouching, + onModalOpen: props => dispatch(openModal('ACTIONS', props)), + onModalClose: () => dispatch(closeModal()), + }); export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown); diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index 5e150842e..c4d4bb747 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import IconButton from '../../../components/icon_button'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import DropdownMenu from '../../../components/dropdown_menu'; +import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; import { defineMessages, injectIntl } from 'react-intl'; const messages = defineMessages({ @@ -84,7 +84,7 @@ export default class ActionBar extends React.PureComponent {
- +
); diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.js b/app/javascript/mastodon/features/ui/components/actions_modal.js new file mode 100644 index 000000000..0fc2560ff --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/actions_modal.js @@ -0,0 +1,72 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import StatusContent from '../../../components/status_content'; +import Avatar from '../../../components/avatar'; +import RelativeTimestamp from '../../../components/relative_timestamp'; +import DisplayName from '../../../components/display_name'; +import IconButton from '../../../components/icon_button'; + +export default class ReportModal extends ImmutablePureComponent { + + static propTypes = { + actions: PropTypes.array, + onClick: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + renderAction = (action, i) => { + if (action === null) { + return
  • ; + } + + const { icon = null, text, meta = null, active = false, href = '#' } = action; + + return ( +
  • + + {icon && } +
    +
    {text}
    +
    {meta}
    +
    +
    +
  • + ); + } + + render () { + const status = this.props.status && ( +
    + + + +
    + ); + + return ( +
    + {status} + +
      + {this.props.actions.map(this.renderAction)} +
    +
    + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index f303088d7..4a917e0a3 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -5,6 +5,7 @@ import spring from 'react-motion/lib/spring'; import BundleContainer from '../containers/bundle_container'; import BundleModalError from './bundle_modal_error'; import ModalLoading from './modal_loading'; +import ActionsModal from '../components/actions_modal'; import { MediaModal, OnboardingModal, @@ -21,6 +22,7 @@ const MODAL_COMPONENTS = { 'BOOST': BoostModal, 'CONFIRM': ConfirmationModal, 'REPORT': ReportModal, + 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), }; export default class ModalRoot extends React.PureComponent { diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js index 992e63727..e9903d59e 100644 --- a/app/javascript/mastodon/is_mobile.js +++ b/app/javascript/mastodon/is_mobile.js @@ -5,6 +5,15 @@ export function isMobile(width) { }; const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; +let userTouching = false; + +window.addEventListener('touchstart', () => { + userTouching = true; +}, { once: true }); + +export function isUserTouching() { + return userTouching; +} export function isIOS() { return iOS; diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index a51cd962e..03bc77eb5 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -214,16 +214,18 @@ } .dropdown--active::after { - content: ""; - display: block; - position: absolute; - width: 0; - height: 0; - border-style: solid; - border-width: 0 4.5px 7.8px; - border-color: transparent transparent $ui-secondary-color; - bottom: 8px; - right: 104px; + @media screen and (min-width: 1025px) { + content: ""; + display: block; + position: absolute; + width: 0; + height: 0; + border-style: solid; + border-width: 0 4.5px 7.8px; + border-color: transparent transparent $ui-secondary-color; + bottom: 8px; + right: 104px; + } } .invisible { @@ -3402,7 +3404,8 @@ button.icon-button.active i.fa-retweet { .boost-modal, .confirmation-modal, -.report-modal { +.report-modal, +.actions-modal { background: lighten($ui-secondary-color, 8%); color: $ui-base-color; border-radius: 8px; @@ -3493,6 +3496,43 @@ button.icon-button.active i.fa-retweet { } } +.actions-modal { + .status { + overflow-y: auto; + max-height: 300px; + } + + max-height: 80vh; + max-width: 80vw; + + ul { + overflow-y: auto; + flex-shrink: 0; + + li:not(:empty) { + a { + color: $ui-base-color; + display: flex; + padding: 10px; + align-items: center; + text-decoration: none; + + &.active { + &, + button { + background: $ui-highlight-color; + color: $primary-text-color; + } + } + + button:first-child { + margin-right: 10px; + } + } + } + } +} + .confirmation-modal__action-bar { .confirmation-modal__cancel-button { background-color: transparent; diff --git a/spec/javascript/components/dropdown_menu.test.js b/spec/javascript/components/dropdown_menu.test.js index 54cdcabf0..a5af730ef 100644 --- a/spec/javascript/components/dropdown_menu.test.js +++ b/spec/javascript/components/dropdown_menu.test.js @@ -5,16 +5,24 @@ import React from 'react'; import DropdownMenu from '../../../app/javascript/mastodon/components/dropdown_menu'; import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; +const isTrue = () => true; + describe('', () => { const icon = 'my-icon'; const size = 123; - const action = sinon.spy(); + let items; + let wrapper; + let action; - const items = [ - { text: 'first item', action: action, href: '/some/url' }, - { text: 'second item', action: 'noop' }, - ]; - const wrapper = shallow(); + beforeEach(() => { + action = sinon.spy(); + + items = [ + { text: 'first item', action: action, href: '/some/url' }, + { text: 'second item', action: 'noop' }, + ]; + wrapper = shallow(); + }); it('contains one ', () => { expect(wrapper).to.have.exactly(1).descendants(Dropdown); @@ -28,6 +36,16 @@ describe('', () => { expect(wrapper.find(Dropdown)).to.have.exactly(1).descendants(DropdownContent); }); + it('does not contain a if isUserTouching', () => { + const touchingWrapper = shallow(); + expect(touchingWrapper.find(Dropdown)).to.have.exactly(0).descendants(DropdownContent); + }); + + it('does not contain a if isUserTouching', () => { + const touchingWrapper = shallow(); + expect(touchingWrapper.find(Dropdown)).to.have.exactly(0).descendants(DropdownContent); + }); + it('uses props.size for style values', () => { ['font-size', 'width', 'line-height'].map((property) => { expect(wrapper.find(DropdownTrigger)).to.have.style(property, `${size}px`); @@ -53,6 +71,23 @@ describe('', () => { expect(wrapper.state('expanded')).to.be.equal(true); }); + it('calls onModalOpen when clicking the trigger if isUserTouching', () => { + const onModalOpen = sinon.spy(); + const touchingWrapper = mount(); + touchingWrapper.find(DropdownTrigger).first().simulate('click'); + expect(onModalOpen.calledOnce).to.be.equal(true); + expect(onModalOpen.args[0][0]).to.be.deep.equal({ status: 3.14, actions: items, onClick: touchingWrapper.node.handleClick }); + }); + + it('calls onModalClose when clicking an action if isUserTouching and isModalOpen', () => { + const onModalOpen = sinon.spy(); + const onModalClose = sinon.spy(); + const touchingWrapper = mount(); + touchingWrapper.find(DropdownTrigger).first().simulate('click'); + touchingWrapper.node.handleClick({ currentTarget: { getAttribute: () => '0' }, preventDefault: () => null }); + expect(onModalClose.calledOnce).to.be.equal(true); + }); + // Error: ReactWrapper::state() can only be called on the root /*it('sets expanded to false when clicking outside', () => { const wrapper = mount((