about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/features
diff options
context:
space:
mode:
authorStarfall <us@starfall.systems>2022-02-13 22:15:26 -0600
committerStarfall <us@starfall.systems>2022-02-13 22:15:26 -0600
commitc0341f06be5310a00b85a5d48fa80891d47c6710 (patch)
tree907ef7f787f8bd446a6d9be1448a8bcff74e5a08 /app/javascript/flavours/glitch/features
parent169688aa9f2a69ac3d36332c833e9cad43b5f7a5 (diff)
parent6f78c66fe01921a4e7e01aa6e2386a5fce7f3afd (diff)
Merge remote-tracking branch 'glitch/main'
Not at all sure where the admin UI is going to display English language
names now but OK.
Diffstat (limited to 'app/javascript/flavours/glitch/features')
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.js4
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown.js78
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js66
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/header.js2
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/options.js81
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js70
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/publisher.js70
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload.js5
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js1
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js20
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_container.js1
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/column_settings.js11
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notification.js22
-rw-r--r--app/javascript/flavours/glitch/features/status/components/action_bar.js7
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js5
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js7
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/actions_modal.js79
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/compare_history_modal.js79
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/modal_root.js4
19 files changed, 349 insertions, 263 deletions
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 5dfc119c1..c75906ce7 100644
--- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
@@ -47,6 +47,7 @@ class ComposeForm extends ImmutablePureComponent {
     preselectDate: PropTypes.instanceOf(Date),
     isSubmitting: PropTypes.bool,
     isChangingUpload: PropTypes.bool,
+    isEditing: PropTypes.bool,
     isUploading: PropTypes.bool,
     onChange: PropTypes.func,
     onSubmit: PropTypes.func,
@@ -293,6 +294,7 @@ class ComposeForm extends ImmutablePureComponent {
       spoilerText,
       suggestions,
       spoilersAlwaysOn,
+      isEditing,
     } = this.props;
 
     const countText = this.getFulltextForCharacterCounting();
@@ -353,6 +355,7 @@ class ComposeForm extends ImmutablePureComponent {
             onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
             onUpload={onPaste}
             privacy={privacy}
+            isEditing={isEditing}
             sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
             spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
           />
@@ -364,6 +367,7 @@ class ComposeForm extends ImmutablePureComponent {
         <Publisher
           countText={countText}
           disabled={!this.canSubmit()}
+          isEditing={isEditing}
           onSecondarySubmit={handleSecondarySubmit}
           onSubmit={handleSubmit}
           privacy={privacy}
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
index abf7cbba1..9f70d6b79 100644
--- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
@@ -21,22 +21,25 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
     icon: PropTypes.string,
     items: PropTypes.arrayOf(PropTypes.shape({
       icon: PropTypes.string,
-      meta: PropTypes.node,
+      meta: PropTypes.string,
       name: PropTypes.string.isRequired,
-      on: PropTypes.bool,
-      text: PropTypes.node,
+      text: PropTypes.string,
     })).isRequired,
     onModalOpen: PropTypes.func,
     onModalClose: PropTypes.func,
     title: PropTypes.string,
     value: PropTypes.string,
     onChange: PropTypes.func,
-    noModal: PropTypes.bool,
     container: PropTypes.func,
+    renderItemContents: PropTypes.func,
+    closeOnChange: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    closeOnChange: true,
   };
 
   state = {
-    needsModalUpdate: false,
     open: false,
     openedViaKeyboard: undefined,
     placement: 'bottom',
@@ -44,10 +47,10 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
 
   //  Toggles opening and closing the dropdown.
   handleToggle = ({ target, type }) => {
-    const { onModalOpen, noModal } = this.props;
+    const { onModalOpen } = this.props;
     const { open } = this.state;
 
-    if (!noModal && isUserTouching()) {
+    if (isUserTouching()) {
       if (this.state.open) {
         this.props.onModalClose();
       } else {
@@ -107,9 +110,25 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
     this.setState({ open: false });
   }
 
+  handleItemClick = (e) => {
+    const {
+      items,
+      onChange,
+      onModalClose,
+      closeOnChange,
+    } = this.props;
+
+    const i = Number(e.currentTarget.getAttribute('data-index'));
+
+    const { name } = items[i];
+
+    e.preventDefault();  //  Prevents focus from changing
+    if (closeOnChange) onModalClose();
+    onChange(name);
+  };
+
   //  Creates an action modal object.
   handleMakeModal = () => {
-    const component = this;
     const {
       items,
       onChange,
@@ -125,6 +144,8 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
 
     //  The object.
     return {
+      renderItemContents: this.props.renderItemContents,
+      onClick: this.handleItemClick,
       actions: items.map(
         ({
           name,
@@ -133,48 +154,11 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
           ...rest,
           active: value && name === value,
           name,
-          onClick (e) {
-            e.preventDefault();  //  Prevents focus from changing
-            onModalClose();
-            onChange(name);
-          },
-          onPassiveClick (e) {
-            e.preventDefault();  //  Prevents focus from changing
-            onChange(name);
-            component.setState({ needsModalUpdate: true });
-          },
         })
       ),
     };
   }
 
-  //  If our modal is open and our props update, we need to also update
-  //  the modal.
-  handleUpdate = () => {
-    const { onModalOpen } = this.props;
-    const { needsModalUpdate } = this.state;
-
-    //  Gets our modal object.
-    const modal = this.handleMakeModal();
-
-    //  Reopens the modal with the new object.
-    if (needsModalUpdate && modal && onModalOpen) {
-      onModalOpen(modal);
-    }
-  }
-
-  //  Updates our modal as necessary.
-  componentDidUpdate (prevProps) {
-    const { items } = this.props;
-    const { needsModalUpdate } = this.state;
-    if (needsModalUpdate && items.find(
-      (item, i) => item.on !== prevProps.items[i].on
-    )) {
-      this.handleUpdate();
-      this.setState({ needsModalUpdate: false });
-    }
-  }
-
   //  Rendering.
   render () {
     const {
@@ -186,6 +170,8 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
       onChange,
       value,
       container,
+      renderItemContents,
+      closeOnChange,
     } = this.props;
     const { open, placement } = this.state;
     const computedClass = classNames('composer--options--dropdown', {
@@ -226,10 +212,12 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
         >
           <DropdownMenu
             items={items}
+            renderItemContents={renderItemContents}
             onChange={onChange}
             onClose={this.handleClose}
             value={value}
             openedViaKeyboard={this.state.openedViaKeyboard}
+            closeOnChange={closeOnChange}
           />
         </Overlay>
       </div>
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
index bee06e64c..0649fe1ca 100644
--- a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
@@ -2,7 +2,6 @@
 import PropTypes from 'prop-types';
 import React from 'react';
 import spring from 'react-motion/lib/spring';
-import Toggle from 'react-toggle';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import classNames from 'classnames';
 
@@ -28,18 +27,20 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
       icon: PropTypes.string,
       meta: PropTypes.node,
       name: PropTypes.string.isRequired,
-      on: PropTypes.bool,
       text: PropTypes.node,
     })),
     onChange: PropTypes.func.isRequired,
     onClose: PropTypes.func.isRequired,
     style: PropTypes.object,
     value: PropTypes.string,
+    renderItemContents: PropTypes.func,
     openedViaKeyboard: PropTypes.bool,
+    closeOnChange: PropTypes.bool,
   };
 
   static defaultProps = {
     style: {},
+    closeOnChange: true,
   };
 
   state = {
@@ -77,16 +78,19 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
     document.removeEventListener('touchend', this.handleDocumentClick, withPassive);
   }
 
-  handleClick = (name, e) => {
+  handleClick = (e) => {
+    const i = Number(e.currentTarget.getAttribute('data-index'));
+
     const {
       onChange,
       onClose,
+      closeOnChange,
       items,
     } = this.props;
 
-    const { on } = this.props.items.find(item => item.name === name);
+    const { name } = this.props.items[i];
     e.preventDefault();  //  Prevents change in focus on click
-    if ((on === null || typeof on === 'undefined')) {
+    if (closeOnChange) {
       onClose();
     }
     onChange(name);
@@ -101,11 +105,9 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
     }
   }
 
-  handleKeyDown = (name, e) => {
+  handleKeyDown = (e) => {
+    const index = Number(e.currentTarget.getAttribute('data-index'));
     const { items } = this.props;
-    const index = items.findIndex(item => {
-      return (item.name === name);
-    });
     let element = null;
 
     switch(e.key) {
@@ -139,7 +141,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
 
     if (element) {
       element.focus();
-      this.handleChange(element.getAttribute('data-index'));
+      this.handleChange(this.props.items[Number(element.getAttribute('data-index'))].name);
       e.preventDefault();
       e.stopPropagation();
     }
@@ -149,44 +151,40 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
     this.focusedItem = c;
   }
 
-  renderItem = (item) => {
-    const { name, icon, meta, on, text } = item;
+  renderItem = (item, i) => {
+    const { name, icon, meta, text } = item;
 
     const active = (name === (this.props.value || this.state.value));
 
-    const computedClass = classNames('composer--options--dropdown--content--item', {
-      active,
-      lengthy: meta,
-      'toggled-off': !on && on !== null && typeof on !== 'undefined',
-      'toggled-on': on,
-      'with-icon': icon,
-    });
+    const computedClass = classNames('composer--options--dropdown--content--item', { active });
+
+    let contents = this.props.renderItemContents && this.props.renderItemContents(item, i);
 
-    let prefix = null;
+    if (!contents) {
+      contents = (
+        <React.Fragment>
+          {icon && <Icon className='icon' fixedWidth id={icon} />}
 
-    if (on !== null && typeof on !== 'undefined') {
-      prefix = <Toggle checked={on} onChange={this.handleClick.bind(this, name)} />;
-    } else if (icon) {
-      prefix = <Icon className='icon' fixedWidth id={icon} />
+          <div className='content'>
+            <strong>{text}</strong>
+            {meta}
+          </div>
+        </React.Fragment>
+      );
     }
 
     return (
       <div
         className={computedClass}
-        onClick={this.handleClick.bind(this, name)}
-        onKeyDown={this.handleKeyDown.bind(this, name)}
+        onClick={this.handleClick}
+        onKeyDown={this.handleKeyDown}
         role='option'
         tabIndex='0'
         key={name}
-        data-index={name}
+        data-index={i}
         ref={active ? this.setFocusRef : null}
       >
-        {prefix}
-
-        <div className='content'>
-          <strong>{text}</strong>
-          {meta}
-        </div>
+        {contents}
       </div>
     );
   }
@@ -229,7 +227,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
               transform: mounted ? `scale(${scaleX}, ${scaleY})` : null,
             }}
           >
-            {!!items && items.map(item => this.renderItem(item))}
+            {!!items && items.map((item, i) => this.renderItem(item, i))}
           </div>
         )}
       </Motion>
diff --git a/app/javascript/flavours/glitch/features/compose/components/header.js b/app/javascript/flavours/glitch/features/compose/components/header.js
index c6e4cc36b..95add2027 100644
--- a/app/javascript/flavours/glitch/features/compose/components/header.js
+++ b/app/javascript/flavours/glitch/features/compose/components/header.js
@@ -119,7 +119,7 @@ class Header extends ImmutablePureComponent {
         <a
           aria-label={intl.formatMessage(messages.settings)}
           onClick={onSettingsClick}
-          href='#'
+          href='/settings/preferences'
           title={intl.formatMessage(messages.settings)}
         ><Icon id='cogs' /></a>
         <a
diff --git a/app/javascript/flavours/glitch/features/compose/components/options.js b/app/javascript/flavours/glitch/features/compose/components/options.js
index f9212bbae..3a31e214d 100644
--- a/app/javascript/flavours/glitch/features/compose/components/options.js
+++ b/app/javascript/flavours/glitch/features/compose/components/options.js
@@ -2,8 +2,10 @@
 import PropTypes from 'prop-types';
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl } from 'react-intl';
 import spring from 'react-motion/lib/spring';
+import Toggle from 'react-toggle';
+import { connect } from 'react-redux';
 
 //  Components.
 import IconButton from 'flavours/glitch/components/icon_button';
@@ -80,6 +82,36 @@ const messages = defineMessages({
   },
 });
 
+@connect((state, { name }) => ({ checked: state.getIn(['compose', 'advanced_options', name]) }))
+class ToggleOption extends ImmutablePureComponent {
+
+  static propTypes = {
+    name: PropTypes.string.isRequired,
+    checked: PropTypes.bool,
+    onChangeAdvancedOption: PropTypes.func.isRequired,
+  };
+
+  handleChange = () => {
+    this.props.onChangeAdvancedOption(this.props.name);
+  };
+
+  render() {
+    const { meta, text, checked } = this.props;
+
+    return (
+      <React.Fragment>
+        <Toggle checked={checked} onChange={this.handleChange} />
+
+        <div className='content'>
+          <strong>{text}</strong>
+          {meta}
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
 export default @injectIntl
 class ComposerOptions extends ImmutablePureComponent {
 
@@ -106,6 +138,7 @@ class ComposerOptions extends ImmutablePureComponent {
     resetFileKey: PropTypes.number,
     spoiler: PropTypes.bool,
     showContentTypeChoice: PropTypes.bool,
+    isEditing: PropTypes.bool,
   };
 
   //  Handles file selection.
@@ -141,6 +174,13 @@ class ComposerOptions extends ImmutablePureComponent {
     this.fileElement = fileElement;
   }
 
+  renderToggleItemContents = (item) => {
+    const { onChangeAdvancedOption } = this.props;
+    const { name, meta, text } = item;
+
+    return <ToggleOption name={name} text={text} meta={meta} onChangeAdvancedOption={onChangeAdvancedOption} />;
+  };
+
   //  Rendering.
   render () {
     const {
@@ -152,7 +192,6 @@ class ComposerOptions extends ImmutablePureComponent {
       hasMedia,
       allowPoll,
       hasPoll,
-      intl,
       onChangeAdvancedOption,
       onChangeContentType,
       onChangeVisibility,
@@ -164,23 +203,25 @@ class ComposerOptions extends ImmutablePureComponent {
       resetFileKey,
       spoiler,
       showContentTypeChoice,
+      isEditing,
+      intl: { formatMessage },
     } = this.props;
 
     const contentTypeItems = {
       plain: {
         icon: 'file-text',
         name: 'text/plain',
-        text: <FormattedMessage {...messages.plain} />,
+        text: formatMessage(messages.plain),
       },
       html: {
         icon: 'code',
         name: 'text/html',
-        text: <FormattedMessage {...messages.html} />,
+        text: formatMessage(messages.html),
       },
       markdown: {
         icon: 'arrow-circle-down',
         name: 'text/markdown',
-        text: <FormattedMessage {...messages.markdown} />,
+        text: formatMessage(messages.markdown),
       },
     };
 
@@ -204,18 +245,18 @@ class ComposerOptions extends ImmutablePureComponent {
             {
               icon: 'cloud-upload',
               name: 'upload',
-              text: <FormattedMessage {...messages.upload} />,
+              text: formatMessage(messages.upload),
             },
             {
               icon: 'paint-brush',
               name: 'doodle',
-              text: <FormattedMessage {...messages.doodle} />,
+              text: formatMessage(messages.doodle),
             },
           ]}
           onChange={this.handleClickAttach}
           onModalClose={onModalClose}
           onModalOpen={onModalOpen}
-          title={intl.formatMessage(messages.attach)}
+          title={formatMessage(messages.attach)}
         />
         {!!pollLimits && (
           <IconButton
@@ -229,12 +270,12 @@ class ComposerOptions extends ImmutablePureComponent {
               height: null,
               lineHeight: null,
             }}
-            title={intl.formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)}
+            title={formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)}
           />
         )}
         <hr />
         <PrivacyDropdown
-          disabled={disabled}
+          disabled={disabled || isEditing}
           onChange={onChangeVisibility}
           onModalClose={onModalClose}
           onModalOpen={onModalOpen}
@@ -252,7 +293,7 @@ class ComposerOptions extends ImmutablePureComponent {
             onChange={onChangeContentType}
             onModalClose={onModalClose}
             onModalOpen={onModalOpen}
-            title={intl.formatMessage(messages.content_type)}
+            title={formatMessage(messages.content_type)}
             value={contentType}
           />
         )}
@@ -262,31 +303,31 @@ class ComposerOptions extends ImmutablePureComponent {
             ariaControls='glitch.composer.spoiler.input'
             label='CW'
             onClick={onToggleSpoiler}
-            title={intl.formatMessage(messages.spoiler)}
+            title={formatMessage(messages.spoiler)}
           />
         )}
         <Dropdown
           active={advancedOptions && advancedOptions.some(value => !!value)}
-          disabled={disabled}
+          disabled={disabled || isEditing}
           icon='ellipsis-h'
           items={advancedOptions ? [
             {
-              meta: <FormattedMessage {...messages.local_only_long} />,
+              meta: formatMessage(messages.local_only_long),
               name: 'do_not_federate',
-              on: advancedOptions.get('do_not_federate'),
-              text: <FormattedMessage {...messages.local_only_short} />,
+              text: formatMessage(messages.local_only_short),
             },
             {
-              meta: <FormattedMessage {...messages.threaded_mode_long} />,
+              meta: formatMessage(messages.threaded_mode_long),
               name: 'threaded_mode',
-              on: advancedOptions.get('threaded_mode'),
-              text: <FormattedMessage {...messages.threaded_mode_short} />,
+              text: formatMessage(messages.threaded_mode_short),
             },
           ] : null}
           onChange={onChangeAdvancedOption}
+          renderItemContents={this.renderToggleItemContents}
           onModalClose={onModalClose}
           onModalOpen={onModalOpen}
-          title={intl.formatMessage(messages.advanced_options_icon_title)}
+          title={formatMessage(messages.advanced_options_icon_title)}
+          closeOnChange={false}
         />
       </div>
     );
diff --git a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js
index 39f7c7bd1..c8e783d22 100644
--- a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js
@@ -1,46 +1,19 @@
 import PropTypes from 'prop-types';
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl } from 'react-intl';
 import Dropdown from './dropdown';
 
 const messages = defineMessages({
-  change_privacy: {
-    defaultMessage: 'Adjust status privacy',
-    id: 'privacy.change',
-  },
-  direct_long: {
-    defaultMessage: 'Visible for mentioned users only',
-    id: 'privacy.direct.long',
-  },
-  direct_short: {
-    defaultMessage: 'Direct',
-    id: 'privacy.direct.short',
-  },
-  private_long: {
-    defaultMessage: 'Visible for followers only',
-    id: 'privacy.private.long',
-  },
-  private_short: {
-    defaultMessage: 'Followers-only',
-    id: 'privacy.private.short',
-  },
-  public_long: {
-    defaultMessage: 'Visible for all, shown in public timelines',
-    id: 'privacy.public.long',
-  },
-  public_short: {
-    defaultMessage: 'Public',
-    id: 'privacy.public.short',
-  },
-  unlisted_long: {
-    defaultMessage: 'Visible for all, but not in public timelines',
-    id: 'privacy.unlisted.long',
-  },
-  unlisted_short: {
-    defaultMessage: 'Unlisted',
-    id: 'privacy.unlisted.short',
-  },
+  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+  public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all, shown in public timelines' },
+  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but not in public timelines' },
+  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+  private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
+  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
+  change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
 });
 
 export default @injectIntl
@@ -53,40 +26,40 @@ class PrivacyDropdown extends React.PureComponent {
     value: PropTypes.string.isRequired,
     onChange: PropTypes.func.isRequired,
     noDirect: PropTypes.bool,
-    noModal: PropTypes.bool,
     container: PropTypes.func,
+    disabled: PropTypes.bool,
     intl: PropTypes.object.isRequired,
   };
 
   render () {
-    const { value, onChange, onModalOpen, onModalClose, disabled, noDirect, noModal, container, intl } = this.props;
+    const { value, onChange, onModalOpen, onModalClose, disabled, noDirect, container, intl: { formatMessage } } = this.props;
 
     //  We predefine our privacy items so that we can easily pick the
     //  dropdown icon later.
     const privacyItems = {
       direct: {
         icon: 'envelope',
-        meta: <FormattedMessage {...messages.direct_long} />,
+        meta: formatMessage(messages.direct_long),
         name: 'direct',
-        text: <FormattedMessage {...messages.direct_short} />,
+        text: formatMessage(messages.direct_short),
       },
       private: {
         icon: 'lock',
-        meta: <FormattedMessage {...messages.private_long} />,
+        meta: formatMessage(messages.private_long),
         name: 'private',
-        text: <FormattedMessage {...messages.private_short} />,
+        text: formatMessage(messages.private_short),
       },
       public: {
         icon: 'globe',
-        meta: <FormattedMessage {...messages.public_long} />,
+        meta: formatMessage(messages.public_long),
         name: 'public',
-        text: <FormattedMessage {...messages.public_short} />,
+        text: formatMessage(messages.public_short),
       },
       unlisted: {
         icon: 'unlock',
-        meta: <FormattedMessage {...messages.unlisted_long} />,
+        meta: formatMessage(messages.unlisted_long),
         name: 'unlisted',
-        text: <FormattedMessage {...messages.unlisted_short} />,
+        text: formatMessage(messages.unlisted_short),
       },
     };
 
@@ -104,9 +77,8 @@ class PrivacyDropdown extends React.PureComponent {
         onChange={onChange}
         onModalClose={onModalClose}
         onModalOpen={onModalOpen}
-        title={intl.formatMessage(messages.change_privacy)}
+        title={formatMessage(messages.change_privacy)}
         container={container}
-        noModal={noModal}
         value={value}
       />
     );
diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js
index 1531dcaa9..9a8c0f510 100644
--- a/app/javascript/flavours/glitch/features/compose/components/publisher.js
+++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js
@@ -2,7 +2,7 @@
 import classNames from 'classnames';
 import PropTypes from 'prop-types';
 import React from 'react';
-import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl } from 'react-intl';
 import { length } from 'stringz';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
@@ -23,6 +23,7 @@ const messages = defineMessages({
     defaultMessage: '{publish}!',
     id: 'compose_form.publish_loud',
   },
+  saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
 });
 
 export default @injectIntl
@@ -36,6 +37,7 @@ class Publisher extends ImmutablePureComponent {
     onSubmit: PropTypes.func,
     privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
     sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
+    isEditing: PropTypes.bool,
   };
 
   handleSubmit = () => {
@@ -43,7 +45,7 @@ class Publisher extends ImmutablePureComponent {
   };
 
   render () {
-    const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm } = this.props;
+    const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm, isEditing } = this.props;
 
     const diff = maxChars - length(countText || '');
     const computedClass = classNames('composer--publisher', {
@@ -51,63 +53,37 @@ class Publisher extends ImmutablePureComponent {
       over: diff < 0,
     });
 
+    const privacyIcons = { direct: 'envelope', private: 'lock', public: 'globe', unlisted: 'unlock' };
+
+    let publishText;
+    if (isEditing) {
+      publishText = intl.formatMessage(messages.saveChanges);
+    } else if (privacy === 'private' || privacy === 'direct') {
+      const iconId = privacyIcons[privacy];
+      publishText = (
+        <span>
+          <Icon id={iconId} /> {intl.formatMessage(messages.publish)}
+        </span>
+      );
+    } else {
+      publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
+    }
+
     return (
       <div className={computedClass}>
-        {sideArm && sideArm !== 'none' ? (
+        {sideArm && !isEditing && sideArm !== 'none' ? (
           <Button
             className='side_arm'
             disabled={disabled}
             onClick={onSecondarySubmit}
             style={{ padding: null }}
-            text={
-              <span>
-                <Icon
-                  id={{
-                    public: 'globe',
-                    unlisted: 'unlock',
-                    private: 'lock',
-                    direct: 'envelope',
-                  }[sideArm]}
-                />
-              </span>
-            }
+            text={<Icon id={privacyIcons[sideArm]} />}
             title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
           />
         ) : null}
         <Button
           className='primary'
-          text={function () {
-            switch (true) {
-            case !!sideArm && sideArm !== 'none':
-            case privacy === 'direct':
-            case privacy === 'private':
-              return (
-                <span>
-                  <Icon
-                    id={{
-                      direct: 'envelope',
-                      private: 'lock',
-                      public: 'globe',
-                      unlisted: 'unlock',
-                    }[privacy]}
-                  />
-                  {' '}
-                  <FormattedMessage {...messages.publish} />
-                </span>
-              );
-            case privacy === 'public':
-              return (
-                <span>
-                  <FormattedMessage
-                    {...messages.publishLoud}
-                    values={{ publish: <FormattedMessage {...messages.publish} /> }}
-                  />
-                </span>
-              );
-            default:
-              return <span><FormattedMessage {...messages.publish} /></span>;
-            }
-          }()}
+          text={publishText}
           title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
           onClick={this.handleSubmit}
           disabled={disabled}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js
index 425b0fe5e..338bfca37 100644
--- a/app/javascript/flavours/glitch/features/compose/components/upload.js
+++ b/app/javascript/flavours/glitch/features/compose/components/upload.js
@@ -19,6 +19,7 @@ export default class Upload extends ImmutablePureComponent {
     media: ImmutablePropTypes.map.isRequired,
     onUndo: PropTypes.func.isRequired,
     onOpenFocalPoint: PropTypes.func.isRequired,
+    isEditingStatus: PropTypes.func.isRequired,
   };
 
   handleUndoClick = e => {
@@ -32,7 +33,7 @@ export default class Upload extends ImmutablePureComponent {
   }
 
   render () {
-    const { intl, media } = this.props;
+    const { intl, media, isEditingStatus } = this.props;
     const focusX = media.getIn(['meta', 'focus', 'x']);
     const focusY = media.getIn(['meta', 'focus', 'y']);
     const x = ((focusX /  2) + .5) * 100;
@@ -45,7 +46,7 @@ export default class Upload extends ImmutablePureComponent {
             <div style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
               <div className={classNames('composer--upload_form--actions', { active: true })}>
                 <button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
-                <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
+                {!isEditingStatus && (<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/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
index 8eff8a36b..a037bbbcc 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
@@ -51,6 +51,7 @@ function mapStateToProps (state) {
     focusDate: state.getIn(['compose', 'focusDate']),
     caretPosition: state.getIn(['compose', 'caretPosition']),
     isSubmitting: state.getIn(['compose', 'is_submitting']),
+    isEditing: state.getIn(['compose', 'id']) !== null,
     isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
     isUploading: state.getIn(['compose', 'is_uploading']),
     layout: state.getIn(['local_settings', 'layout']),
diff --git a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js
index 395a9aa5b..dd6899be4 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js
@@ -1,14 +1,24 @@
 import { connect } from 'react-redux';
 import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
-import { makeGetStatus } from 'flavours/glitch/selectors';
 import ReplyIndicator from '../components/reply_indicator';
 
-function makeMapStateToProps (state) {
-  const inReplyTo = state.getIn(['compose', 'in_reply_to']);
+const makeMapStateToProps = () => {
+  const mapStateToProps = state => {
+    let statusId = state.getIn(['compose', 'id'], null);
+    let editing  = true;
 
-  return {
-    status: inReplyTo ? state.getIn(['statuses', inReplyTo]) : null,
+    if (statusId === null) {
+      statusId = state.getIn(['compose', 'in_reply_to']);
+      editing  = false;
+    }
+
+    return {
+      status: state.getIn(['statuses', statusId]),
+      editing,
+    };
   };
+
+  return mapStateToProps;
 };
 
 const mapDispatchToProps = dispatch => ({
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 f3ca4ce7b..d6256fe96 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
@@ -5,6 +5,7 @@ import { submitCompose } from 'flavours/glitch/actions/compose';
 
 const mapStateToProps = (state, { id }) => ({
   media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+  isEditingStatus: state.getIn(['compose', 'id']) !== null,
 });
 
 const mapDispatchToProps = dispatch => ({
diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
index 95250c6ed..569ba4db0 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
@@ -154,6 +154,17 @@ export default class ColumnSettings extends React.PureComponent {
             <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
           </div>
         </div>
+
+        <div role='group' aria-labelledby='notifications-update'>
+          <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.update' defaultMessage='Edits:' /></span>
+
+          <div className='column-settings__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'update']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'update']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'update']} onChange={onChange} label={showStr} />
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'update']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js
index e1d9fbd0a..1cf205898 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/notification.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js
@@ -171,6 +171,28 @@ export default class Notification extends ImmutablePureComponent {
           unread={this.props.unread}
         />
       );
+    case 'update':
+      return (
+        <StatusContainer
+          containerId={notification.get('id')}
+          hidden={hidden}
+          id={notification.get('status')}
+          account={notification.get('account')}
+          prepend='update'
+          muted
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
+          cachedMediaWidth={this.props.cachedMediaWidth}
+          cacheMediaWidth={this.props.cacheMediaWidth}
+          onUnmount={this.props.onUnmount}
+          withDismiss
+          unread={this.props.unread}
+        />
+      );
     default:
       return null;
     }
diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js
index eb4583026..a67a045da 100644
--- a/app/javascript/flavours/glitch/features/status/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js
@@ -11,6 +11,7 @@ import classNames from 'classnames';
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
   redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
+  edit: { id: 'status.edit', defaultMessage: 'Edit' },
   direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
   mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
@@ -52,6 +53,7 @@ class ActionBar extends React.PureComponent {
     onMuteConversation: PropTypes.func,
     onBlock: PropTypes.func,
     onDelete: PropTypes.func.isRequired,
+    onEdit: PropTypes.func.isRequired,
     onDirect: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
     onReport: PropTypes.func,
@@ -84,6 +86,10 @@ class ActionBar extends React.PureComponent {
     this.props.onDelete(this.props.status, this.context.router.history, true);
   }
 
+  handleEditClick = () => {
+    this.props.onEdit(this.props.status, this.context.router.history);
+  }
+
   handleDirectClick = () => {
     this.props.onDirect(this.props.status.get('account'), this.context.router.history);
   }
@@ -166,6 +172,7 @@ class ActionBar extends React.PureComponent {
 
       menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
       menu.push(null);
+      // menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
       menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
     } else {
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index 4b3a6aaaa..528d2eb73 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -7,7 +7,7 @@ import StatusContent from 'flavours/glitch/components/status_content';
 import MediaGallery from 'flavours/glitch/components/media_gallery';
 import AttachmentList from 'flavours/glitch/components/attachment_list';
 import { Link } from 'react-router-dom';
-import { injectIntl, FormattedDate, FormattedMessage } from 'react-intl';
+import { injectIntl, FormattedDate } from 'react-intl';
 import Card from './card';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Video from 'flavours/glitch/features/video';
@@ -19,6 +19,7 @@ import PollContainer from 'flavours/glitch/containers/poll_container';
 import Icon from 'flavours/glitch/components/icon';
 import AnimatedNumber from 'flavours/glitch/components/animated_number';
 import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
+import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
 
 export default @injectIntl
 class DetailedStatus extends ImmutablePureComponent {
@@ -265,7 +266,7 @@ class DetailedStatus extends ImmutablePureComponent {
       edited = (
         <React.Fragment>
           <React.Fragment> · </React.Fragment>
-          <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(status.get('edited_at'), { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} />
+          <EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} />
         </React.Fragment>
       );
     }
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 12ea407ad..653fabeae 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -26,7 +26,7 @@ import {
   directCompose,
 } from 'flavours/glitch/actions/compose';
 import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
-import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses';
+import { muteStatus, unmuteStatus, deleteStatus, editStatus } from 'flavours/glitch/actions/statuses';
 import { initMuteModal } from 'flavours/glitch/actions/mutes';
 import { initBlockModal } from 'flavours/glitch/actions/blocks';
 import { initReport } from 'flavours/glitch/actions/reports';
@@ -307,6 +307,10 @@ class Status extends ImmutablePureComponent {
     }
   }
 
+  handleEditClick = (status, history) => {
+    this.props.dispatch(editStatus(status.get('id'), history));
+  }
+
   handleDirectClick = (account, router) => {
     this.props.dispatch(directCompose(account, router));
   }
@@ -585,6 +589,7 @@ class Status extends ImmutablePureComponent {
                   onReblog={this.handleReblogClick}
                   onBookmark={this.handleBookmarkClick}
                   onDelete={this.handleDeleteClick}
+                  onEdit={this.handleEditClick}
                   onDirect={this.handleDirectClick}
                   onMention={this.handleMentionClick}
                   onMute={this.handleMuteClick}
diff --git a/app/javascript/flavours/glitch/features/ui/components/actions_modal.js b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js
index 24169036c..aae2e4426 100644
--- a/app/javascript/flavours/glitch/features/ui/components/actions_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js
@@ -7,24 +7,22 @@ import Avatar from 'flavours/glitch/components/avatar';
 import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
 import DisplayName from 'flavours/glitch/components/display_name';
 import classNames from 'classnames';
-import Icon from 'flavours/glitch/components/icon';
-import Link from 'flavours/glitch/components/link';
-import Toggle from 'react-toggle';
+import IconButton from 'flavours/glitch/components/icon_button';
 
 export default class ActionsModal extends ImmutablePureComponent {
 
   static propTypes = {
     status: ImmutablePropTypes.map,
+    onClick: PropTypes.func,
     actions: PropTypes.arrayOf(PropTypes.shape({
       active: PropTypes.bool,
       href: PropTypes.string,
       icon: PropTypes.string,
-      meta: PropTypes.node,
+      meta: PropTypes.string,
       name: PropTypes.string,
-      on: PropTypes.bool,
-      onPassiveClick: PropTypes.func,
-      text: PropTypes.node,
+      text: PropTypes.string,
     })),
+    renderItemContents: PropTypes.func,
   };
 
   renderAction = (action, i) => {
@@ -32,57 +30,26 @@ export default class ActionsModal extends ImmutablePureComponent {
       return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
     }
 
-    const {
-      active,
-      href,
-      icon,
-      meta,
-      name,
-      on,
-      onClick,
-      onPassiveClick,
-      text,
-    } = action;
+    const { icon = null, text, meta = null, active = false, href = '#' } = action;
+    let contents = this.props.renderItemContents && this.props.renderItemContents(action, i);
 
-    return (
-      <li key={name || i}>
-        <Link
-          className={classNames('link', { active })}
-          href={href}
-          onClick={on !== null && typeof on !== 'undefined' && onPassiveClick || onClick}
-          role={onClick ? 'button' : null}
-        >
-          {function () {
+    if (!contents) {
+      contents = (
+        <React.Fragment>
+          {icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' inverted />}
+          <div>
+            <div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
+            <div>{meta}</div>
+          </div>
+        </React.Fragment>
+      );
+    }
 
-            //  We render a `<Toggle>` if we were provided an `on`
-            //  property, and otherwise show an `<Icon>` if available.
-            switch (true) {
-            case on !== null && typeof on !== 'undefined':
-              return (
-                <Toggle
-                  checked={on}
-                  onChange={onPassiveClick || onClick}
-                />
-              );
-            case !!icon:
-              return (
-                <Icon
-                  className='icon'
-                  fixedWidth
-                  id={icon}
-                />
-              );
-            default:
-              return null;
-            }
-          }()}
-          {meta ? (
-            <div>
-              <strong>{text}</strong>
-              {meta}
-            </div>
-          ) : <div>{text}</div>}
-        </Link>
+    return (
+      <li key={`${text}-${i}`}>
+        <a href={href} target='_blank' rel='noopener noreferrer' onClick={this.props.onClick} data-index={i} className={classNames('link', { active })}>
+          {contents}
+        </a>
       </li>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.js b/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.js
new file mode 100644
index 000000000..198443221
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.js
@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { FormattedMessage } from 'react-intl';
+import { closeModal } from 'flavours/glitch/actions/modal';
+import emojify from 'flavours/glitch/util/emoji';
+import escapeTextContentForBrowser from 'escape-html';
+import InlineAccount from 'flavours/glitch/components/inline_account';
+import IconButton from 'flavours/glitch/components/icon_button';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+
+const mapStateToProps = (state, { statusId }) => ({
+  versions: state.getIn(['history', statusId, 'items']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onClose() {
+    dispatch(closeModal());
+  },
+
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+class CompareHistoryModal extends React.PureComponent {
+
+  static propTypes = {
+    onClose: PropTypes.func.isRequired,
+    index: PropTypes.number.isRequired,
+    statusId: PropTypes.string.isRequired,
+    versions: ImmutablePropTypes.list.isRequired,
+  };
+
+  render () {
+    const { index, versions, onClose } = this.props;
+    const currentVersion = versions.get(index);
+
+    const emojiMap = currentVersion.get('emojis').reduce((obj, emoji) => {
+      obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
+      return obj;
+    }, {});
+
+    const content = { __html: emojify(currentVersion.get('content'), emojiMap) };
+    const spoilerContent = { __html: emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap) };
+
+    const formattedDate = <RelativeTimestamp timestamp={currentVersion.get('created_at')} short={false} />;
+    const formattedName = <InlineAccount accountId={currentVersion.get('account')} />;
+
+    const label = currentVersion.get('original') ? (
+      <FormattedMessage id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} />
+    ) : (
+      <FormattedMessage id='status.history.edited' defaultMessage='{name} edited {date}' values={{ name: formattedName, date: formattedDate }} />
+    );
+
+    return (
+      <div className='modal-root__modal compare-history-modal'>
+        <div className='report-modal__target'>
+          <IconButton className='report-modal__close' icon='times' onClick={onClose} size={20} />
+          {label}
+        </div>
+
+        <div className='compare-history-modal__container'>
+          <div className='status__content'>
+            {currentVersion.get('spoiler_text').length > 0 && (
+              <React.Fragment>
+                <div className='translate' dangerouslySetInnerHTML={spoilerContent} />
+                <hr />
+              </React.Fragment>
+            )}
+
+            <div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
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 62bb167a0..1e065c171 100644
--- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
@@ -24,6 +24,7 @@ import {
   ListEditor,
   ListAdder,
   PinnedAccountsEditor,
+  CompareHistoryModal,
 } from 'flavours/glitch/util/async-components';
 
 const MODAL_COMPONENTS = {
@@ -42,9 +43,10 @@ const MODAL_COMPONENTS = {
   'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
   'EMBED': EmbedModal,
   'LIST_EDITOR': ListEditor,
-  'LIST_ADDER':ListAdder,
   'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
+  'LIST_ADDER': ListAdder,
   'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor,
+  'COMPARE_HISTORY': CompareHistoryModal,
 };
 
 export default class ModalRoot extends React.PureComponent {