From a8a7066e977cb0aa1988d340ef8b7c542f179b14 Mon Sep 17 00:00:00 2001 From: Claire Date: Sun, 25 Jul 2021 01:14:43 +0200 Subject: [PATCH] Add confirmation modal when closing media edit modal with unsaved changes (#16518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add confirmation modal when closing media edit modal with unsaved changes * Move focal point media state to redux so it does not get erased by confirmation dialog * Change upload modal behavior to keep it open while saving changes Instead of closing it immediately and losing changes if they fail to save… * Make it work with react-intl 2.9 --- app/javascript/mastodon/actions/compose.js | 32 ++++++ .../compose/containers/upload_container.js | 5 +- .../ui/components/focal_point_modal.js | 108 ++++++++---------- .../features/ui/components/modal_root.js | 23 +++- .../features/ui/containers/modal_container.js | 16 ++- app/javascript/mastodon/reducers/compose.js | 24 ++++ app/javascript/mastodon/reducers/modal.js | 3 + 7 files changed, 142 insertions(+), 69 deletions(-) diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 891403969..39d31a88f 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -9,6 +9,7 @@ import { importFetchedAccounts } from './importer'; import { updateTimeline } from './timelines'; import { showAlertForError } from './alerts'; import { showAlert } from './alerts'; +import { openModal } from './modal'; import { defineMessages } from 'react-intl'; let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags; @@ -63,6 +64,11 @@ export const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE'; export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE'; export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE'; +export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL'; + +export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION'; +export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS'; + const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, @@ -306,6 +312,32 @@ export const uploadThumbnailFail = error => ({ skipLoading: true, }); +export function initMediaEditModal(id) { + return dispatch => { + dispatch({ + type: INIT_MEDIA_EDIT_MODAL, + id, + }); + + dispatch(openModal('FOCAL_POINT', { id })); + }; +}; + +export function onChangeMediaDescription(description) { + return { + type: COMPOSE_CHANGE_MEDIA_DESCRIPTION, + description, + }; +}; + +export function onChangeMediaFocus(focusX, focusY) { + return { + type: COMPOSE_CHANGE_MEDIA_FOCUS, + focusX, + focusY, + }; +}; + export function changeUploadCompose(id, params) { return (dispatch, getState) => { dispatch(changeUploadComposeRequest()); diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js index 342b0c2a9..05cd2ecc1 100644 --- a/app/javascript/mastodon/features/compose/containers/upload_container.js +++ b/app/javascript/mastodon/features/compose/containers/upload_container.js @@ -1,7 +1,6 @@ import { connect } from 'react-redux'; import Upload from '../components/upload'; -import { undoUploadCompose } from '../../../actions/compose'; -import { openModal } from '../../../actions/modal'; +import { undoUploadCompose, initMediaEditModal } from '../../../actions/compose'; import { submitCompose } from '../../../actions/compose'; const mapStateToProps = (state, { id }) => ({ @@ -15,7 +14,7 @@ const mapDispatchToProps = dispatch => ({ }, onOpenFocalPoint: id => { - dispatch(openModal('FOCAL_POINT', { id })); + dispatch(initMediaEditModal(id)); }, onSubmit (router) { diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js index edeb281e9..a2e6b3d16 100644 --- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; import classNames from 'classnames'; -import { changeUploadCompose, uploadThumbnail } from '../../../actions/compose'; +import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from '../../../actions/compose'; import { getPointerPosition } from '../../video'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import IconButton from 'mastodon/components/icon_button'; @@ -27,14 +27,22 @@ import { assetHost } from 'mastodon/utils/config'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' }, + applying: { id: 'upload_modal.applying', defaultMessage: 'Applying…' }, placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' }, chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' }, + discardMessage: { id: 'confirmations.discard_edit_media.message', defaultMessage: 'You have unsaved changes to the media description or preview, discard them anyway?' }, + discardConfirm: { id: 'confirmations.discard_edit_media.confirm', defaultMessage: 'Discard' }, }); const mapStateToProps = (state, { id }) => ({ media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), account: state.getIn(['accounts', me]), isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']), + description: state.getIn(['compose', 'media_modal', 'description']), + focusX: state.getIn(['compose', 'media_modal', 'focusX']), + focusY: state.getIn(['compose', 'media_modal', 'focusY']), + dirty: state.getIn(['compose', 'media_modal', 'dirty']), + is_changing_upload: state.getIn(['compose', 'is_changing_upload']), }); const mapDispatchToProps = (dispatch, { id }) => ({ @@ -43,6 +51,14 @@ const mapDispatchToProps = (dispatch, { id }) => ({ dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` })); }, + onChangeDescription: (description) => { + dispatch(onChangeMediaDescription(description)); + }, + + onChangeFocus: (focusX, focusY) => { + dispatch(onChangeMediaFocus(focusX, focusY)); + }, + onSelectThumbnail: files => { dispatch(uploadThumbnail(id, files[0])); }, @@ -83,8 +99,8 @@ class ImageLoader extends React.PureComponent { } -export default @connect(mapStateToProps, mapDispatchToProps) -@injectIntl +export default @connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true }) +@(component => injectIntl(component, { withRef: true })) class FocalPointModal extends ImmutablePureComponent { static propTypes = { @@ -92,34 +108,21 @@ class FocalPointModal extends ImmutablePureComponent { account: ImmutablePropTypes.map.isRequired, isUploadingThumbnail: PropTypes.bool, onSave: PropTypes.func.isRequired, + onChangeDescription: PropTypes.func.isRequired, + onChangeFocus: PropTypes.func.isRequired, onSelectThumbnail: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; state = { - x: 0, - y: 0, - focusX: 0, - focusY: 0, dragging: false, - description: '', dirty: false, progress: 0, loading: true, ocrStatus: '', }; - componentWillMount () { - this.updatePositionFromMedia(this.props.media); - } - - componentWillReceiveProps (nextProps) { - if (this.props.media.get('id') !== nextProps.media.get('id')) { - this.updatePositionFromMedia(nextProps.media); - } - } - componentWillUnmount () { document.removeEventListener('mousemove', this.handleMouseMove); document.removeEventListener('mouseup', this.handleMouseUp); @@ -164,54 +167,37 @@ class FocalPointModal extends ImmutablePureComponent { const focusX = (x - .5) * 2; const focusY = (y - .5) * -2; - this.setState({ x, y, focusX, focusY, dirty: true }); - } - - updatePositionFromMedia = media => { - const focusX = media.getIn(['meta', 'focus', 'x']); - const focusY = media.getIn(['meta', 'focus', 'y']); - const description = media.get('description') || ''; - - if (focusX && focusY) { - const x = (focusX / 2) + .5; - const y = (focusY / -2) + .5; - - this.setState({ - x, - y, - focusX, - focusY, - description, - dirty: false, - }); - } else { - this.setState({ - x: 0.5, - y: 0.5, - focusX: 0, - focusY: 0, - description, - dirty: false, - }); - } + this.props.onChangeFocus(focusX, focusY); } handleChange = e => { - this.setState({ description: e.target.value, dirty: true }); + this.props.onChangeDescription(e.target.value); } handleKeyDown = (e) => { if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { e.preventDefault(); e.stopPropagation(); - this.setState({ description: e.target.value, dirty: true }); + this.props.onChangeDescription(e.target.value); this.handleSubmit(); } } handleSubmit = () => { - this.props.onSave(this.state.description, this.state.focusX, this.state.focusY); - this.props.onClose(); + this.props.onSave(this.props.description, this.props.focusX, this.props.focusY); + } + + getCloseConfirmationMessage = () => { + const { intl, dirty } = this.props; + + if (dirty) { + return { + message: intl.formatMessage(messages.discardMessage), + confirm: intl.formatMessage(messages.discardConfirm), + }; + } else { + return null; + } } setRef = c => { @@ -257,7 +243,8 @@ class FocalPointModal extends ImmutablePureComponent { await worker.loadLanguage('eng'); await worker.initialize('eng'); const { data: { text } } = await worker.recognize(media_url); - this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false }); + this.setState({ detecting: false }); + this.props.onChangeDescription(removeExtraLineBreaks(text)); await worker.terminate(); })().catch((e) => { if (refreshCache) { @@ -274,7 +261,6 @@ class FocalPointModal extends ImmutablePureComponent { handleThumbnailChange = e => { if (e.target.files.length > 0) { - this.setState({ dirty: true }); this.props.onSelectThumbnail(e.target.files); } } @@ -288,8 +274,10 @@ class FocalPointModal extends ImmutablePureComponent { } render () { - const { media, intl, account, onClose, isUploadingThumbnail } = this.props; - const { x, y, dragging, description, dirty, detecting, progress, ocrStatus } = this.state; + const { media, intl, account, onClose, isUploadingThumbnail, description, focusX, focusY, dirty, is_changing_upload } = this.props; + const { dragging, detecting, progress, ocrStatus } = this.state; + const x = (focusX / 2) + .5; + const y = (focusY / -2) + .5; const width = media.getIn(['meta', 'original', 'width']) || null; const height = media.getIn(['meta', 'original', 'height']) || null; @@ -344,7 +332,7 @@ class FocalPointModal extends ImmutablePureComponent { accept='image/png,image/jpeg' onChange={this.handleThumbnailChange} style={{ display: 'none' }} - disabled={isUploadingThumbnail} + disabled={isUploadingThumbnail || is_changing_upload} /> @@ -363,7 +351,7 @@ class FocalPointModal extends ImmutablePureComponent { value={detecting ? '…' : description} onChange={this.handleChange} onKeyDown={this.handleKeyDown} - disabled={detecting} + disabled={detecting || is_changing_upload} autoFocus /> @@ -373,11 +361,11 @@ class FocalPointModal extends ImmutablePureComponent {
- +
-