From 8e7e86ee354fca36b989b6de40904f38e6db103b Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Mon, 25 Mar 2024 11:29:55 +0100
Subject: [PATCH] Add ability to reorder uploaded media before posting in web
 UI (#28456)

---
 app/javascript/mastodon/actions/compose.js    |   7 ++
 .../compose/components/compose_form.jsx       |   4 +-
 .../features/compose/components/upload.jsx    | 114 +++++++++---------
 .../compose/components/upload_form.jsx        |  70 +++++++----
 .../compose/components/upload_progress.jsx    |  77 ++++++------
 .../compose/containers/upload_container.js    |  27 -----
 .../containers/upload_form_container.js       |   9 --
 .../containers/upload_progress_container.js   |  11 --
 .../ui/components/focal_point_modal.jsx       |   2 +-
 app/javascript/mastodon/reducers/compose.js   |   9 ++
 10 files changed, 159 insertions(+), 171 deletions(-)
 delete mode 100644 app/javascript/mastodon/features/compose/containers/upload_container.js
 delete mode 100644 app/javascript/mastodon/features/compose/containers/upload_form_container.js
 delete mode 100644 app/javascript/mastodon/features/compose/containers/upload_progress_container.js

diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 12bd43f807..7477e45e5e 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -75,6 +75,7 @@ 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';
+export const COMPOSE_CHANGE_MEDIA_ORDER       = 'COMPOSE_CHANGE_MEDIA_ORDER';
 
 export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
 export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';
@@ -809,3 +810,9 @@ export function changePollSettings(expiresIn, isMultiple) {
     isMultiple,
   };
 }
+
+export const changeMediaOrder = (a, b) => ({
+  type: COMPOSE_CHANGE_MEDIA_ORDER,
+  a,
+  b,
+});
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx
index b93bac9d19..9b4d3dfeb5 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.jsx
+++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx
@@ -21,7 +21,6 @@ import PollButtonContainer from '../containers/poll_button_container';
 import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
 import SpoilerButtonContainer from '../containers/spoiler_button_container';
 import UploadButtonContainer from '../containers/upload_button_container';
-import UploadFormContainer from '../containers/upload_form_container';
 import WarningContainer from '../containers/warning_container';
 import { countableText } from '../util/counter';
 
@@ -30,6 +29,7 @@ import { EditIndicator } from './edit_indicator';
 import { NavigationBar } from './navigation_bar';
 import { PollForm } from "./poll_form";
 import { ReplyIndicator } from './reply_indicator';
+import { UploadForm } from './upload_form';
 
 const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
 
@@ -283,7 +283,7 @@ class ComposeForm extends ImmutablePureComponent {
             />
           </div>
 
-          <UploadFormContainer />
+          <UploadForm />
           <PollForm />
 
           <div className='compose-form__footer'>
diff --git a/app/javascript/mastodon/features/compose/components/upload.jsx b/app/javascript/mastodon/features/compose/components/upload.jsx
index e8045ae81f..7f6ef6cfd8 100644
--- a/app/javascript/mastodon/features/compose/components/upload.jsx
+++ b/app/javascript/mastodon/features/compose/components/upload.jsx
@@ -1,77 +1,81 @@
 import PropTypes from 'prop-types';
+import { useCallback } from 'react';
 
 import { FormattedMessage } from 'react-intl';
 
 import classNames from 'classnames';
 
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
+import { useDispatch, useSelector } from 'react-redux';
 
 import spring from 'react-motion/lib/spring';
 
 import CloseIcon from '@/material-icons/400-20px/close.svg?react';
 import EditIcon from '@/material-icons/400-24px/edit.svg?react';
 import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
+import { undoUploadCompose, initMediaEditModal } from 'mastodon/actions/compose';
 import { Blurhash } from 'mastodon/components/blurhash';
 import { Icon }  from 'mastodon/components/icon';
+import Motion from 'mastodon/features/ui/util/optional_motion';
 
-import Motion from '../../ui/util/optional_motion';
+export const Upload = ({ id, onDragStart, onDragEnter, onDragEnd }) => {
+  const dispatch = useDispatch();
+  const media = useSelector(state => state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id));
+  const sensitive = useSelector(state => state.getIn(['compose', 'spoiler']));
 
-export default class Upload extends ImmutablePureComponent {
+  const handleUndoClick = useCallback(() => {
+    dispatch(undoUploadCompose(id));
+  }, [dispatch, id]);
 
-  static propTypes = {
-    media: ImmutablePropTypes.map.isRequired,
-    sensitive: PropTypes.bool,
-    onUndo: PropTypes.func.isRequired,
-    onOpenFocalPoint: PropTypes.func.isRequired,
-  };
+  const handleFocalPointClick = useCallback(() => {
+    dispatch(initMediaEditModal(id));
+  }, [dispatch, id]);
 
-  handleUndoClick = e => {
-    e.stopPropagation();
-    this.props.onUndo(this.props.media.get('id'));
-  };
+  const handleDragStart = useCallback(() => {
+    onDragStart(id);
+  }, [onDragStart, id]);
 
-  handleFocalPointClick = e => {
-    e.stopPropagation();
-    this.props.onOpenFocalPoint(this.props.media.get('id'));
-  };
+  const handleDragEnter = useCallback(() => {
+    onDragEnter(id);
+  }, [onDragEnter, id]);
 
-  render () {
-    const { media, sensitive } = this.props;
-
-    if (!media) {
-      return null;
-    }
-
-    const focusX = media.getIn(['meta', 'focus', 'x']);
-    const focusY = media.getIn(['meta', 'focus', 'y']);
-    const x = ((focusX /  2) + .5) * 100;
-    const y = ((focusY / -2) + .5) * 100;
-    const missingDescription = (media.get('description') || '').length === 0;
-
-    return (
-      <div className='compose-form__upload'>
-        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
-          {({ scale }) => (
-            <div className='compose-form__upload__thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: !sensitive ? `url(${media.get('preview_url')})` : null, backgroundPosition: `${x}% ${y}%` }}>
-              {sensitive && <Blurhash
-                hash={media.get('blurhash')}
-                className='compose-form__upload__preview'
-              />}
-
-              <div className='compose-form__upload__actions'>
-                <button type='button' className='icon-button compose-form__upload__delete' onClick={this.handleUndoClick}><Icon icon={CloseIcon} /></button>
-                <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
-              </div>
-
-              <div className='compose-form__upload__warning'>
-                <button type='button' className={classNames('icon-button', { active: missingDescription })} onClick={this.handleFocalPointClick}>{missingDescription && <Icon icon={WarningIcon} />} ALT</button>
-              </div>
-            </div>
-          )}
-        </Motion>
-      </div>
-    );
+  if (!media) {
+    return null;
   }
 
-}
+  const focusX = media.getIn(['meta', 'focus', 'x']);
+  const focusY = media.getIn(['meta', 'focus', 'y']);
+  const x = ((focusX /  2) + .5) * 100;
+  const y = ((focusY / -2) + .5) * 100;
+  const missingDescription = (media.get('description') || '').length === 0;
+
+  return (
+    <div className='compose-form__upload' draggable onDragStart={handleDragStart} onDragEnter={handleDragEnter} onDragEnd={onDragEnd}>
+      <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
+        {({ scale }) => (
+          <div className='compose-form__upload__thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: !sensitive ? `url(${media.get('preview_url')})` : null, backgroundPosition: `${x}% ${y}%` }}>
+            {sensitive && <Blurhash
+              hash={media.get('blurhash')}
+              className='compose-form__upload__preview'
+            />}
+
+            <div className='compose-form__upload__actions'>
+              <button type='button' className='icon-button compose-form__upload__delete' onClick={handleUndoClick}><Icon icon={CloseIcon} /></button>
+              <button type='button' className='icon-button' onClick={handleFocalPointClick}><Icon icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
+            </div>
+
+            <div className='compose-form__upload__warning'>
+              <button type='button' className={classNames('icon-button', { active: missingDescription })} onClick={handleFocalPointClick}>{missingDescription && <Icon icon={WarningIcon} />} ALT</button>
+            </div>
+          </div>
+        )}
+      </Motion>
+    </div>
+  );
+};
+
+Upload.propTypes = {
+  id: PropTypes.string,
+  onDragEnter: PropTypes.func,
+  onDragStart: PropTypes.func,
+  onDragEnd: PropTypes.func,
+};
diff --git a/app/javascript/mastodon/features/compose/components/upload_form.jsx b/app/javascript/mastodon/features/compose/components/upload_form.jsx
index 46bac7823b..adf5591382 100644
--- a/app/javascript/mastodon/features/compose/components/upload_form.jsx
+++ b/app/javascript/mastodon/features/compose/components/upload_form.jsx
@@ -1,31 +1,53 @@
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
+import { useRef, useCallback } from 'react';
 
-import UploadContainer from '../containers/upload_container';
-import UploadProgressContainer from '../containers/upload_progress_container';
+import { useSelector, useDispatch } from 'react-redux';
 
-export default class UploadForm extends ImmutablePureComponent {
+import { changeMediaOrder } from 'mastodon/actions/compose';
 
-  static propTypes = {
-    mediaIds: ImmutablePropTypes.list.isRequired,
-  };
+import { Upload } from './upload';
+import { UploadProgress } from './upload_progress';
 
-  render () {
-    const { mediaIds } = this.props;
+export const UploadForm = () => {
+  const dispatch = useDispatch();
+  const mediaIds = useSelector(state => state.getIn(['compose', 'media_attachments']).map(item => item.get('id')));
+  const active = useSelector(state => state.getIn(['compose', 'is_uploading']));
+  const progress = useSelector(state => state.getIn(['compose', 'progress']));
+  const isProcessing = useSelector(state => state.getIn(['compose', 'is_processing']));
 
-    return (
-      <>
-        <UploadProgressContainer />
+  const dragItem = useRef();
+  const dragOverItem = useRef();
 
-        {mediaIds.size > 0 && (
-          <div className='compose-form__uploads'>
-            {mediaIds.map(id => (
-              <UploadContainer id={id} key={id} />
-            ))}
-          </div>
-        )}
-      </>
-    );
-  }
+  const handleDragStart = useCallback(id => {
+    dragItem.current = id;
+  }, [dragItem]);
 
-}
+  const handleDragEnter = useCallback(id => {
+    dragOverItem.current = id;
+  }, [dragOverItem]);
+
+  const handleDragEnd = useCallback(() => {
+    dispatch(changeMediaOrder(dragItem.current, dragOverItem.current));
+    dragItem.current = null;
+    dragOverItem.current = null;
+  }, [dispatch, dragItem, dragOverItem]);
+
+  return (
+    <>
+      <UploadProgress active={active} progress={progress} isProcessing={isProcessing} />
+
+      {mediaIds.size > 0 && (
+        <div className='compose-form__uploads'>
+          {mediaIds.map(id => (
+            <Upload
+              key={id}
+              id={id}
+              onDragStart={handleDragStart}
+              onDragEnter={handleDragEnter}
+              onDragEnd={handleDragEnd}
+            />
+          ))}
+        </div>
+      )}
+    </>
+  );
+};
diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.jsx b/app/javascript/mastodon/features/compose/components/upload_progress.jsx
index 1276cded1f..fd0c8f4530 100644
--- a/app/javascript/mastodon/features/compose/components/upload_progress.jsx
+++ b/app/javascript/mastodon/features/compose/components/upload_progress.jsx
@@ -1,5 +1,4 @@
 import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
 
 import { FormattedMessage } from 'react-intl';
 
@@ -10,46 +9,40 @@ import { Icon }  from 'mastodon/components/icon';
 
 import Motion from '../../ui/util/optional_motion';
 
-export default class UploadProgress extends PureComponent {
-
-  static propTypes = {
-    active: PropTypes.bool,
-    progress: PropTypes.number,
-    isProcessing: PropTypes.bool,
-  };
-
-  render () {
-    const { active, progress, isProcessing } = this.props;
-
-    if (!active) {
-      return null;
-    }
-
-    let message;
-
-    if (isProcessing) {
-      message = <FormattedMessage id='upload_progress.processing' defaultMessage='Processing…' />;
-    } else {
-      message = <FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />;
-    }
-
-    return (
-      <div className='upload-progress'>
-        <Icon id='upload' icon={UploadFileIcon} />
-
-        <div className='upload-progress__message'>
-          {message}
-
-          <div className='upload-progress__backdrop'>
-            <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
-              {({ width }) =>
-                <div className='upload-progress__tracker' style={{ width: `${width}%` }} />
-              }
-            </Motion>
-          </div>
-        </div>
-      </div>
-    );
+export const UploadProgress = ({ active, progress, isProcessing }) => {
+  if (!active) {
+    return null;
   }
 
-}
+  let message;
+
+  if (isProcessing) {
+    message = <FormattedMessage id='upload_progress.processing' defaultMessage='Processing…' />;
+  } else {
+    message = <FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />;
+  }
+
+  return (
+    <div className='upload-progress'>
+      <Icon id='upload' icon={UploadFileIcon} />
+
+      <div className='upload-progress__message'>
+        {message}
+
+        <div className='upload-progress__backdrop'>
+          <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
+            {({ width }) =>
+              <div className='upload-progress__tracker' style={{ width: `${width}%` }} />
+            }
+          </Motion>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+UploadProgress.propTypes = {
+  active: PropTypes.bool,
+  progress: PropTypes.number,
+  isProcessing: PropTypes.bool,
+};
diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js
deleted file mode 100644
index a17a691444..0000000000
--- a/app/javascript/mastodon/features/compose/containers/upload_container.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import { connect } from 'react-redux';
-
-import { undoUploadCompose, initMediaEditModal, submitCompose } from '../../../actions/compose';
-import Upload from '../components/upload';
-
-const mapStateToProps = (state, { id }) => ({
-  media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
-  sensitive: state.getIn(['compose', 'spoiler']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
-  onUndo: id => {
-    dispatch(undoUploadCompose(id));
-  },
-
-  onOpenFocalPoint: id => {
-    dispatch(initMediaEditModal(id));
-  },
-
-  onSubmit (router) {
-    dispatch(submitCompose(router));
-  },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(Upload);
diff --git a/app/javascript/mastodon/features/compose/containers/upload_form_container.js b/app/javascript/mastodon/features/compose/containers/upload_form_container.js
deleted file mode 100644
index 336525cf53..0000000000
--- a/app/javascript/mastodon/features/compose/containers/upload_form_container.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import { connect } from 'react-redux';
-
-import UploadForm from '../components/upload_form';
-
-const mapStateToProps = state => ({
-  mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
-});
-
-export default connect(mapStateToProps)(UploadForm);
diff --git a/app/javascript/mastodon/features/compose/containers/upload_progress_container.js b/app/javascript/mastodon/features/compose/containers/upload_progress_container.js
deleted file mode 100644
index ffff321c3f..0000000000
--- a/app/javascript/mastodon/features/compose/containers/upload_progress_container.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { connect } from 'react-redux';
-
-import UploadProgress from '../components/upload_progress';
-
-const mapStateToProps = state => ({
-  active: state.getIn(['compose', 'is_uploading']),
-  progress: state.getIn(['compose', 'progress']),
-  isProcessing: state.getIn(['compose', 'is_processing']),
-});
-
-export default connect(mapStateToProps)(UploadProgress);
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx b/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx
index 5f430d5392..7adfc208e7 100644
--- a/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx
@@ -22,7 +22,7 @@ import { GIFV } from 'mastodon/components/gifv';
 import { IconButton } from 'mastodon/components/icon_button';
 import Audio from 'mastodon/features/audio';
 import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
-import UploadProgress from 'mastodon/features/compose/components/upload_progress';
+import { UploadProgress } from 'mastodon/features/compose/components/upload_progress';
 import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
 import { me } from 'mastodon/initial_state';
 import { assetHost } from 'mastodon/utils/config';
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 8dc2801857..97218e9f75 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -45,6 +45,7 @@ import {
   INIT_MEDIA_EDIT_MODAL,
   COMPOSE_CHANGE_MEDIA_DESCRIPTION,
   COMPOSE_CHANGE_MEDIA_FOCUS,
+  COMPOSE_CHANGE_MEDIA_ORDER,
   COMPOSE_SET_STATUS,
   COMPOSE_FOCUS,
 } from '../actions/compose';
@@ -536,6 +537,14 @@ export default function compose(state = initialState, action) {
     return state.set('language', action.language);
   case COMPOSE_FOCUS:
     return state.set('focusDate', new Date()).update('text', text => text.length > 0 ? text : action.defaultText);
+  case COMPOSE_CHANGE_MEDIA_ORDER:
+    return state.update('media_attachments', list => {
+      const indexA = list.findIndex(x => x.get('id') === action.a);
+      const moveItem = list.get(indexA);
+      const indexB = list.findIndex(x => x.get('id') === action.b);
+
+      return list.splice(indexA, 1).splice(indexB, 0, moveItem);
+    });
   default:
     return state;
   }