about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch
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
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')
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js51
-rw-r--r--app/javascript/flavours/glitch/actions/history.js37
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js13
-rw-r--r--app/javascript/flavours/glitch/actions/statuses.js37
-rw-r--r--app/javascript/flavours/glitch/components/dropdown_menu.js158
-rw-r--r--app/javascript/flavours/glitch/components/edited_timestamp/containers/dropdown_menu_container.js27
-rw-r--r--app/javascript/flavours/glitch/components/edited_timestamp/index.js70
-rw-r--r--app/javascript/flavours/glitch/components/inline_account.js34
-rw-r--r--app/javascript/flavours/glitch/components/loading_indicator.js27
-rw-r--r--app/javascript/flavours/glitch/components/relative_timestamp.js23
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js6
-rw-r--r--app/javascript/flavours/glitch/components/status_prepend.js11
-rw-r--r--app/javascript/flavours/glitch/containers/dropdown_menu_container.js10
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js6
-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
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js33
-rw-r--r--app/javascript/flavours/glitch/reducers/history.js28
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js3
-rw-r--r--app/javascript/flavours/glitch/styles/admin.scss41
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss225
-rw-r--r--app/javascript/flavours/glitch/styles/components/modal.scss3
-rw-r--r--app/javascript/flavours/glitch/theme.yml4
-rw-r--r--app/javascript/flavours/glitch/util/async-components.js4
42 files changed, 1051 insertions, 414 deletions
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 261c72b2a..baa98e98f 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -75,6 +75,8 @@ 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_SET_STATUS = 'COMPOSE_SET_STATUS';
+
 const messages = defineMessages({
   uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
   uploadErrorPoll:  { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
@@ -88,6 +90,15 @@ export const ensureComposeIsVisible = (getState, routerHistory) => {
   }
 };
 
+export function setComposeToStatus(status, text, spoiler_text) {
+  return{
+    type: COMPOSE_SET_STATUS,
+    status,
+    text,
+    spoiler_text,
+  };
+};
+
 export function changeCompose(text) {
   return {
     type: COMPOSE_CHANGE,
@@ -150,8 +161,9 @@ export function directCompose(account, routerHistory) {
 
 export function submitCompose(routerHistory) {
   return function (dispatch, getState) {
-    let status = getState().getIn(['compose', 'text'], '');
-    let media  = getState().getIn(['compose', 'media_attachments']);
+    let status     = getState().getIn(['compose', 'text'], '');
+    const media    = getState().getIn(['compose', 'media_attachments']);
+    const statusId = getState().getIn(['compose', 'id'], null);
     const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']);
     let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
 
@@ -159,20 +171,25 @@ export function submitCompose(routerHistory) {
       return;
     }
 
-    dispatch(submitComposeRequest());
     if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
       status = status + ' 👁️';
     }
-    api(getState).post('/api/v1/statuses', {
-      status,
-      content_type: getState().getIn(['compose', 'content_type']),
-      in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
-      media_ids: media.map(item => item.get('id')),
-      sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
-      spoiler_text: spoilerText,
-      visibility: getState().getIn(['compose', 'privacy']),
-      poll: getState().getIn(['compose', 'poll'], null),
-    }, {
+
+    dispatch(submitComposeRequest());
+
+    api(getState).request({
+      url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
+      method: statusId === null ? 'post' : 'put',
+      data: {
+        status,
+        content_type: getState().getIn(['compose', 'content_type']),
+        in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
+        media_ids: media.map(item => item.get('id')),
+        sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
+        spoiler_text: spoilerText,
+        visibility: getState().getIn(['compose', 'privacy']),
+        poll: getState().getIn(['compose', 'poll'], null),
+      },
       headers: {
         'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
       },
@@ -202,14 +219,16 @@ export function submitCompose(routerHistory) {
         }
       };
 
-      insertIfOnline('home');
+      if (statusId === null) {
+        insertIfOnline('home');
+      }
 
-      if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
+      if (statusId === null && response.data.in_reply_to_id === null && response.data.visibility === 'public') {
         insertIfOnline('community');
         if (!response.data.local_only) {
           insertIfOnline('public');
         }
-      } else if (response.data.visibility === 'direct') {
+      } else if (statusId === null && response.data.visibility === 'direct') {
         insertIfOnline('direct');
       }
     }).catch(function (error) {
diff --git a/app/javascript/flavours/glitch/actions/history.js b/app/javascript/flavours/glitch/actions/history.js
new file mode 100644
index 000000000..c47057261
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/history.js
@@ -0,0 +1,37 @@
+import api from 'flavours/glitch/util/api';
+import { importFetchedAccounts } from './importer';
+
+export const HISTORY_FETCH_REQUEST = 'HISTORY_FETCH_REQUEST';
+export const HISTORY_FETCH_SUCCESS = 'HISTORY_FETCH_SUCCESS';
+export const HISTORY_FETCH_FAIL    = 'HISTORY_FETCH_FAIL';
+
+export const fetchHistory = statusId => (dispatch, getState) => {
+  const loading = getState().getIn(['history', statusId, 'loading']);
+
+  if (loading) {
+    return;
+  }
+
+  dispatch(fetchHistoryRequest(statusId));
+
+  api(getState).get(`/api/v1/statuses/${statusId}/history`).then(({ data }) => {
+    dispatch(importFetchedAccounts(data.map(x => x.account)));
+    dispatch(fetchHistorySuccess(statusId, data));
+  }).catch(error => dispatch(fetchHistoryFail(error)));
+};
+
+export const fetchHistoryRequest = statusId => ({
+  type: HISTORY_FETCH_REQUEST,
+  statusId,
+});
+
+export const fetchHistorySuccess = (statusId, history) => ({
+  type: HISTORY_FETCH_SUCCESS,
+  statusId,
+  history,
+});
+
+export const fetchHistoryFail = error => ({
+  type: HISTORY_FETCH_FAIL,
+  error,
+});
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index 4b00ea632..40430102c 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -47,7 +47,6 @@ export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
 
 export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY';
 
-
 export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
 
 export const NOTIFICATIONS_SET_BROWSER_SUPPORT    = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
@@ -136,7 +135,17 @@ const excludeTypesFromSettings = state => state.getIn(['settings', 'notification
 
 
 const excludeTypesFromFilter = filter => {
-  const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll']);
+  const allTypes = ImmutableList([
+    'follow',
+    'follow_request',
+    'favourite',
+    'reblog',
+    'mention',
+    'poll',
+    'status',
+    'update',
+  ]);
+
   return allTypes.filterNot(item => item === filter).toJS();
 };
 
diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js
index 7db357df1..6ffcf181d 100644
--- a/app/javascript/flavours/glitch/actions/statuses.js
+++ b/app/javascript/flavours/glitch/actions/statuses.js
@@ -2,7 +2,7 @@ import api from 'flavours/glitch/util/api';
 
 import { deleteFromTimelines } from './timelines';
 import { importFetchedStatus, importFetchedStatuses } from './importer';
-import { ensureComposeIsVisible } from './compose';
+import { ensureComposeIsVisible, setComposeToStatus } from './compose';
 
 export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
 export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@@ -26,6 +26,10 @@ export const STATUS_UNMUTE_FAIL    = 'STATUS_UNMUTE_FAIL';
 
 export const REDRAFT = 'REDRAFT';
 
+export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST';
+export const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS';
+export const STATUS_FETCH_SOURCE_FAIL    = 'STATUS_FETCH_SOURCE_FAIL';
+
 export function fetchStatusRequest(id, skipLoading) {
   return {
     type: STATUS_FETCH_REQUEST,
@@ -81,6 +85,37 @@ export function redraft(status, raw_text, content_type) {
   };
 };
 
+export const editStatus = (id, routerHistory) => (dispatch, getState) => {
+  let status = getState().getIn(['statuses', id]);
+
+  if (status.get('poll')) {
+    status = status.set('poll', getState().getIn(['polls', status.get('poll')]));
+  }
+
+  dispatch(fetchStatusSourceRequest());
+
+  api(getState).get(`/api/v1/statuses/${id}/source`).then(response => {
+    dispatch(fetchStatusSourceSuccess());
+    ensureComposeIsVisible(getState, routerHistory);
+    dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text));
+  }).catch(error => {
+    dispatch(fetchStatusSourceFail(error));
+  });
+};
+
+export const fetchStatusSourceRequest = () => ({
+  type: STATUS_FETCH_SOURCE_REQUEST,
+});
+
+export const fetchStatusSourceSuccess = () => ({
+  type: STATUS_FETCH_SOURCE_SUCCESS,
+});
+
+export const fetchStatusSourceFail = error => ({
+  type: STATUS_FETCH_SOURCE_FAIL,
+  error,
+});
+
 export function deleteStatus(id, routerHistory, withRedraft = false) {
   return (dispatch, getState) => {
     let status = getState().getIn(['statuses', id]);
diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js
index 023fecb9a..bd6bc4ffd 100644
--- a/app/javascript/flavours/glitch/components/dropdown_menu.js
+++ b/app/javascript/flavours/glitch/components/dropdown_menu.js
@@ -6,6 +6,8 @@ import Overlay from 'react-overlays/lib/Overlay';
 import Motion from 'flavours/glitch/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import { supportsPassiveEvents } from 'detect-passive-events';
+import classNames from 'classnames';
+import { CircularProgress } from 'mastodon/components/loading_indicator';
 
 const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 let id = 0;
@@ -17,13 +19,18 @@ class DropdownMenu extends React.PureComponent {
   };
 
   static propTypes = {
-    items: PropTypes.array.isRequired,
+    items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
+    loading: PropTypes.bool,
+    scrollable: PropTypes.bool,
     onClose: PropTypes.func.isRequired,
     style: PropTypes.object,
     placement: PropTypes.string,
     arrowOffsetLeft: PropTypes.string,
     arrowOffsetTop: PropTypes.string,
     openedViaKeyboard: PropTypes.bool,
+    renderItem: PropTypes.func,
+    renderHeader: PropTypes.func,
+    onItemClick: PropTypes.func.isRequired,
   };
 
   static defaultProps = {
@@ -45,9 +52,11 @@ class DropdownMenu extends React.PureComponent {
     document.addEventListener('click', this.handleDocumentClick, false);
     document.addEventListener('keydown', this.handleKeyDown, false);
     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+
     if (this.focusedItem && this.props.openedViaKeyboard) {
       this.focusedItem.focus({ preventScroll: true });
     }
+
     this.setState({ mounted: true });
   }
 
@@ -66,7 +75,7 @@ class DropdownMenu extends React.PureComponent {
   }
 
   handleKeyDown = e => {
-    const items = Array.from(this.node.getElementsByTagName('a'));
+    const items = Array.from(this.node.querySelectorAll('a, button'));
     const index = items.indexOf(document.activeElement);
     let element = null;
 
@@ -109,30 +118,20 @@ class DropdownMenu extends React.PureComponent {
   }
 
   handleClick = e => {
-    const i = Number(e.currentTarget.getAttribute('data-index'));
-    const { action, to } = this.props.items[i];
-
-    this.props.onClose();
-
-    if (typeof action === 'function') {
-      e.preventDefault();
-      action();
-    } else if (to) {
-      e.preventDefault();
-      this.context.router.history.push(to);
-    }
+    const { onItemClick } = this.props;
+    onItemClick(e);
   }
 
-  renderItem (option, i) {
+  renderItem = (option, i) => {
     if (option === null) {
       return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
     }
 
-    const { text, href = '#' } = option;
+    const { text, href = '#', target = '_blank', method } = option;
 
     return (
       <li className='dropdown-menu__item' key={`${text}-${i}`}>
-        <a href={href} target='_blank' rel='noopener noreferrer' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
+        <a href={href} target={target} data-method={method} rel='noopener noreferrer' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
           {text}
         </a>
       </li>
@@ -140,21 +139,37 @@ class DropdownMenu extends React.PureComponent {
   }
 
   render () {
-    const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
+    const { items, style, placement, arrowOffsetLeft, arrowOffsetTop, scrollable, renderHeader, loading } = this.props;
     const { mounted } = this.state;
 
+    let renderItem = this.props.renderItem || this.renderItem;
+
     return (
       <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
         {({ opacity, scaleX, scaleY }) => (
           // It should not be transformed when mounting because the resulting
           // size will be used to determine the coordinate of the menu by
           // react-overlays
-          <div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
+          <div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
             <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
 
-            <ul>
-              {items.map((option, i) => this.renderItem(option, i))}
-            </ul>
+            <div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })}>
+              {loading && (
+                <CircularProgress size={30} strokeWidth={3.5} />
+              )}
+
+              {!loading && renderHeader && (
+                <div className='dropdown-menu__container__header'>
+                  {renderHeader(items)}
+                </div>
+              )}
+
+              {!loading && (
+                <ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}>
+                  {items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
+                </ul>
+              )}
+            </div>
           </div>
         )}
       </Motion>
@@ -170,11 +185,14 @@ export default class Dropdown extends React.PureComponent {
   };
 
   static propTypes = {
-    icon: PropTypes.string.isRequired,
-    items: PropTypes.array.isRequired,
-    size: PropTypes.number.isRequired,
+    children: PropTypes.node,
+    icon: PropTypes.string,
+    items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
+    loading: PropTypes.bool,
+    size: PropTypes.number,
     title: PropTypes.string,
     disabled: PropTypes.bool,
+    scrollable: PropTypes.bool,
     status: ImmutablePropTypes.map,
     isUserTouching: PropTypes.func,
     onOpen: PropTypes.func.isRequired,
@@ -182,6 +200,9 @@ export default class Dropdown extends React.PureComponent {
     dropdownPlacement: PropTypes.string,
     openDropdownId: PropTypes.number,
     openedViaKeyboard: PropTypes.bool,
+    renderItem: PropTypes.func,
+    renderHeader: PropTypes.func,
+    onItemClick: PropTypes.func,
   };
 
   static defaultProps = {
@@ -236,17 +257,22 @@ export default class Dropdown extends React.PureComponent {
     }
   }
 
-  handleItemClick = (i, e) => {
-    const { action, to } = this.props.items[i];
+  handleItemClick = e => {
+    const { onItemClick } = this.props;
+    const i = Number(e.currentTarget.getAttribute('data-index'));
+    const item = this.props.items[i];
 
     this.handleClose();
 
-    if (typeof action === 'function') {
+    if (typeof onItemClick === 'function') {
+      e.preventDefault();
+      onItemClick(item, i);
+    } else if (item && typeof item.action === 'function') {
       e.preventDefault();
-      action();
-    } else if (to) {
+      item.action();
+    } else if (item && item.to) {
       e.preventDefault();
-      this.context.router.history.push(to);
+      this.context.router.history.push(item.to);
     }
   }
 
@@ -264,29 +290,67 @@ export default class Dropdown extends React.PureComponent {
     }
   }
 
+  close = () => {
+    this.handleClose();
+  }
+
   render () {
-    const { icon, items, size, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard } = this.props;
+    const {
+      icon,
+      items,
+      size,
+      title,
+      disabled,
+      loading,
+      scrollable,
+      dropdownPlacement,
+      openDropdownId,
+      openedViaKeyboard,
+      children,
+      renderItem,
+      renderHeader,
+    } = this.props;
+
     const open = this.state.id === openDropdownId;
 
+    const button = children ? React.cloneElement(React.Children.only(children), {
+      ref: this.setTargetRef,
+      onClick: this.handleClick,
+      onMouseDown: this.handleMouseDown,
+      onKeyDown: this.handleButtonKeyDown,
+      onKeyPress: this.handleKeyPress,
+    }) : (
+      <IconButton
+        icon={icon}
+        title={title}
+        active={open}
+        disabled={disabled}
+        size={size}
+        ref={this.setTargetRef}
+        onClick={this.handleClick}
+        onMouseDown={this.handleMouseDown}
+        onKeyDown={this.handleButtonKeyDown}
+        onKeyPress={this.handleKeyPress}
+      />
+    );
+
     return (
-      <div>
-        <IconButton
-          icon={icon}
-          title={title}
-          active={open}
-          disabled={disabled}
-          size={size}
-          ref={this.setTargetRef}
-          onClick={this.handleClick}
-          onMouseDown={this.handleMouseDown}
-          onKeyDown={this.handleButtonKeyDown}
-          onKeyPress={this.handleKeyPress}
-        />
+      <React.Fragment>
+        {button}
 
         <Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
-          <DropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} />
+          <DropdownMenu
+            items={items}
+            loading={loading}
+            scrollable={scrollable}
+            onClose={this.handleClose}
+            openedViaKeyboard={openedViaKeyboard}
+            renderItem={renderItem}
+            renderHeader={renderHeader}
+            onItemClick={this.handleItemClick}
+          />
         </Overlay>
-      </div>
+      </React.Fragment>
     );
   }
 
diff --git a/app/javascript/flavours/glitch/components/edited_timestamp/containers/dropdown_menu_container.js b/app/javascript/flavours/glitch/components/edited_timestamp/containers/dropdown_menu_container.js
new file mode 100644
index 000000000..8b73663d4
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/edited_timestamp/containers/dropdown_menu_container.js
@@ -0,0 +1,27 @@
+import { connect } from 'react-redux';
+import { openDropdownMenu, closeDropdownMenu } from 'flavours/glitch/actions/dropdown_menu';
+import { fetchHistory } from 'flavours/glitch/actions/history';
+import DropdownMenu from 'flavours/glitch/components/dropdown_menu';
+
+const mapStateToProps = (state, { statusId }) => ({
+  dropdownPlacement: state.getIn(['dropdown_menu', 'placement']),
+  openDropdownId: state.getIn(['dropdown_menu', 'openId']),
+  openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
+  items: state.getIn(['history', statusId, 'items']),
+  loading: state.getIn(['history', statusId, 'loading']),
+});
+
+const mapDispatchToProps = (dispatch, { statusId }) => ({
+
+  onOpen (id, onItemClick, dropdownPlacement, keyboard) {
+    dispatch(fetchHistory(statusId));
+    dispatch(openDropdownMenu(id, dropdownPlacement, keyboard));
+  },
+
+  onClose (id) {
+    dispatch(closeDropdownMenu(id));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
diff --git a/app/javascript/flavours/glitch/components/edited_timestamp/index.js b/app/javascript/flavours/glitch/components/edited_timestamp/index.js
new file mode 100644
index 000000000..9648133af
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/edited_timestamp/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+import DropdownMenu from './containers/dropdown_menu_container';
+import { connect } from 'react-redux';
+import { openModal } from 'flavours/glitch/actions/modal';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+import InlineAccount from 'flavours/glitch/components/inline_account';
+
+const mapDispatchToProps = (dispatch, { statusId }) => ({
+
+  onItemClick (index) {
+    dispatch(openModal('COMPARE_HISTORY', { index, statusId }));
+  },
+
+});
+
+export default @connect(null, mapDispatchToProps)
+@injectIntl
+class EditedTimestamp extends React.PureComponent {
+
+  static propTypes = {
+    statusId: PropTypes.string.isRequired,
+    timestamp: PropTypes.string.isRequired,
+    intl: PropTypes.object.isRequired,
+    onItemClick: PropTypes.func.isRequired,
+  };
+
+  handleItemClick = (item, i) => {
+    const { onItemClick } = this.props;
+    onItemClick(i);
+  };
+
+  renderHeader = items => {
+    return (
+      <FormattedMessage id='status.edited_x_times' defaultMessage='Edited {count, plural, one {{count} time} other {{count} times}}' values={{ count: items.size - 1 }} />
+    );
+  }
+
+  renderItem = (item, index, { onClick, onKeyPress }) => {
+    const formattedDate = <RelativeTimestamp timestamp={item.get('created_at')} short={false} />;
+    const formattedName = <InlineAccount accountId={item.get('account')} />;
+
+    const label = item.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 (
+      <li className='dropdown-menu__item edited-timestamp__history__item' key={item.get('created_at')}>
+        <button data-index={index} onClick={onClick} onKeyPress={onKeyPress}>{label}</button>
+      </li>
+    );
+  }
+
+  render () {
+    const { timestamp, intl, statusId } = this.props;
+
+    return (
+      <DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
+        <button className='dropdown-menu__text-button'>
+          <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(timestamp, { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} /> <Icon id='caret-down' />
+        </button>
+      </DropdownMenu>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/inline_account.js b/app/javascript/flavours/glitch/components/inline_account.js
new file mode 100644
index 000000000..2ef1f52cc
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/inline_account.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import Avatar from 'flavours/glitch/components/avatar';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId }) => ({
+    account: getAccount(state, accountId),
+  });
+
+  return mapStateToProps;
+};
+
+export default @connect(makeMapStateToProps)
+class InlineAccount extends React.PureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { account } = this.props;
+
+    return (
+      <span className='inline-account'>
+        <Avatar size={13} account={account} /> <strong>{account.get('username')}</strong>
+      </span>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/loading_indicator.js b/app/javascript/flavours/glitch/components/loading_indicator.js
index d6a5adb6f..59f721c50 100644
--- a/app/javascript/flavours/glitch/components/loading_indicator.js
+++ b/app/javascript/flavours/glitch/components/loading_indicator.js
@@ -1,10 +1,31 @@
 import React from 'react';
-import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export const CircularProgress = ({ size, strokeWidth }) => {
+  const viewBox = `0 0 ${size} ${size}`;
+  const radius  = (size - strokeWidth) / 2;
+
+  return (
+    <svg width={size} heigh={size} viewBox={viewBox} className='circular-progress' role='progressbar'>
+      <circle
+        fill='none'
+        cx={size / 2}
+        cy={size / 2}
+        r={radius}
+        strokeWidth={`${strokeWidth}px`}
+      />
+    </svg>
+  );
+};
+
+CircularProgress.propTypes = {
+  size: PropTypes.number.isRequired,
+  strokeWidth: PropTypes.number.isRequired,
+};
 
 const LoadingIndicator = () => (
   <div className='loading-indicator'>
-    <div className='loading-indicator__figure' />
-    <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
+    <CircularProgress size={50} strokeWidth={6} />
   </div>
 );
 
diff --git a/app/javascript/flavours/glitch/components/relative_timestamp.js b/app/javascript/flavours/glitch/components/relative_timestamp.js
index 711181dcd..512480339 100644
--- a/app/javascript/flavours/glitch/components/relative_timestamp.js
+++ b/app/javascript/flavours/glitch/components/relative_timestamp.js
@@ -5,10 +5,15 @@ import PropTypes from 'prop-types';
 const messages = defineMessages({
   today: { id: 'relative_time.today', defaultMessage: 'today' },
   just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
+  just_now_full: { id: 'relative_time.full.just_now', defaultMessage: 'just now' },
   seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
+  seconds_full: { id: 'relative_time.full.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} ago' },
   minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
+  minutes_full: { id: 'relative_time.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago' },
   hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
+  hours_full: { id: 'relative_time.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} ago' },
   days: { id: 'relative_time.days', defaultMessage: '{number}d' },
+  days_full: { id: 'relative_time.full.days', defaultMessage: '{number, plural, one {# day} other {# days}} ago' },
   moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
   seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
   minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' },
@@ -66,7 +71,7 @@ const getUnitDelay = units => {
   }
 };
 
-export const timeAgoString = (intl, date, now, year, timeGiven = true) => {
+export const timeAgoString = (intl, date, now, year, timeGiven, short) => {
   const delta = now - date.getTime();
 
   let relativeTime;
@@ -74,16 +79,16 @@ export const timeAgoString = (intl, date, now, year, timeGiven = true) => {
   if (delta < DAY && !timeGiven) {
     relativeTime = intl.formatMessage(messages.today);
   } else if (delta < 10 * SECOND) {
-    relativeTime = intl.formatMessage(messages.just_now);
+    relativeTime = intl.formatMessage(short ? messages.just_now : messages.just_now_full);
   } else if (delta < 7 * DAY) {
     if (delta < MINUTE) {
-      relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
+      relativeTime = intl.formatMessage(short ? messages.seconds : messages.seconds_full, { number: Math.floor(delta / SECOND) });
     } else if (delta < HOUR) {
-      relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) });
+      relativeTime = intl.formatMessage(short ? messages.minutes : messages.minutes_full, { number: Math.floor(delta / MINUTE) });
     } else if (delta < DAY) {
-      relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) });
+      relativeTime = intl.formatMessage(short ? messages.hours : messages.hours_full, { number: Math.floor(delta / HOUR) });
     } else {
-      relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
+      relativeTime = intl.formatMessage(short ? messages.days : messages.days_full, { number: Math.floor(delta / DAY) });
     }
   } else if (date.getFullYear() === year) {
     relativeTime = intl.formatDate(date, shortDateFormatOptions);
@@ -124,6 +129,7 @@ class RelativeTimestamp extends React.Component {
     timestamp: PropTypes.string.isRequired,
     year: PropTypes.number.isRequired,
     futureDate: PropTypes.bool,
+    short: PropTypes.bool,
   };
 
   state = {
@@ -132,6 +138,7 @@ class RelativeTimestamp extends React.Component {
 
   static defaultProps = {
     year: (new Date()).getFullYear(),
+    short: true,
   };
 
   shouldComponentUpdate (nextProps, nextState) {
@@ -176,11 +183,11 @@ class RelativeTimestamp extends React.Component {
   }
 
   render () {
-    const { timestamp, intl, year, futureDate } = this.props;
+    const { timestamp, intl, year, futureDate, short } = this.props;
 
     const timeGiven    = timestamp.includes('T');
     const date         = new Date(timestamp);
-    const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven);
+    const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven, short);
 
     return (
       <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index ae67c6116..68d93cd67 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -13,6 +13,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}' },
   mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
@@ -126,6 +127,10 @@ class StatusActionBar extends ImmutablePureComponent {
     this.props.onDelete(this.props.status, this.context.router.history, true);
   }
 
+  handleEditClick = () => {
+    this.props.onEdit(this.props.status, this.context.router.history);
+  }
+
   handlePinClick = () => {
     this.props.onPin(this.props.status);
   }
@@ -225,6 +230,7 @@ class StatusActionBar extends ImmutablePureComponent {
     }
 
     if (writtenByMe) {
+      // 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/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js
index 5a00f232e..1661ca8f5 100644
--- a/app/javascript/flavours/glitch/components/status_prepend.js
+++ b/app/javascript/flavours/glitch/components/status_prepend.js
@@ -88,6 +88,14 @@ export default class StatusPrepend extends React.PureComponent {
           />
         );
       }
+    case 'update':
+      return (
+        <FormattedMessage
+          id='notification.update'
+          defaultMessage='{name} edited a post'
+          values={{ name: link }}
+        />
+      );
     }
     return null;
   }
@@ -115,6 +123,9 @@ export default class StatusPrepend extends React.PureComponent {
     case 'status':
       iconId = 'bell';
       break;
+    case 'update':
+      iconId = 'pencil';
+      break;
     };
 
     return !type ? null : (
diff --git a/app/javascript/flavours/glitch/containers/dropdown_menu_container.js b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js
index 1c0385b74..0c4a2b50f 100644
--- a/app/javascript/flavours/glitch/containers/dropdown_menu_container.js
+++ b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js
@@ -14,15 +14,11 @@ const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
   onOpen(id, onItemClick, dropdownPlacement, keyboard) {
     dispatch(isUserTouching() ? openModal('ACTIONS', {
       status,
-      actions: items.map(
-        (item, i) => item ? {
-          ...item,
-          name: `${item.text}-${i}`,
-          onClick: item.action ? ((e) => { return onItemClick(i, e) }) : null,
-        } : null
-      ),
+      actions: items,
+      onClick: onItemClick,
     }) : openDropdownMenu(id, dropdownPlacement, keyboard, scrollKey));
   },
+
   onClose(id) {
     dispatch(closeModal('ACTIONS'));
     dispatch(closeDropdownMenu(id));
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
index bc3c43d85..358b89ab9 100644
--- a/app/javascript/flavours/glitch/containers/status_container.js
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -17,7 +17,7 @@ import {
   pin,
   unpin,
 } from 'flavours/glitch/actions/interactions';
-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';
@@ -169,6 +169,10 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
     }
   },
 
+  onEdit (status, history) {
+    dispatch(editStatus(status.get('id'), history));
+  },
+
   onDirect (account, router) {
     dispatch(directCompose(account, router));
   },
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 {
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index d2ea0a924..f97c799e7 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -46,6 +46,7 @@ import {
   INIT_MEDIA_EDIT_MODAL,
   COMPOSE_CHANGE_MEDIA_DESCRIPTION,
   COMPOSE_CHANGE_MEDIA_FOCUS,
+  COMPOSE_SET_STATUS,
 } from 'flavours/glitch/actions/compose';
 import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
 import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
@@ -75,6 +76,7 @@ const initialState = ImmutableMap({
   spoiler: false,
   spoiler_text: '',
   privacy: null,
+  id: null,
   content_type: defaultContentType || 'text/plain',
   text: '',
   focusDate: null,
@@ -160,6 +162,7 @@ function apiStatusToTextHashtags (state, status) {
 
 function clearAll(state) {
   return state.withMutations(map => {
+    map.set('id', null);
     map.set('text', '');
     if (defaultContentType) map.set('content_type', defaultContentType);
     map.set('spoiler', false);
@@ -400,6 +403,7 @@ export default function compose(state = initialState, action) {
       .set('elefriend', (state.get('elefriend') + 1) % totalElefriends);
   case COMPOSE_REPLY:
     return state.withMutations(map => {
+      map.set('id', null);
       map.set('in_reply_to', action.status.get('id'));
       map.set('text', statusToTextMentions(state, action.status));
       map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
@@ -434,6 +438,7 @@ export default function compose(state = initialState, action) {
       map.set('spoiler', false);
       map.set('spoiler_text', '');
       map.set('privacy', state.get('default_privacy'));
+      map.set('id', null);
       map.set('poll', null);
       map.update(
         'advanced_options',
@@ -573,6 +578,34 @@ export default function compose(state = initialState, action) {
         }));
       }
     });
+  case COMPOSE_SET_STATUS:
+    return state.withMutations(map => {
+      map.set('id', action.status.get('id'));
+      map.set('text', action.text);
+      map.set('in_reply_to', action.status.get('in_reply_to_id'));
+      map.set('privacy', action.status.get('visibility'));
+      map.set('media_attachments', action.status.get('media_attachments'));
+      map.set('focusDate', new Date());
+      map.set('caretPosition', null);
+      map.set('idempotencyKey', uuid());
+      map.set('sensitive', action.status.get('sensitive'));
+
+      if (action.spoiler_text.length > 0) {
+        map.set('spoiler', true);
+        map.set('spoiler_text', action.spoiler_text);
+      } else {
+        map.set('spoiler', false);
+        map.set('spoiler_text', '');
+      }
+
+      if (action.status.get('poll')) {
+        map.set('poll', ImmutableMap({
+          options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
+          multiple: action.status.getIn(['poll', 'multiple']),
+          expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])),
+        }));
+      }
+    });
   case COMPOSE_POLL_ADD:
     return state.set('poll', initialPoll);
   case COMPOSE_POLL_REMOVE:
diff --git a/app/javascript/flavours/glitch/reducers/history.js b/app/javascript/flavours/glitch/reducers/history.js
new file mode 100644
index 000000000..04f5f2fd1
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/history.js
@@ -0,0 +1,28 @@
+import { HISTORY_FETCH_REQUEST, HISTORY_FETCH_SUCCESS, HISTORY_FETCH_FAIL } from 'flavours/glitch/actions/history';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialHistory = ImmutableMap({
+  loading: false,
+  items: ImmutableList(),
+});
+
+const initialState = ImmutableMap();
+
+export default function history(state = initialState, action) {
+  switch(action.type) {
+  case HISTORY_FETCH_REQUEST:
+    return state.update(action.statusId, initialHistory, history => history.withMutations(map => {
+      map.set('loading', true);
+      map.set('items', ImmutableList());
+    }));
+  case HISTORY_FETCH_SUCCESS:
+    return state.update(action.statusId, initialHistory, history => history.withMutations(map => {
+      map.set('loading', false);
+      map.set('items', fromJS(action.history.map((x, i) => ({ ...x, account: x.account.id, original: i === 0 })).reverse()));
+    }));
+  case HISTORY_FETCH_FAIL:
+    return state.update(action.statusId, initialHistory, history => history.set('loading', false));
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
index 7d7fe6fd3..d9123b103 100644
--- a/app/javascript/flavours/glitch/reducers/index.js
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -41,6 +41,7 @@ import markers from './markers';
 import account_notes from './account_notes';
 import picture_in_picture from './picture_in_picture';
 import accounts_map from './accounts_map';
+import history from './history';
 
 const reducers = {
   announcements,
@@ -85,6 +86,7 @@ const reducers = {
   markers,
   account_notes,
   picture_in_picture,
+  history,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js
index a53d34a83..48587ce64 100644
--- a/app/javascript/flavours/glitch/reducers/settings.js
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -40,6 +40,7 @@ const initialState = ImmutableMap({
       mention: false,
       poll: false,
       status: false,
+      update: false,
     }),
 
     quickFilter: ImmutableMap({
@@ -59,6 +60,7 @@ const initialState = ImmutableMap({
       mention: true,
       poll: true,
       status: true,
+      update: true,
     }),
 
     sounds: ImmutableMap({
@@ -69,6 +71,7 @@ const initialState = ImmutableMap({
       mention: true,
       poll: true,
       status: true,
+      update: true,
     }),
   }),
 
diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
index 92061585a..66ce92ce2 100644
--- a/app/javascript/flavours/glitch/styles/admin.scss
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -1203,6 +1203,10 @@ a.sparkline {
       }
     }
   }
+
+  @media screen and (max-width: 930px) {
+    grid-template-columns: minmax(0, 1fr);
+  }
 }
 
 .account-card {
@@ -1410,8 +1414,9 @@ a.sparkline {
     }
 
     &__button {
+      box-sizing: border-box;
       flex: 0 0 auto;
-      width: 100px;
+      width: 200px;
       padding: 15px;
       padding-right: 0;
 
@@ -1427,4 +1432,38 @@ a.sparkline {
       color: $dark-text-color;
     }
   }
+
+  @media screen and (max-width: 800px) {
+    border: 0;
+
+    &__item {
+      flex-direction: column;
+      border: 0;
+
+      &__button {
+        width: 100%;
+        padding: 15px 0;
+      }
+
+      &__description {
+        padding: 0;
+        padding-bottom: 15px;
+      }
+    }
+  }
+}
+
+.section-skip-link {
+  float: right;
+
+  a {
+    color: $ui-highlight-color;
+    text-decoration: none;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 2656890d7..656d8f25d 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -500,8 +500,47 @@
   box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
   z-index: 9999;
 
-  ul {
-    list-style: none;
+  &__text-button {
+    display: inline;
+    color: inherit;
+    background: transparent;
+    border: 0;
+    margin: 0;
+    padding: 0;
+    font-family: inherit;
+    font-size: inherit;
+    line-height: inherit;
+
+    &:focus {
+      outline: 1px dotted;
+    }
+  }
+
+  &__container {
+    &__header {
+      border-bottom: 1px solid darken($ui-secondary-color, 8%);
+      padding: 4px 14px;
+      padding-bottom: 8px;
+      font-size: 13px;
+      line-height: 18px;
+      color: $inverted-text-color;
+    }
+
+    &__list {
+      list-style: none;
+
+      &--scrollable {
+        max-height: 300px;
+        overflow-y: scroll;
+      }
+    }
+
+    &--loading {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      padding: 30px 45px;
+    }
   }
 }
 
@@ -541,18 +580,29 @@
 }
 
 .dropdown-menu__item {
-  a {
-    font-size: 13px;
-    line-height: 18px;
+  font-size: 13px;
+  line-height: 18px;
+  display: block;
+  color: $inverted-text-color;
+
+  a,
+  button {
+    font-family: inherit;
+    font-size: inherit;
+    line-height: inherit;
     display: block;
+    width: 100%;
     padding: 4px 14px;
+    border: 0;
+    margin: 0;
     box-sizing: border-box;
     text-decoration: none;
     background: $ui-secondary-color;
-    color: $inverted-text-color;
+    color: inherit;
     overflow: hidden;
     text-overflow: ellipsis;
     white-space: nowrap;
+    text-align: inherit;
 
     &:focus,
     &:hover,
@@ -564,6 +614,42 @@
   }
 }
 
+.dropdown-menu__item--text {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  padding: 4px 14px;
+}
+
+.dropdown-menu__item.edited-timestamp__history__item {
+  border-bottom: 1px solid darken($ui-secondary-color, 8%);
+
+  &:last-child {
+    border-bottom: 0;
+  }
+
+  &.dropdown-menu__item--text,
+  a,
+  button {
+    padding: 8px 14px;
+  }
+}
+
+.inline-account {
+  display: inline-flex;
+  align-items: center;
+  vertical-align: top;
+
+  .account__avatar {
+    margin-right: 5px;
+    border-radius: 50%;
+  }
+
+  strong {
+    font-weight: 600;
+  }
+}
+
 .dropdown--active .dropdown__content {
   display: block;
   line-height: 18px;
@@ -1229,36 +1315,48 @@ button.icon-button.active i.fa-retweet {
   top: 50%;
   left: 50%;
   transform: translate(-50%, -50%);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
 
-  span {
-    display: block;
-    float: left;
-    transform: translateX(-50%);
-    margin: 82px 0 0 50%;
-    white-space: nowrap;
+.circular-progress {
+  color: lighten($ui-base-color, 26%);
+  animation: 1.4s linear 0s infinite normal none running simple-rotate;
+
+  circle {
+    stroke: currentColor;
+    stroke-dasharray: 80px, 200px;
+    stroke-dashoffset: 0;
+    animation: circular-progress 1.4s ease-in-out infinite;
   }
 }
 
-.loading-indicator__figure {
-  position: absolute;
-  top: 50%;
-  left: 50%;
-  transform: translate(-50%, -50%);
-  width: 42px;
-  height: 42px;
-  box-sizing: border-box;
-  background-color: transparent;
-  border: 0 solid lighten($ui-base-color, 26%);
-  border-width: 6px;
-  border-radius: 50%;
-}
+@keyframes circular-progress {
+  0% {
+    stroke-dasharray: 1px, 200px;
+    stroke-dashoffset: 0;
+  }
+
+  50% {
+    stroke-dasharray: 100px, 200px;
+    stroke-dashoffset: -15px;
+  }
 
-.no-reduce-motion .loading-indicator span {
-  animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);
+  100% {
+    stroke-dasharray: 100px, 200px;
+    stroke-dashoffset: -125px;
+  }
 }
 
-.no-reduce-motion .loading-indicator__figure {
-  animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);
+@keyframes simple-rotate {
+  0% {
+    transform: rotate(0deg);
+  }
+
+  100% {
+    transform: rotate(360deg);
+  }
 }
 
 @keyframes spring-rotate-in {
@@ -1305,40 +1403,6 @@ button.icon-button.active i.fa-retweet {
   }
 }
 
-@keyframes loader-figure {
-  0% {
-    width: 0;
-    height: 0;
-    background-color: lighten($ui-base-color, 26%);
-  }
-
-  29% {
-    background-color: lighten($ui-base-color, 26%);
-  }
-
-  30% {
-    width: 42px;
-    height: 42px;
-    background-color: transparent;
-    border-width: 21px;
-    opacity: 1;
-  }
-
-  100% {
-    width: 42px;
-    height: 42px;
-    border-width: 0;
-    opacity: 0;
-    background-color: transparent;
-  }
-}
-
-@keyframes loader-label {
-  0% { opacity: 0.25; }
-  30% { opacity: 1; }
-  100% { opacity: 0.25; }
-}
-
 .spoiler-button {
   top: 0;
   left: 0;
@@ -1508,6 +1572,41 @@ button.icon-button.active i.fa-retweet {
   filter: none;
 }
 
+.compare-history-modal {
+  .report-modal__target {
+    border-bottom: 1px solid $ui-secondary-color;
+  }
+
+  &__container {
+    padding: 30px;
+    pointer-events: all;
+  }
+
+  .status__content {
+    color: $inverted-text-color;
+    font-size: 19px;
+    line-height: 24px;
+
+    .emojione {
+      width: 24px;
+      height: 24px;
+      margin: -1px 0 0;
+    }
+
+    a {
+      color: $highlight-text-color;
+    }
+
+    hr {
+      height: 0.25rem;
+      padding: 0;
+      background-color: $ui-secondary-color;
+      border: 0;
+      margin: 20px 0;
+    }
+  }
+}
+
 .loading-bar {
   background-color: $ui-highlight-color;
   height: 3px;
diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss
index cb776e88f..fb2445a17 100644
--- a/app/javascript/flavours/glitch/styles/components/modal.scss
+++ b/app/javascript/flavours/glitch/styles/components/modal.scss
@@ -420,7 +420,8 @@
 .report-modal,
 .actions-modal,
 .mute-modal,
-.block-modal {
+.block-modal,
+.compare-history-modal {
   background: lighten($ui-secondary-color, 8%);
   color: $inverted-text-color;
   border-radius: 8px;
diff --git a/app/javascript/flavours/glitch/theme.yml b/app/javascript/flavours/glitch/theme.yml
index ee2b699b2..f3c7fac7e 100644
--- a/app/javascript/flavours/glitch/theme.yml
+++ b/app/javascript/flavours/glitch/theme.yml
@@ -1,7 +1,9 @@
 #  (REQUIRED) The location of the pack files.
 pack:
   about: packs/about.js
-  admin: packs/admin.js
+  admin:
+    - packs/admin.js
+    - packs/public.js
   auth: packs/public.js
   common:
     filename: packs/common.js
diff --git a/app/javascript/flavours/glitch/util/async-components.js b/app/javascript/flavours/glitch/util/async-components.js
index a6c6ab0ab..8c9630eea 100644
--- a/app/javascript/flavours/glitch/util/async-components.js
+++ b/app/javascript/flavours/glitch/util/async-components.js
@@ -173,3 +173,7 @@ export function Directory () {
 export function FollowRecommendations () {
   return import(/* webpackChunkName: "features/glitch/async/follow_recommendations" */'flavours/glitch/features/follow_recommendations');
 }
+
+export function CompareHistoryModal () {
+  return import(/*webpackChunkName: "flavours/glitch/async/compare_history_modal" */'flavours/glitch/features/ui/components/compare_history_modal');
+}