logo

mastofe

My custom branche(s) on git.pleroma.social/pleroma/mastofe
commit: 50d38d7605b8998463b1428b8da886f33e0714da
parent: aa803153e2161f4462d9d26ecd021fe0d2396cc5
Author: Sorin Davidoi <sorin.davidoi@gmail.com>
Date:   Thu, 27 Jul 2017 22:31:59 +0200

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

Diffstat:

Mapp/javascript/mastodon/components/dropdown_menu.js39++++++++++++++++++++++++++++++++++-----
Mapp/javascript/mastodon/components/status_action_bar.js4++--
Aapp/javascript/mastodon/containers/dropdown_menu_container.js16++++++++++++++++
Mapp/javascript/mastodon/features/account/components/action_bar.js4++--
Mapp/javascript/mastodon/features/compose/components/privacy_dropdown.js50++++++++++++++++++++++++++++++++++++++------------
Mapp/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js7+++++++
Mapp/javascript/mastodon/features/status/components/action_bar.js4++--
Aapp/javascript/mastodon/features/ui/components/actions_modal.js72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/javascript/mastodon/features/ui/components/modal_root.js2++
Mapp/javascript/mastodon/is_mobile.js9+++++++++
Mapp/javascript/styles/components.scss62+++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mspec/javascript/components/dropdown_menu.test.js49++++++++++++++++++++++++++++++++++++++++++-------
12 files changed, 277 insertions(+), 41 deletions(-)

diff --git 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 { </ul> ); + // No need to render the actual dropdown if we use the modal. If we + // don't render anything <Dropdow /> breaks, so we just put an empty div. + const dropdownContent = !isUserTouching ? ( + <DropdownContent className={directionClass}> + {dropdownItems} + </DropdownContent> + ) : <div />; + return ( - <Dropdown ref={this.setRef} onShow={this.handleShow} onHide={this.handleHide}> + <Dropdown ref={this.setRef} active={isUserTouching ? false : undefined} onShow={this.handleShow} onHide={this.handleHide}> <DropdownTrigger className='icon-button' style={iconStyle} aria-label={ariaLabel}> <i className={iconClassname} aria-hidden /> </DropdownTrigger> - <DropdownContent className={directionClass}> - {dropdownItems} - </DropdownContent> + {dropdownContent} </Dropdown> ); } diff --git 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} <div className='status__action-bar-dropdown'> - <DropdownMenu disabled={anonymousAccess} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' /> + <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' /> </div> </div> ); diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js 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 @@ -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 { <div className='account__action-bar'> <div className='account__action-bar-dropdown'> - <DropdownMenu items={menu} icon='bars' size={24} direction='right' /> + <DropdownMenuContainer items={menu} icon='bars' size={24} direction='right' /> </div> <div className='account__action-bar-links'> diff --git 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 ( <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}> <div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div> <div className='privacy-dropdown__dropdown'> - {open && options.map(item => + {open && this.options.map(item => <div role='button' tabIndex='0' key={item.value} data-index={item.value} onClick={this.handleClick} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}> <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div> <div className='privacy-dropdown__option__content'> - <strong>{item.shortText}</strong> - {item.longText} + <strong>{item.text}</strong> + {item.meta} </div> </div> )} diff --git 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 @@ -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 { <div className='detailed-status__button'><IconButton animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> <div className='detailed-status__action-bar-dropdown'> - <DropdownMenu size={18} icon='ellipsis-h' items={menu} direction='left' ariaLabel='More' /> + <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' ariaLabel='More' /> </div> </div> ); diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.js 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 <li key={`sep-${i}`} className='dropdown__sep' />; + } + + const { icon = null, text, meta = null, active = false, href = '#' } = action; + + return ( + <li key={`${text}-${i}`}> + <a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={active && 'active'}> + {icon && <IconButton title={text} icon={icon} />} + <div> + <div>{text}</div> + <div>{meta}</div> + </div> + </a> + </li> + ); + } + + render () { + const status = this.props.status && ( + <div className='status light'> + <div className='boost-modal__status-header'> + <div className='boost-modal__status-time'> + <a href={this.props.status.get('url')} className='status__relative-time' target='_blank' rel='noopener'> + <RelativeTimestamp timestamp={this.props.status.get('created_at')} /> + </a> + </div> + + <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name'> + <div className='status__avatar'> + <Avatar src={this.props.status.getIn(['account', 'avatar'])} staticSrc={this.props.status.getIn(['account', 'avatar_static'])} size={48} /> + </div> + + <DisplayName account={this.props.status.get('account')} /> + </a> + </div> + + <StatusContent status={this.props.status} /> + </div> + ); + + return ( + <div className='modal-root__modal actions-modal'> + {status} + + <ul> + {this.props.actions.map(this.renderAction)} + </ul> + </div> + ); + } + +} diff --git 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 @@ -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 @@ -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 @@ -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('<DropdownMenu />', () => { const icon = 'my-icon'; const size = 123; - const action = sinon.spy(); - - const items = [ - { text: 'first item', action: action, href: '/some/url' }, - { text: 'second item', action: 'noop' }, - ]; - const wrapper = shallow(<DropdownMenu icon={icon} items={items} size={size} />); + let items; + let wrapper; + let action; + + beforeEach(() => { + action = sinon.spy(); + + items = [ + { text: 'first item', action: action, href: '/some/url' }, + { text: 'second item', action: 'noop' }, + ]; + wrapper = shallow(<DropdownMenu icon={icon} items={items} size={size} />); + }); it('contains one <Dropdown />', () => { expect(wrapper).to.have.exactly(1).descendants(Dropdown); @@ -28,6 +36,16 @@ describe('<DropdownMenu />', () => { expect(wrapper.find(Dropdown)).to.have.exactly(1).descendants(DropdownContent); }); + it('does not contain a <DropdownContent /> if isUserTouching', () => { + const touchingWrapper = shallow(<DropdownMenu icon={icon} items={items} size={size} isUserTouching={isTrue} />); + expect(touchingWrapper.find(Dropdown)).to.have.exactly(0).descendants(DropdownContent); + }); + + it('does not contain a <DropdownContent /> if isUserTouching', () => { + const touchingWrapper = shallow(<DropdownMenu icon={icon} items={items} size={size} isUserTouching={isTrue} />); + expect(touchingWrapper.find(Dropdown)).to.have.exactly(0).descendants(DropdownContent); + }); + it('uses props.size for <DropdownTrigger /> style values', () => { ['font-size', 'width', 'line-height'].map((property) => { expect(wrapper.find(DropdownTrigger)).to.have.style(property, `${size}px`); @@ -53,6 +71,23 @@ describe('<DropdownMenu />', () => { expect(wrapper.state('expanded')).to.be.equal(true); }); + it('calls onModalOpen when clicking the trigger if isUserTouching', () => { + const onModalOpen = sinon.spy(); + const touchingWrapper = mount(<DropdownMenu icon={icon} items={items} status={3.14} size={size} onModalOpen={onModalOpen} isUserTouching={isTrue} />); + 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(<DropdownMenu icon={icon} items={items} status={3.14} size={size} isModalOpen onModalOpen={onModalOpen} onModalClose={onModalClose} isUserTouching={isTrue} />); + 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((