Add confirmation modal when closing media edit modal with unsaved changes (#16518)
* 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
This commit is contained in:
		
							parent
							
								
									af08229ff4
								
							
						
					
					
						commit
						a8a7066e97
					
				| @ -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()); | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -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} | ||||
|                   /> | ||||
|                 </label> | ||||
| 
 | ||||
| @ -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 { | ||||
|             </div> | ||||
| 
 | ||||
|             <div className='setting-text__toolbar'> | ||||
|               <button disabled={detecting || media.get('type') !== 'image'} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button> | ||||
|               <button disabled={detecting || media.get('type') !== 'image' || is_changing_upload} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button> | ||||
|               <CharacterCounter max={1500} text={detecting ? '' : description} /> | ||||
|             </div> | ||||
| 
 | ||||
|             <Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} /> | ||||
|             <Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500 || is_changing_upload} text={intl.formatMessage(is_changing_upload ? messages.applying : messages.apply)} onClick={this.handleSubmit} /> | ||||
|           </div> | ||||
| 
 | ||||
|           <div className='focal-point-modal__content'> | ||||
|  | ||||
| @ -77,16 +77,33 @@ export default class ModalRoot extends React.PureComponent { | ||||
|     return <BundleModalError {...props} onClose={onClose} />; | ||||
|   } | ||||
| 
 | ||||
|   handleClose = () => { | ||||
|     const { onClose } = this.props; | ||||
|     let message = null; | ||||
|     try { | ||||
|       message = this._modal?.getWrappedInstance?.().getCloseConfirmationMessage?.(); | ||||
|     } catch (_) { | ||||
|       // injectIntl defines `getWrappedInstance` but errors out if `withRef`
 | ||||
|       // isn't set.
 | ||||
|       // This would be much smoother with react-intl 3+ and `forwardRef`.
 | ||||
|     } | ||||
|     onClose(message); | ||||
|   } | ||||
| 
 | ||||
|   setModalRef = (c) => { | ||||
|     this._modal = c; | ||||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { type, props, onClose } = this.props; | ||||
|     const { type, props } = this.props; | ||||
|     const { backgroundColor } = this.state; | ||||
|     const visible = !!type; | ||||
| 
 | ||||
|     return ( | ||||
|       <Base backgroundColor={backgroundColor} onClose={onClose}> | ||||
|       <Base backgroundColor={backgroundColor} onClose={this.handleClose}> | ||||
|         {visible && ( | ||||
|           <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}> | ||||
|             {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={onClose} />} | ||||
|             {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />} | ||||
|           </BundleContainer> | ||||
|         )} | ||||
|       </Base> | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import { connect } from 'react-redux'; | ||||
| import { closeModal } from '../../../actions/modal'; | ||||
| import { openModal, closeModal } from '../../../actions/modal'; | ||||
| import ModalRoot from '../components/modal_root'; | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
| @ -8,8 +8,18 @@ const mapStateToProps = state => ({ | ||||
| }); | ||||
| 
 | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
|   onClose () { | ||||
|     dispatch(closeModal()); | ||||
|   onClose (confirmationMessage) { | ||||
|     if (confirmationMessage) { | ||||
|       dispatch( | ||||
|         openModal('CONFIRM', { | ||||
|           message: confirmationMessage.message, | ||||
|           confirm: confirmationMessage.confirm, | ||||
|           onConfirm: () => dispatch(closeModal()), | ||||
|         }), | ||||
|       ); | ||||
|     } else { | ||||
|       dispatch(closeModal()); | ||||
|     } | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
|  | ||||
| @ -39,6 +39,9 @@ import { | ||||
|   COMPOSE_POLL_OPTION_CHANGE, | ||||
|   COMPOSE_POLL_OPTION_REMOVE, | ||||
|   COMPOSE_POLL_SETTINGS_CHANGE, | ||||
|   INIT_MEDIA_EDIT_MODAL, | ||||
|   COMPOSE_CHANGE_MEDIA_DESCRIPTION, | ||||
|   COMPOSE_CHANGE_MEDIA_FOCUS, | ||||
| } from '../actions/compose'; | ||||
| import { TIMELINE_DELETE } from '../actions/timelines'; | ||||
| import { STORE_HYDRATE } from '../actions/store'; | ||||
| @ -76,6 +79,13 @@ const initialState = ImmutableMap({ | ||||
|   resetFileKey: Math.floor((Math.random() * 0x10000)), | ||||
|   idempotencyKey: null, | ||||
|   tagHistory: ImmutableList(), | ||||
|   media_modal: ImmutableMap({ | ||||
|     id: null, | ||||
|     description: '', | ||||
|     focusX: 0, | ||||
|     focusY: 0, | ||||
|     dirty: false, | ||||
|   }), | ||||
| }); | ||||
| 
 | ||||
| const initialPoll = ImmutableMap({ | ||||
| @ -354,6 +364,19 @@ export default function compose(state = initialState, action) { | ||||
| 
 | ||||
|         return item; | ||||
|       })); | ||||
|   case INIT_MEDIA_EDIT_MODAL: | ||||
|     const media =  state.get('media_attachments').find(item => item.get('id') === action.id); | ||||
|     return state.set('media_modal', ImmutableMap({ | ||||
|       id: action.id, | ||||
|       description: media.get('description') || '', | ||||
|       focusX: media.getIn(['meta', 'focus', 'x'], 0), | ||||
|       focusY: media.getIn(['meta', 'focus', 'y'], 0), | ||||
|       dirty: false, | ||||
|     })); | ||||
|   case COMPOSE_CHANGE_MEDIA_DESCRIPTION: | ||||
|     return state.setIn(['media_modal', 'description'], action.description).setIn(['media_modal', 'dirty'], true); | ||||
|   case COMPOSE_CHANGE_MEDIA_FOCUS: | ||||
|     return state.setIn(['media_modal', 'focusX'], action.focusX).setIn(['media_modal', 'focusY'], action.focusY).setIn(['media_modal', 'dirty'], true); | ||||
|   case COMPOSE_MENTION: | ||||
|     return state.withMutations(map => { | ||||
|       map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); | ||||
| @ -390,6 +413,7 @@ export default function compose(state = initialState, action) { | ||||
|   case COMPOSE_UPLOAD_CHANGE_SUCCESS: | ||||
|     return state | ||||
|       .set('is_changing_upload', false) | ||||
|       .setIn(['media_modal', 'dirty'], false) | ||||
|       .update('media_attachments', list => list.map(item => { | ||||
|         if (item.get('id') === action.media.id) { | ||||
|           return fromJS(action.media); | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal'; | ||||
| import { TIMELINE_DELETE } from '../actions/timelines'; | ||||
| import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose'; | ||||
| import { Stack as ImmutableStack, Map as ImmutableMap } from 'immutable'; | ||||
| 
 | ||||
| export default function modal(state = ImmutableStack(), action) { | ||||
| @ -8,6 +9,8 @@ export default function modal(state = ImmutableStack(), action) { | ||||
|     return state.unshift(ImmutableMap({ modalType: action.modalType, modalProps: action.modalProps })); | ||||
|   case MODAL_CLOSE: | ||||
|     return (action.modalType === undefined || action.modalType === state.getIn([0, 'modalType'])) ? state.shift() : state; | ||||
|   case COMPOSE_UPLOAD_CHANGE_SUCCESS: | ||||
|     return state.getIn([0, 'modalType']) === 'FOCAL_POINT' ? state.shift() : state; | ||||
|   case TIMELINE_DELETE: | ||||
|     return state.filterNot((modal) => modal.get('modalProps').statusId === action.id); | ||||
|   default: | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user