about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/features
diff options
context:
space:
mode:
authorReverite <github@reverite.sh>2019-08-20 10:46:41 -0600
committerReverite <github@reverite.sh>2019-08-20 10:46:41 -0600
commitd2c357ba86b1a9d1b7b4c291e1c55811997027ed (patch)
tree229bd4223833900f11701fb232f6ea7b2e4160f1 /app/javascript/flavours/glitch/features
parent2e221bd5b6472d1eecb654b3518af7886d3dadaf (diff)
parentbce46f2057b06e78958a42821f3ce18c945de88d (diff)
Merge branch 'glitch' into production
Diffstat (limited to 'app/javascript/flavours/glitch/features')
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/character_counter.js25
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.js41
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/publisher.js1
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload.js84
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_form.js3
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_progress.js9
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js6
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_container.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js188
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js11
10 files changed, 235 insertions, 139 deletions
diff --git a/app/javascript/flavours/glitch/features/compose/components/character_counter.js b/app/javascript/flavours/glitch/features/compose/components/character_counter.js
new file mode 100644
index 000000000..0ecfc9141
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/character_counter.js
@@ -0,0 +1,25 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { length } from 'stringz';
+
+export default class CharacterCounter extends React.PureComponent {
+
+  static propTypes = {
+    text: PropTypes.string.isRequired,
+    max: PropTypes.number.isRequired,
+  };
+
+  checkRemainingText (diff) {
+    if (diff < 0) {
+      return <span className='character-counter character-counter--over'>{diff}</span>;
+    }
+
+    return <span className='character-counter'>{diff}</span>;
+  }
+
+  render () {
+    const diff = this.props.max - length(this.props.text);
+    return this.checkRemainingText(diff);
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
index 3d9002fe4..6e07998ec 100644
--- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
@@ -15,6 +15,8 @@ import { countableText } from 'flavours/glitch/util/counter';
 import OptionsContainer from '../containers/options_container';
 import Publisher from './publisher';
 import TextareaIcons from './textarea_icons';
+import { maxChars } from 'flavours/glitch/util/initial_state';
+import CharacterCounter from './character_counter';
 
 const messages = defineMessages({
   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
@@ -119,14 +121,8 @@ class ComposeForm extends ImmutablePureComponent {
 
     // Submit unless there are media with missing descriptions
     if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) {
-      const firstWithoutDescription = media.findIndex(item => !item.get('description'));
-      if (uploadForm) {
-        const inputs = uploadForm.querySelectorAll('.composer--upload_form--item input');
-        if (inputs.length == media.size && firstWithoutDescription !== -1) {
-          inputs[firstWithoutDescription].focus();
-        }
-      }
-      onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null);
+      const firstWithoutDescription = media.find(item => !item.get('description'));
+      onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null, firstWithoutDescription.get('id'));
     } else if (onSubmit) {
       onSubmit(this.context.router ? this.context.router.history : null);
     }
@@ -298,6 +294,8 @@ class ComposeForm extends ImmutablePureComponent {
 
     let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia);
 
+    const countText = `${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`;
+
     return (
       <div className='composer'>
         <WarningContainer />
@@ -347,19 +345,24 @@ class ComposeForm extends ImmutablePureComponent {
           </div>
         </AutosuggestTextarea>
 
-        <OptionsContainer
-          advancedOptions={advancedOptions}
-          disabled={isSubmitting}
-          onChangeVisibility={onChangeVisibility}
-          onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
-          onUpload={onPaste}
-          privacy={privacy}
-          sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
-          spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
-        />
+        <div className='composer--options-wrapper'>
+          <OptionsContainer
+            advancedOptions={advancedOptions}
+            disabled={isSubmitting}
+            onChangeVisibility={onChangeVisibility}
+            onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
+            onUpload={onPaste}
+            privacy={privacy}
+            sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
+            spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
+          />
+          <div className='compose--counter-wrapper'>
+            <CharacterCounter text={countText} max={maxChars} />
+          </div>
+        </div>
 
         <Publisher
-          countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`}
+          countText={countText}
           disabled={disabledButton}
           onSecondarySubmit={handleSecondarySubmit}
           onSubmit={handleSubmit}
diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js
index e283b32b9..f5eafc6fd 100644
--- a/app/javascript/flavours/glitch/features/compose/components/publisher.js
+++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js
@@ -49,7 +49,6 @@ class Publisher extends ImmutablePureComponent {
 
     return (
       <div className={computedClass}>
-        <span className='count'>{diff}</span>
         {sideArm && sideArm !== 'none' ? (
           <Button
             className='side_arm'
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js
index 84edf664e..f89145a52 100644
--- a/app/javascript/flavours/glitch/features/compose/components/upload.js
+++ b/app/javascript/flavours/glitch/features/compose/components/upload.js
@@ -4,18 +4,12 @@ import PropTypes from 'prop-types';
 import Motion from 'flavours/glitch/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { FormattedMessage } from 'react-intl';
 import classNames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
 import { isUserTouching } from 'flavours/glitch/util/is_mobile';
 
-const messages = defineMessages({
-  description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
-});
-
-//  The component.
-export default @injectIntl
-class Upload extends ImmutablePureComponent {
+export default class Upload extends ImmutablePureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
@@ -23,30 +17,10 @@ class Upload extends ImmutablePureComponent {
 
   static propTypes = {
     media: ImmutablePropTypes.map.isRequired,
-    intl: PropTypes.object.isRequired,
     onUndo: PropTypes.func.isRequired,
-    onDescriptionChange: PropTypes.func.isRequired,
     onOpenFocalPoint: PropTypes.func.isRequired,
-    onSubmit: PropTypes.func.isRequired,
-  };
-
-  state = {
-    hovered: false,
-    focused: false,
-    dirtyDescription: null,
   };
 
-  handleKeyDown = (e) => {
-    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
-      this.handleSubmit();
-    }
-  }
-
-  handleSubmit = () => {
-    this.handleInputBlur();
-    this.props.onSubmit(this.context.router.history);
-  }
-
   handleUndoClick = e => {
     e.stopPropagation();
     this.props.onUndo(this.props.media.get('id'));
@@ -57,69 +31,21 @@ class Upload extends ImmutablePureComponent {
     this.props.onOpenFocalPoint(this.props.media.get('id'));
   }
 
-  handleInputChange = e => {
-    this.setState({ dirtyDescription: e.target.value });
-  }
-
-  handleMouseEnter = () => {
-    this.setState({ hovered: true });
-  }
-
-  handleMouseLeave = () => {
-    this.setState({ hovered: false });
-  }
-
-  handleInputFocus = () => {
-    this.setState({ focused: true });
-  }
-
-  handleClick = () => {
-    this.setState({ focused: true });
-  }
-
-  handleInputBlur = () => {
-    const { dirtyDescription } = this.state;
-
-    this.setState({ focused: false, dirtyDescription: null });
-
-    if (dirtyDescription !== null) {
-      this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
-    }
-  }
-
   render () {
     const { intl, media } = this.props;
-    const active          = this.state.hovered || this.state.focused || isUserTouching();
-    const description     = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
-    const computedClass   = classNames('composer--upload_form--item', { active });
     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;
 
     return (
-      <div className={computedClass} tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'>
+      <div className='composer--upload_form--item' tabIndex='0' role='button'>
         <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12, }) }}>
           {({ scale }) => (
             <div style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
-              <div className={classNames('composer--upload_form--actions', { active })}>
+              <div className={classNames('composer--upload_form--actions', { active: true })}>
                 <button className='icon-button' onClick={this.handleUndoClick}><Icon icon='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
-                {media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
-              </div>
-
-              <div className={classNames('composer--upload_form--description', { active })}>
-                <label>
-                  <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
-                  <textarea
-                    placeholder={intl.formatMessage(messages.description)}
-                    value={description}
-                    maxLength={420}
-                    onFocus={this.handleInputFocus}
-                    onChange={this.handleInputChange}
-                    onBlur={this.handleInputBlur}
-                    onKeyDown={this.handleKeyDown}
-                  />
-                </label>
+                <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
               </div>
             </div>
           )}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.js b/app/javascript/flavours/glitch/features/compose/components/upload_form.js
index 35880ddcc..43039c674 100644
--- a/app/javascript/flavours/glitch/features/compose/components/upload_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.js
@@ -4,6 +4,7 @@ import UploadProgressContainer from '../containers/upload_progress_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import UploadContainer from '../containers/upload_container';
 import SensitiveButtonContainer from '../containers/sensitive_button_container';
+import { FormattedMessage } from 'react-intl';
 
 export default class UploadForm extends ImmutablePureComponent {
   static propTypes = {
@@ -15,7 +16,7 @@ export default class UploadForm extends ImmutablePureComponent {
 
     return (
       <div className='composer--upload_form'>
-        <UploadProgressContainer />
+        <UploadProgressContainer icon='upload' message={<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />} />
 
         {mediaIds.size > 0 && (
           <div className='content'>
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
index 264c563f2..b00612983 100644
--- a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
@@ -2,7 +2,6 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import Motion from 'flavours/glitch/util/optional_motion';
 import spring from 'react-motion/lib/spring';
-import { FormattedMessage } from 'react-intl';
 import Icon from 'flavours/glitch/components/icon';
 
 export default class UploadProgress extends React.PureComponent {
@@ -10,10 +9,12 @@ export default class UploadProgress extends React.PureComponent {
   static propTypes = {
     active: PropTypes.bool,
     progress: PropTypes.number,
+    icon: PropTypes.string.isRequired,
+    message: PropTypes.node.isRequired,
   };
 
   render () {
-    const { active, progress } = this.props;
+    const { active, progress, icon, message } = this.props;
 
     if (!active) {
       return null;
@@ -21,10 +22,10 @@ export default class UploadProgress extends React.PureComponent {
 
     return (
       <div className='composer--upload_form--progress'>
-        <Icon icon='upload' />
+        <Icon icon={icon} />
 
         <div className='message'>
-          <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' />
+          {message}
 
           <div className='backdrop'>
             <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
index 199d43913..18e2b2f39 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
@@ -25,6 +25,8 @@ const messages = defineMessages({
                                 defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' },
   missingDescriptionConfirm: {  id: 'confirmations.missing_media_description.confirm',
                                 defaultMessage: 'Send anyway' },
+  missingDescriptionEdit:    {  id: 'confirmations.missing_media_description.edit',
+                                defaultMessage: 'Edit media' },
 });
 
 //  State mapping.
@@ -112,11 +114,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(changeComposeVisibility(value));
   },
 
-  onMediaDescriptionConfirm(routerHistory) {
+  onMediaDescriptionConfirm(routerHistory, mediaId) {
     dispatch(openModal('CONFIRM', {
       message: intl.formatMessage(messages.missingDescriptionMessage),
       confirm: intl.formatMessage(messages.missingDescriptionConfirm),
       onConfirm: () => dispatch(submitCompose(routerHistory)),
+      secondary: intl.formatMessage(messages.missingDescriptionEdit),
+      onSecondary: () => dispatch(openModal('FOCAL_POINT', { id: mediaId })),
       onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)),
     }));
   },
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
index d6bff63ac..f687fae99 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
@@ -1,6 +1,6 @@
 import { connect } from 'react-redux';
 import Upload from '../components/upload';
-import { undoUploadCompose, changeUploadCompose } from 'flavours/glitch/actions/compose';
+import { undoUploadCompose } from 'flavours/glitch/actions/compose';
 import { openModal } from 'flavours/glitch/actions/modal';
 import { submitCompose } from 'flavours/glitch/actions/compose';
 
@@ -14,10 +14,6 @@ const mapDispatchToProps = dispatch => ({
     dispatch(undoUploadCompose(id));
   },
 
-  onDescriptionChange: (id, description) => {
-    dispatch(changeUploadCompose(id, { description }));
-  },
-
   onOpenFocalPoint: id => {
     dispatch(openModal('FOCAL_POINT', { id }));
   },
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
index 57c92cc66..c4cc18f94 100644
--- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -1,11 +1,26 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { connect } from 'react-redux';
-import ImageLoader from './image_loader';
 import classNames from 'classnames';
 import { changeUploadCompose } from 'flavours/glitch/actions/compose';
 import { getPointerPosition } from 'flavours/glitch/features/video';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import IconButton from 'flavours/glitch/components/icon_button';
+import Button from 'flavours/glitch/components/button';
+import Video from 'flavours/glitch/features/video';
+import Textarea from 'react-textarea-autosize';
+import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress';
+import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter';
+import { length } from 'stringz';
+import { Tesseract as fetchTesseract } from 'flavours/glitch/util/async-components';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+  apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
+  placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
+});
 
 const mapStateToProps = (state, { id }) => ({
   media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
@@ -13,17 +28,26 @@ const mapStateToProps = (state, { id }) => ({
 
 const mapDispatchToProps = (dispatch, { id }) => ({
 
-  onSave: (x, y) => {
-    dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
+  onSave: (description, x, y) => {
+    dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
   },
 
 });
 
-@connect(mapStateToProps, mapDispatchToProps)
-export default class FocalPointModal extends ImmutablePureComponent {
+const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
+  .replace(/\n/g, ' ')
+  .replace(/\*\*\*\*\*\*/g, '\n\n');
+
+const assetHost = process.env.CDN_HOST || '';
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class FocalPointModal extends ImmutablePureComponent {
 
   static propTypes = {
     media: ImmutablePropTypes.map.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
   };
 
   state = {
@@ -32,6 +56,9 @@ export default class FocalPointModal extends ImmutablePureComponent {
     focusX: 0,
     focusY: 0,
     dragging: false,
+    description: '',
+    dirty: false,
+    progress: 0,
   };
 
   componentWillMount () {
@@ -57,6 +84,14 @@ export default class FocalPointModal extends ImmutablePureComponent {
     this.setState({ dragging: true });
   }
 
+  handleTouchStart = e => {
+    document.addEventListener('touchmove', this.handleMouseMove);
+    document.addEventListener('touchend', this.handleTouchEnd);
+
+    this.updatePosition(e);
+    this.setState({ dragging: true });
+  }
+
   handleMouseMove = e => {
     this.updatePosition(e);
   }
@@ -66,7 +101,13 @@ export default class FocalPointModal extends ImmutablePureComponent {
     document.removeEventListener('mouseup', this.handleMouseUp);
 
     this.setState({ dragging: false });
-    this.props.onSave(this.state.focusX, this.state.focusY);
+  }
+
+  handleTouchEnd = () => {
+    document.removeEventListener('touchmove', this.handleMouseMove);
+    document.removeEventListener('touchend', this.handleTouchEnd);
+
+    this.setState({ dragging: false });
   }
 
   updatePosition = e => {
@@ -74,46 +115,145 @@ export default class FocalPointModal extends ImmutablePureComponent {
     const focusX   = (x - .5) *  2;
     const focusY   = (y - .5) * -2;
 
-    this.setState({ x, y, focusX, focusY });
+    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 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 });
+      this.setState({
+        x,
+        y,
+        focusX,
+        focusY,
+        description,
+        dirty: false,
+      });
     } else {
-      this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 });
+      this.setState({
+        x: 0.5,
+        y: 0.5,
+        focusX: 0,
+        focusY: 0,
+        description,
+        dirty: false,
+      });
     }
   }
 
+  handleChange = e => {
+    this.setState({ description: e.target.value, dirty: true });
+  }
+
+  handleSubmit = () => {
+    this.props.onSave(this.state.description, this.state.focusX, this.state.focusY);
+    this.props.onClose();
+  }
+
   setRef = c => {
     this.node = c;
   }
 
-  render () {
+  handleTextDetection = () => {
     const { media } = this.props;
-    const { x, y, dragging } = this.state;
+
+    this.setState({ detecting: true });
+
+    fetchTesseract().then(({ TesseractWorker }) => {
+      const worker = new TesseractWorker({
+        workerPath: `${assetHost}/packs/ocr/worker.min.js`,
+        corePath: `${assetHost}/packs/ocr/tesseract-core.wasm.js`,
+        langPath: `${assetHost}/ocr/lang-data`,
+      });
+
+      worker.recognize(media.get('url'))
+        .progress(({ progress }) => this.setState({ progress }))
+        .finally(() => worker.terminate())
+        .then(({ text }) => this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false }))
+        .catch(() => this.setState({ detecting: false }));
+    }).catch(() => this.setState({ detecting: false }));
+  }
+
+  render () {
+    const { media, intl, onClose } = this.props;
+    const { x, y, dragging, description, dirty, detecting, progress } = this.state;
 
     const width  = media.getIn(['meta', 'original', 'width']) || null;
     const height = media.getIn(['meta', 'original', 'height']) || null;
+    const focals = ['image', 'gifv'].includes(media.get('type'));
+
+    const previewRatio  = 16/9;
+    const previewWidth  = 200;
+    const previewHeight = previewWidth / previewRatio;
 
     return (
-      <div className='modal-root__modal video-modal focal-point-modal'>
-        <div className={classNames('focal-point', { dragging })} ref={this.setRef}>
-          <ImageLoader
-            previewSrc={media.get('preview_url')}
-            src={media.get('url')}
-            width={width}
-            height={height}
-          />
-
-          <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
-          <div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
+      <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
+        <div className='report-modal__target'>
+          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
+          <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
+        </div>
+
+        <div className='report-modal__container'>
+          <div className='report-modal__comment'>
+            {focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>}
+
+            <label className='setting-text-label' htmlFor='upload-modal__description'><FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' /></label>
+
+            <div className='setting-text__wrapper'>
+              <Textarea
+                id='upload-modal__description'
+                className='setting-text light'
+                value={detecting ? '…' : description}
+                onChange={this.handleChange}
+                disabled={detecting}
+                autoFocus
+              />
+
+              <div className='setting-text__modifiers'>
+                <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={<FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />} />
+              </div>
+            </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>
+              <CharacterCounter max={420} text={detecting ? '' : description} />
+            </div>
+
+            <Button disabled={!dirty || detecting || length(description) > 420} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
+          </div>
+
+          <div className='focal-point-modal__content'>
+            {focals && (
+              <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}>
+                {media.get('type') === 'image' && <img src={media.get('url')} width={width} height={height} alt='' />}
+                {media.get('type') === 'gifv' && <video src={media.get('url')} width={width} height={height} loop muted autoPlay />}
+
+                <div className='focal-point__preview'>
+                  <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong>
+                  <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} />
+                </div>
+
+                <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
+                <div className='focal-point__overlay' />
+              </div>
+            )}
+
+            {['audio', 'video'].includes(media.get('type')) && (
+              <Video
+                preview={media.get('preview_url')}
+                blurhash={media.get('blurhash')}
+                src={media.get('url')}
+                detailed
+                editable
+              />
+            )}
+          </div>
         </div>
       </div>
     );
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
index 112f9d101..6d5162519 100644
--- a/app/javascript/flavours/glitch/features/video/index.js
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -101,6 +101,7 @@ export default class Video extends React.PureComponent {
     fullwidth: PropTypes.bool,
     detailed: PropTypes.bool,
     inline: PropTypes.bool,
+    editable: PropTypes.bool,
     cacheWidth: PropTypes.func,
     intl: PropTypes.object.isRequired,
     visible: PropTypes.bool,
@@ -393,7 +394,7 @@ export default class Video extends React.PureComponent {
   }
 
   render () {
-    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link } = this.props;
+    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable } = this.props;
     const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
     const progress = (currentTime / duration) * 100;
     const playerStyle = {};
@@ -401,7 +402,7 @@ export default class Video extends React.PureComponent {
     const volumeWidth = (muted) ? 0 : volume * this.volWidth;
     const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume);
 
-    const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth });
+    const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth });
 
     let { width, height } = this.props;
 
@@ -443,7 +444,7 @@ export default class Video extends React.PureComponent {
       >
         <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />
 
-        {revealed && <video
+        {(revealed || editable) && <video
           ref={this.setVideoRef}
           src={src}
           poster={preview}
@@ -465,7 +466,7 @@ export default class Video extends React.PureComponent {
           onVolumeChange={this.handleVolumeChange}
         />}
 
-        <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}>
+        <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
           <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
             <span className='spoiler-button__overlay__label'>{warning}</span>
           </button>
@@ -508,7 +509,7 @@ export default class Video extends React.PureComponent {
             </div>
 
             <div className='video-player__buttons right'>
-              {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye-slash' /></button>}
+              {(!onCloseVideo && !editable) && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye-slash' /></button>}
               {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>}
               {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-compress' /></button>}
               <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button>