diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
index 2636e79f533..62bb167a059 100644
--- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
@@ -83,16 +83,33 @@ export default class ModalRoot extends React.PureComponent {
return
;
}
+ 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 (
-
+
{visible && (
- {(SpecificComponent) => }
+ {(SpecificComponent) => }
)}
diff --git a/app/javascript/flavours/glitch/features/ui/containers/modal_container.js b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js
index e13e745e6da..039aabd8ae9 100644
--- a/app/javascript/flavours/glitch/features/ui/containers/modal_container.js
+++ b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js
@@ -1,5 +1,5 @@
import { connect } from 'react-redux';
-import { closeModal } from 'flavours/glitch/actions/modal';
+import { openModal, closeModal } from 'flavours/glitch/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());
+ }
},
});
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index e989401d87c..1735cfb4d67 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -42,6 +42,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 'flavours/glitch/actions/compose';
import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
@@ -97,6 +100,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,
+ }),
doodle: ImmutableMap({
fg: 'rgb( 0, 0, 0)',
bg: 'rgb(255, 255, 255)',
@@ -455,6 +465,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(' '));
@@ -491,6 +514,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);
diff --git a/app/javascript/flavours/glitch/reducers/modal.js b/app/javascript/flavours/glitch/reducers/modal.js
index f8fdc29954c..ae205c6d549 100644
--- a/app/javascript/flavours/glitch/reducers/modal.js
+++ b/app/javascript/flavours/glitch/reducers/modal.js
@@ -1,5 +1,6 @@
import { MODAL_OPEN, MODAL_CLOSE } from 'flavours/glitch/actions/modal';
import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
+import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from 'flavours/glitch/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:
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index a60373fd520..c844a907fd8 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.' },
@@ -308,6 +314,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/components/status.js b/app/javascript/mastodon/components/status.js
index 295e83f5819..ccc9067d124 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -309,8 +309,8 @@ class Status extends ImmutablePureComponent {
return (
- {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
- {status.get('content')}
+ {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
+ {status.get('content')}
);
diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js
index 342b0c2a9ca..05cd2ecc1fe 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/picture_in_picture/components/footer.js b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
index 1ecb18bf820..f5ce50ac81c 100644
--- a/app/javascript/mastodon/features/picture_in_picture/components/footer.js
+++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
@@ -114,7 +114,11 @@ class Footer extends ImmutablePureComponent {
return;
}
- const { status } = this.props;
+ const { status, onClose } = this.props;
+
+ if (onClose) {
+ onClose();
+ }
router.history.push(`/statuses/${status.get('id')}`);
}
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 edeb281e96d..a2e6b3d16b2 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 {