From d9123e21ba682b0cad3f17fcd13d44ff2ef566a2 Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 21 Mar 2023 12:14:31 +0900 Subject: [PATCH] Add choose expiration form --- app/javascript/mastodon/actions/compose.js | 9 + .../compose/components/compose_form.jsx | 10 + .../components/expiration_dropdown.jsx | 279 ++++++++++++++++++ .../containers/compose_form_container.js | 5 + .../expiration_dropdown_container.js | 25 ++ app/javascript/mastodon/reducers/compose.js | 14 + .../update_status_expiration_service.rb | 4 +- 7 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 app/javascript/mastodon/features/compose/components/expiration_dropdown.jsx create mode 100644 app/javascript/mastodon/features/compose/containers/expiration_dropdown_container.js 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 => ( +
+
+ {item.text} +
+
+ ))} +
+ ); + } + +} + +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