diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 3756a975b7..6cf44df086 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -57,6 +57,7 @@ export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
+export const COMPOSE_EXPIRATION_INSERT = 'COMPOSE_EXPIRATION_INSERT';
export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
@@ -742,6 +743,14 @@ export function insertEmojiCompose(position, emoji, needsSpace) {
};
}
+export function insertExpirationCompose(position, data) {
+ return {
+ type: COMPOSE_EXPIRATION_INSERT,
+ position,
+ data,
+ };
+}
+
export function changeComposing(value) {
return {
type: COMPOSE_COMPOSING_CHANGE,
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx
index d5da8141c6..edd0c0257b 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.jsx
+++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx
@@ -11,6 +11,7 @@ import UploadButtonContainer from '../containers/upload_button_container';
import { defineMessages, injectIntl } from 'react-intl';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
+import ExpirationDropdownContainer from '../containers/expiration_dropdown_container';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import PollFormContainer from '../containers/poll_form_container';
import UploadFormContainer from '../containers/upload_form_container';
@@ -60,6 +61,7 @@ class ComposeForm extends ImmutablePureComponent {
onChangeSpoilerText: PropTypes.func.isRequired,
onPaste: PropTypes.func.isRequired,
onPickEmoji: PropTypes.func.isRequired,
+ onPickExpiration: PropTypes.func.isRequired,
autoFocus: PropTypes.bool,
anyMedia: PropTypes.bool,
isInReply: PropTypes.bool,
@@ -206,6 +208,13 @@ class ComposeForm extends ImmutablePureComponent {
this.props.onPickEmoji(position, data, needsSpace);
};
+ handleExpirationPick = (data) => {
+ const { text } = this.props;
+ const position = this.autosuggestTextarea.textarea.selectionStart;
+
+ this.props.onPickExpiration(position, data);
+ };
+
render () {
const { intl, onPaste, autoFocus } = this.props;
const disabled = this.props.isSubmitting;
@@ -277,6 +286,7 @@ class ComposeForm extends ImmutablePureComponent {
+
diff --git a/app/javascript/mastodon/features/compose/components/expiration_dropdown.jsx b/app/javascript/mastodon/features/compose/components/expiration_dropdown.jsx
new file mode 100644
index 0000000000..470d5deae5
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/expiration_dropdown.jsx
@@ -0,0 +1,279 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import Overlay from 'react-overlays/Overlay';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import classNames from 'classnames';
+import Icon from 'mastodon/components/icon';
+
+const messages = defineMessages({
+ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+ public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all' },
+ unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+ unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' },
+ public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' },
+ public_unlisted_long: { id: 'privacy.public_unlisted.long', defaultMessage: 'Visible for all without GTL' },
+ private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
+ private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
+ direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
+ direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
+ change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
+ add_expiration: { id: 'status.expiration.add', defaultMessage: 'Set status expiration' },
+});
+
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
+
+class ExpirationDropdownMenu extends React.PureComponent {
+
+ static propTypes = {
+ style: PropTypes.object,
+ items: PropTypes.array.isRequired,
+ value: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ };
+
+ handleDocumentClick = e => {
+ if (this.node && !this.node.contains(e.target)) {
+ this.props.onClose();
+ }
+ };
+
+ handleKeyDown = e => {
+ const { items } = this.props;
+ const value = e.currentTarget.getAttribute('data-index');
+ const index = items.findIndex(item => {
+ return (item.value === value);
+ });
+ let element = null;
+
+ switch(e.key) {
+ case 'Escape':
+ this.props.onClose();
+ break;
+ case 'Enter':
+ this.handleClick(e);
+ break;
+ case 'ArrowDown':
+ element = this.node.childNodes[index + 1] || this.node.firstChild;
+ break;
+ case 'ArrowUp':
+ element = this.node.childNodes[index - 1] || this.node.lastChild;
+ break;
+ case 'Tab':
+ if (e.shiftKey) {
+ element = this.node.childNodes[index - 1] || this.node.lastChild;
+ } else {
+ element = this.node.childNodes[index + 1] || this.node.firstChild;
+ }
+ break;
+ case 'Home':
+ element = this.node.firstChild;
+ break;
+ case 'End':
+ element = this.node.lastChild;
+ break;
+ }
+
+ if (element) {
+ element.focus();
+ this.props.onChange(element.getAttribute('data-index'));
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ };
+
+ handleClick = e => {
+ const value = e.currentTarget.getAttribute('data-index');
+
+ e.preventDefault();
+
+ this.props.onClose();
+ this.props.onChange(value);
+ };
+
+ componentDidMount () {
+ document.addEventListener('click', this.handleDocumentClick, false);
+ document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ };
+
+ setFocusRef = c => {
+ this.focusedItem = c;
+ };
+
+ render () {
+ const { style, items, value } = this.props;
+
+ return (
+
+ {items.map(item => (
+
+ ))}
+
+ );
+ }
+
+}
+
+export default @injectIntl
+class ExpirationDropdown extends React.PureComponent {
+
+ static propTypes = {
+ isUserTouching: PropTypes.func,
+ onModalOpen: PropTypes.func,
+ onModalClose: PropTypes.func,
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ noDirect: PropTypes.bool,
+ container: PropTypes.func,
+ disabled: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ open: false,
+ placement: 'bottom',
+ };
+
+ handleToggle = () => {
+ if (this.props.isUserTouching && 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 {
+ if (this.state.open && this.activeElement) {
+ this.activeElement.focus({ preventScroll: true });
+ }
+ 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);
+ };
+
+ handleKeyDown = e => {
+ switch(e.key) {
+ case 'Escape':
+ this.handleClose();
+ break;
+ }
+ };
+
+ handleMouseDown = () => {
+ if (!this.state.open) {
+ this.activeElement = document.activeElement;
+ }
+ };
+
+ handleButtonKeyDown = (e) => {
+ switch(e.key) {
+ case ' ':
+ case 'Enter':
+ this.handleMouseDown();
+ break;
+ }
+ };
+
+ handleClose = () => {
+ if (this.state.open && this.activeElement) {
+ this.activeElement.focus({ preventScroll: true });
+ }
+ this.setState({ open: false });
+ };
+
+ handleChange = value => {
+ this.props.onChange(value);
+ };
+
+ componentWillMount () {
+ this.options = [
+ { value: '#exp5m', text: '#exp5m (5 minutes)' },
+ { value: '#exp30m', text: '#exp30m (30 minutes)' },
+ { value: '#exp1h', text: '#exp1h (1 hour)' },
+ { value: '#exp3h', text: '#exp3h (3 hours)' },
+ { value: '#exp12h', text: '#exp12h (12 hours)' },
+ { value: '#exp1d', text: '#exp1d (1 day)' },
+ { value: '#exp7d', text: '#exp7d (7 days)' },
+ ];
+ }
+
+ setTargetRef = c => {
+ this.target = c;
+ };
+
+ findTarget = () => {
+ return this.target;
+ };
+
+ handleOverlayEnter = (state) => {
+ this.setState({ placement: state.placement });
+ };
+
+ render () {
+ const { value, container, disabled, intl } = this.props;
+ const { open, placement } = this.state;
+
+ return (
+
+
+
+
+
+
+ {({ props, placement }) => (
+
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
index 2b76422376..132546dbc7 100644
--- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
@@ -8,6 +8,7 @@ import {
selectComposeSuggestion,
changeComposeSpoilerText,
insertEmojiCompose,
+ insertExpirationCompose,
uploadCompose,
} from '../../../actions/compose';
@@ -63,6 +64,10 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(insertEmojiCompose(position, data, needsSpace));
},
+ onPickExpiration (position, data) {
+ dispatch(insertExpirationCompose(position, data));
+ },
+
});
export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/javascript/mastodon/features/compose/containers/expiration_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/expiration_dropdown_container.js
new file mode 100644
index 0000000000..2243aa70be
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/expiration_dropdown_container.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import ExpirationDropdown from '../components/expiration_dropdown';
+import { changeComposeVisibility } from '../../../actions/compose';
+import { openModal, closeModal } from '../../../actions/modal';
+import { isUserTouching } from '../../../is_mobile';
+
+const mapStateToProps = state => ({
+ value: state.getIn(['compose', 'privacy']),
+});
+
+const mapDispatchToProps = (dispatch, { onPickExpiration }) => ({
+
+ onChange (value) {
+ if (onPickExpiration) {
+ onPickExpiration(value);
+ }
+ },
+
+ isUserTouching,
+ onModalOpen: props => dispatch(openModal('ACTIONS', props)),
+ onModalClose: () => dispatch(closeModal()),
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ExpirationDropdown);
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 9cd81a7bf0..1483f1c2e4 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -32,6 +32,7 @@ import {
COMPOSE_LANGUAGE_CHANGE,
COMPOSE_COMPOSING_CHANGE,
COMPOSE_EMOJI_INSERT,
+ COMPOSE_EXPIRATION_INSERT,
COMPOSE_UPLOAD_CHANGE_REQUEST,
COMPOSE_UPLOAD_CHANGE_SUCCESS,
COMPOSE_UPLOAD_CHANGE_FAIL,
@@ -217,6 +218,17 @@ const insertEmoji = (state, position, emojiData, needsSpace) => {
});
};
+const insertExpiration = (state, position, data) => {
+ const oldText = state.get('text');
+
+ return state.merge({
+ text: `${oldText.slice(0, position)} ${data} ${oldText.slice(position)}`,
+ focusDate: new Date(),
+ caretPosition: position + data.length + 1,
+ idempotencyKey: uuid(),
+ });
+};
+
const privacyPreference = (a, b) => {
const order = ['public', 'public_unlisted', 'unlisted', 'private', 'direct'];
return order[Math.max(order.indexOf(a), order.indexOf(b), 0)];
@@ -443,6 +455,8 @@ export default function compose(state = initialState, action) {
}
case COMPOSE_EMOJI_INSERT:
return insertEmoji(state, action.position, action.emoji, action.needsSpace);
+ case COMPOSE_EXPIRATION_INSERT:
+ return insertExpiration(state, action.position, action.data);
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
return state
.set('is_changing_upload', false)
diff --git a/app/services/update_status_expiration_service.rb b/app/services/update_status_expiration_service.rb
index ab0f2735f0..374999f2e8 100644
--- a/app/services/update_status_expiration_service.rb
+++ b/app/services/update_status_expiration_service.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class UpdateStatusExpirationService < BaseService
- SCAN_EXPIRATION_RE = /#exp((\d.\d|\d)+)([dms]+)/
+ SCAN_EXPIRATION_RE = /#exp((\d.\d|\d)+)([dhms]+)/
def call(status)
existing_expiration = ScheduledExpirationStatus.find_by(status: status)
@@ -13,7 +13,7 @@ class UpdateStatusExpirationService < BaseService
expiration_num = expiration[0].to_f
expiration_option = expiration[1]
- expired_at = Time.now.utc + (expiration_option == 'd' ? expiration_num.days : expiration_option == 's' ? expiration_num.seconds : expiration_num.minutes)
+ expired_at = Time.now.utc + (expiration_option == 'd' ? expiration_num.days : expiration_option == 'h' ? expiration_num.hours : expiration_option == 's' ? expiration_num.seconds : expiration_num.minutes)
ScheduledExpirationStatus.create!(account: status.account, status: status, scheduled_at: expired_at)
end
end