about summary refs log tree commit diff
path: root/app/javascript
diff options
context:
space:
mode:
authorFire Demon <firedemon@creature.cafe>2020-06-30 17:33:55 -0500
committerFire Demon <firedemon@creature.cafe>2020-08-30 05:41:03 -0500
commiteaf9bc1a428b338ee666f1da1e32eed7e3b6b25e (patch)
treeaeec5fdde79d6e4fa354da326a540811b5576907 /app/javascript
parent5d5d88e4f65df4c190afeb407167c153584be108 (diff)
[Feature] Add in-place post editing
Diffstat (limited to 'app/javascript')
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js5
-rw-r--r--app/javascript/flavours/glitch/actions/importer/normalizer.js5
-rw-r--r--app/javascript/flavours/glitch/actions/statuses.js21
-rw-r--r--app/javascript/flavours/glitch/actions/streaming.js5
-rw-r--r--app/javascript/flavours/glitch/components/status.js1
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js9
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js18
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js6
-rw-r--r--app/javascript/flavours/glitch/features/status/components/action_bar.js7
-rw-r--r--app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js5
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js7
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js5
-rw-r--r--app/javascript/flavours/glitch/styles/index.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/monsterfork/components/index.scss1
-rw-r--r--app/javascript/flavours/glitch/styles/monsterfork/components/status.scss8
-rw-r--r--app/javascript/flavours/glitch/styles/monsterfork/index.scss1
-rw-r--r--app/javascript/mastodon/actions/importer/normalizer.js5
-rw-r--r--app/javascript/mastodon/actions/streaming.js5
-rw-r--r--app/javascript/mastodon/components/status_content.js20
-rw-r--r--app/javascript/mastodon/locales/en.json1
20 files changed, 130 insertions, 7 deletions
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index f83738093..4c2cca9eb 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -147,6 +147,9 @@ export function submitCompose(routerHistory) {
     let media  = getState().getIn(['compose', 'media_attachments']);
     const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']);
     let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
+    const id = getState().getIn(['compose', 'id'], null);
+    const submit_url = id ? `/api/v1/statuses/${id}` : '/api/v1/statuses';
+    const submit_action = (res, body, config) => id ? api(getState).put(res, body, config) : api(getState).post(res, body, config);
 
     if ((!status || !status.length) && media.size === 0) {
       return;
@@ -156,7 +159,7 @@ export function submitCompose(routerHistory) {
     if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
       status = status + ' 👁️';
     }
-    api(getState).post('/api/v1/statuses', {
+    submit_action(submit_url, {
       status,
       content_type: getState().getIn(['compose', 'content_type']),
       in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js
index 05955963c..b71ee3150 100644
--- a/app/javascript/flavours/glitch/actions/importer/normalizer.js
+++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js
@@ -53,9 +53,12 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.poll = status.poll.id;
   }
 
+  const oldUpdatedAt = normalOldStatus ? normalOldStatus.updated_at || normalOldStatus.created_at : null;
+  const newUpdatedAt = normalStatus ? normalStatus.updated_at || normalStatus.created_at : null;
+
   // Only calculate these values when status first encountered
   // Otherwise keep the ones already in the reducer
-  if (normalOldStatus) {
+  if (normalOldStatus && oldUpdatedAt === newUpdatedAt) {
     normalStatus.search_index = normalOldStatus.get('search_index');
     normalStatus.contentHtml = normalOldStatus.get('contentHtml');
     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js
index 4d2bda78b..72e8f14d8 100644
--- a/app/javascript/flavours/glitch/actions/statuses.js
+++ b/app/javascript/flavours/glitch/actions/statuses.js
@@ -55,6 +55,24 @@ export function fetchStatus(id) {
   };
 };
 
+export function editStatus(status, routerHistory) {
+  return (dispatch, getState) => {
+    const id = status.get('id');
+
+    dispatch(fetchContext(id));
+    dispatch(fetchStatusRequest(id, false));
+
+    api(getState).get(`/api/v1/statuses/${id}`, { params: { source: 1 } }).then(response => {
+      dispatch(importFetchedStatus(response.data));
+      dispatch(fetchStatusSuccess(false));
+      dispatch(redraft(status, response.data.text, response.data.content_type, true));
+      ensureComposeIsVisible(getState, routerHistory);
+    }).catch(error => {
+      dispatch(fetchStatusFail(id, error, false));
+    });
+  };
+};
+
 export function fetchStatusSuccess(skipLoading) {
   return {
     type: STATUS_FETCH_SUCCESS,
@@ -72,12 +90,13 @@ export function fetchStatusFail(id, error, skipLoading) {
   };
 };
 
-export function redraft(status, raw_text, content_type) {
+export function redraft(status, raw_text, content_type, inplace = false) {
   return {
     type: REDRAFT,
     status,
     raw_text,
     content_type,
+    inplace,
   };
 };
 
diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js
index 35db5dcc9..295896e55 100644
--- a/app/javascript/flavours/glitch/actions/streaming.js
+++ b/app/javascript/flavours/glitch/actions/streaming.js
@@ -18,6 +18,7 @@ import {
 } from './announcements';
 import { fetchFilters } from './filters';
 import { getLocale } from 'mastodon/locales';
+import { resetCompose } from 'flavours/glitch/actions/compose';
 
 const { messages } = getLocale();
 
@@ -96,6 +97,10 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
         case 'announcement.delete':
           dispatch(deleteAnnouncement(data.payload));
           break;
+        case 'refresh':
+          dispatch(resetCompose());
+          window.location.reload();
+          break;
         }
       },
     };
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 4e628a420..5d3789b24 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -73,6 +73,7 @@ class Status extends ImmutablePureComponent {
     onReblog: PropTypes.func,
     onBookmark: PropTypes.func,
     onDelete: PropTypes.func,
+    onEdit: PropTypes.func,
     onDirect: PropTypes.func,
     onMention: PropTypes.func,
     onPin: PropTypes.func,
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index c314c5fd5..ce1c8df2c 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -12,6 +12,7 @@ import { accountAdminLink, statusAdminLink } from 'flavours/glitch/util/backend_
 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}' },
@@ -61,6 +62,7 @@ class StatusActionBar extends ImmutablePureComponent {
     onFavourite: PropTypes.func,
     onReblog: PropTypes.func,
     onDelete: PropTypes.func,
+    onEdit: PropTypes.func,
     onDirect: PropTypes.func,
     onMention: PropTypes.func,
     onMute: PropTypes.func,
@@ -123,7 +125,7 @@ class StatusActionBar extends ImmutablePureComponent {
 
   _openInteractionDialog = type => {
     window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
-   }
+  }
 
   handleDeleteClick = () => {
     this.props.onDelete(this.props.status, this.context.router.history);
@@ -133,6 +135,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);
   }
@@ -233,6 +239,7 @@ class StatusActionBar extends ImmutablePureComponent {
 
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
       menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
+      menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
     } else {
       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
       menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
index a39f747b8..b353b028b 100644
--- a/app/javascript/flavours/glitch/components/status_content.js
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
 import { isRtl } from 'flavours/glitch/util/rtl';
 import { FormattedMessage } from 'react-intl';
 import Permalink from './permalink';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
 import classnames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
 import { autoPlayGif } from 'flavours/glitch/util/initial_state';
@@ -275,6 +276,20 @@ export default class StatusContent extends React.PureComponent {
 
     const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
 
+    const edited = (status.get('edited') === 0) ? null : (
+      <div className='status__edit-notice'>
+        <FormattedMessage
+          id='status.edited'
+          defaultMessage='{count, plural, one {# edit} other {# edits}} · last update: {updated_at}'
+          key={`edit-${status.get('id')}`}
+          values={{
+            count: status.get('edited'),
+            updated_at: <RelativeTimestamp timestamp={status.get('updated_at')} />,
+          }}
+        />
+      </div>
+    );
+
     const content = { __html: status.get('contentHtml') };
     const spoilerContent = { __html: status.get('spoilerHtml') };
     const directionStyle = { direction: 'ltr' };
@@ -340,6 +355,7 @@ export default class StatusContent extends React.PureComponent {
             </button>
           </p>
 
+          {edited}
           {mentionsPlaceholder}
 
           <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
@@ -366,6 +382,7 @@ export default class StatusContent extends React.PureComponent {
           tabIndex='0'
           ref={this.setRef}
         >
+          {edited}
           <div
             ref={this.setContentsRef}
             key={`contents-${tagLinks}-${rewriteMentions}`}
@@ -384,6 +401,7 @@ export default class StatusContent extends React.PureComponent {
           tabIndex='0'
           ref={this.setRef}
         >
+          {edited}
           <div ref={this.setContentsRef} key={`contents-${tagLinks}`} className='status__content__text' dangerouslySetInnerHTML={content} tabIndex='0' />
           {media}
         </div>
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
index 2cbe3d094..9e011ac6b 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';
@@ -166,6 +166,10 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
     }
   },
 
+  onEdit (status, history) {
+    dispatch(editStatus(status, history));
+  },
+
   onDirect (account, router) {
     dispatch(directCompose(account, router));
   },
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 080362dd0..c4f510184 100644
--- a/app/javascript/flavours/glitch/features/status/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js
@@ -10,6 +10,7 @@ import { accountAdminLink, statusAdminLink } from 'flavours/glitch/util/backend_
 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' },
@@ -50,6 +51,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,
@@ -82,6 +84,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);
   }
@@ -164,6 +170,7 @@ class ActionBar extends React.PureComponent {
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
       menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
+      menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
     } else {
       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
       menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
diff --git a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
index 9d11f37e0..eeafc0b08 100644
--- a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
+++ b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
@@ -17,6 +17,7 @@ import {
 import {
   muteStatus,
   unmuteStatus,
+  editStatus,
   deleteStatus,
   hideStatus,
   revealStatus,
@@ -118,6 +119,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onEdit (status, history) {
+    dispatch(editStatus(status, history));
+  },
+
   onDirect (account, router) {
     dispatch(directCompose(account, router));
   },
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 3e2e95f35..beea64341 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';
@@ -304,6 +304,10 @@ class Status extends ImmutablePureComponent {
     }
   }
 
+  handleEditClick = (status, history) => {
+    this.props.dispatch(editStatus(status, history));
+  }
+
   handleDirectClick = (account, router) => {
     this.props.dispatch(directCompose(account, router));
   }
@@ -588,6 +592,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/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index 478883f91..5f53361fa 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -66,6 +66,7 @@ const initialState = ImmutableMap({
     do_not_federate: false,
     threaded_mode: false,
   }),
+  id: null,
   sensitive: false,
   elefriend: Math.random() < glitchProbability ? Math.floor(Math.random() * totalElefriends) : totalElefriends,
   spoiler: false,
@@ -149,6 +150,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);
@@ -404,8 +406,10 @@ export default function compose(state = initialState, action) {
     });
   case COMPOSE_REPLY_CANCEL:
     state = state.setIn(['advanced_options', 'threaded_mode'], false);
+  // eslint-disable-next-line no-fallthrough
   case COMPOSE_RESET:
     return state.withMutations(map => {
+      map.set('id', null);
       map.set('in_reply_to', null);
       if (defaultContentType) map.set('content_type', defaultContentType);
       map.set('text', '');
@@ -505,6 +509,7 @@ export default function compose(state = initialState, action) {
     let text = action.raw_text || unescapeHTML(expandMentions(action.status));
     if (do_not_federate) text = text.replace(/ ?👁\ufe0f?\u200b?$/, '');
     return state.withMutations(map => {
+      map.set('id', action.inplace ? action.status.get('id') : null);
       map.set('text', text);
       map.set('content_type', action.content_type || 'text/plain');
       map.set('in_reply_to', action.status.get('in_reply_to_id'));
diff --git a/app/javascript/flavours/glitch/styles/index.scss b/app/javascript/flavours/glitch/styles/index.scss
index af73feb89..2994d7aff 100644
--- a/app/javascript/flavours/glitch/styles/index.scss
+++ b/app/javascript/flavours/glitch/styles/index.scss
@@ -23,3 +23,5 @@
 @import 'accessibility';
 @import 'rtl';
 @import 'dashboard';
+
+@import 'monsterfork/index';
diff --git a/app/javascript/flavours/glitch/styles/monsterfork/components/index.scss b/app/javascript/flavours/glitch/styles/monsterfork/components/index.scss
new file mode 100644
index 000000000..827779123
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/monsterfork/components/index.scss
@@ -0,0 +1 @@
+@import 'status';
diff --git a/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss b/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss
new file mode 100644
index 000000000..e64f21a21
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss
@@ -0,0 +1,8 @@
+.status__edit-notice {
+  & > span {
+    color: $dark-text-color;
+    line-height: normal;
+    font-style: italic;
+    font-size: 12px;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/monsterfork/index.scss b/app/javascript/flavours/glitch/styles/monsterfork/index.scss
new file mode 100644
index 000000000..841415620
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/monsterfork/index.scss
@@ -0,0 +1 @@
+@import 'components/index';
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index dca44917a..d0a55538f 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -53,9 +53,12 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.poll = status.poll.id;
   }
 
+  const oldUpdatedAt = normalOldStatus ? normalOldStatus.updated_at || normalOldStatus.created_at : null;
+  const newUpdatedAt = normalStatus ? normalStatus.updated_at || normalStatus.created_at : null;
+
   // Only calculate these values when status first encountered
   // Otherwise keep the ones already in the reducer
-  if (normalOldStatus) {
+  if (normalOldStatus && oldUpdatedAt === newUpdatedAt) {
     normalStatus.search_index = normalOldStatus.get('search_index');
     normalStatus.contentHtml = normalOldStatus.get('contentHtml');
     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index beb5c6a4a..1adc1b815 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -18,6 +18,7 @@ import {
 } from './announcements';
 import { fetchFilters } from './filters';
 import { getLocale } from '../locales';
+import { resetCompose } from '../actions/compose';
 
 const { messages } = getLocale();
 
@@ -96,6 +97,10 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
         case 'announcement.delete':
           dispatch(deleteAnnouncement(data.payload));
           break;
+        case 'refresh':
+          dispatch(resetCompose());
+          window.location.reload();
+          break;
         }
       },
     };
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index 3200f2d82..df05d8515 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
 import { isRtl } from '../rtl';
 import { FormattedMessage } from 'react-intl';
 import Permalink from './permalink';
+import RelativeTimestamp from './relative_timestamp';
 import classnames from 'classnames';
 import PollContainer from 'mastodon/containers/poll_container';
 import Icon from 'mastodon/components/icon';
@@ -180,6 +181,20 @@ export default class StatusContent extends React.PureComponent {
       return null;
     }
 
+    const edited = (status.get('edited') === 0) ? null : (
+      <div className='status__edit-notice'>
+        <FormattedMessage
+          id='status.edited'
+          defaultMessage='{count, plural, one {# edit} other {# edits}} · last update: {updated_at}'
+          key={`edit-${status.get('id')}`}
+          values={{
+            count: status.get('edited'),
+            updated_at: <RelativeTimestamp timestamp={status.get('updated_at')} />,
+          }}
+        />
+      </div>
+    );
+
     const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
     const renderReadMore = this.props.onClick && status.get('collapsed');
     const renderViewThread = this.props.showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
@@ -232,6 +247,7 @@ export default class StatusContent extends React.PureComponent {
             <button tabIndex='0' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick}>{toggleText}</button>
           </p>
 
+          {edited}
           {mentionsPlaceholder}
 
           <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} />
@@ -244,6 +260,8 @@ export default class StatusContent extends React.PureComponent {
     } else if (this.props.onClick) {
       const output = [
         <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'>
+          {edited}
+
           <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} />
 
           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
@@ -260,6 +278,8 @@ export default class StatusContent extends React.PureComponent {
     } else {
       return (
         <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}>
+          {edited}
+
           <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} />
 
           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 025ae6e7d..7702c8be1 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -380,6 +380,7 @@
   "status.delete": "Delete",
   "status.detailed_status": "Detailed conversation view",
   "status.direct": "Direct message @{name}",
+  "status.edited": "{count, plural, one {# edit} other {# edits}} · last update: {updated_at}",
   "status.embed": "Embed",
   "status.favourite": "Favourite",
   "status.filtered": "Filtered",