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 && }
+
+
+
+ );
+ }
+
+ 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((