From 2da92fcd134712273384006a25e051d8593a4472 Mon Sep 17 00:00:00 2001
From: floatingghost <hannah@coffee-and-dreams.uk>
Date: Tue, 6 Sep 2022 19:25:03 +0000
Subject: [PATCH] editing (#158)

Co-authored-by: Sean King <seanking2919@protonmail.com>
Co-authored-by: Tusooa Zhu <tusooa@kazv.moe>
Co-authored-by: FloatingGhost <hannah@coffee-and-dreams.uk>
Reviewed-on: https://akkoma.dev/AkkomaGang/pleroma-fe/pulls/158
---
 CONTRIBUTORS.md                               |  2 +
 src/App.js                                    |  5 ++
 src/App.vue                                   |  2 +
 src/boot/after_store.js                       |  1 +
 src/components/attachment/attachment.js       |  3 +
 src/components/conversation/conversation.js   | 16 +++-
 .../edit_status_modal/edit_status_modal.js    | 75 ++++++++++++++++
 .../edit_status_modal/edit_status_modal.vue   | 48 +++++++++++
 src/components/extra_buttons/extra_buttons.js | 31 ++++++-
 .../extra_buttons/extra_buttons.vue           | 22 +++++
 .../post_status_form/post_status_form.js      | 51 ++++++++---
 .../post_status_form/post_status_form.vue     | 18 ++++
 src/components/status/status.js               |  6 ++
 src/components/status/status.scss             |  3 +-
 src/components/status/status.vue              | 24 ++++++
 .../status_history_modal.js                   | 60 +++++++++++++
 .../status_history_modal.vue                  | 46 ++++++++++
 src/i18n/en.json                              |  7 ++
 src/main.js                                   |  6 +-
 src/modules/api.js                            |  7 ++
 src/modules/editStatus.js                     | 25 ++++++
 src/modules/statusHistory.js                  | 25 ++++++
 src/modules/statuses.js                       |  9 ++
 src/services/api/api.service.js               | 85 ++++++++++++++++++-
 .../entity_normalizer.service.js              | 16 ++++
 .../status_poster/status_poster.service.js    | 42 +++++++++
 26 files changed, 616 insertions(+), 19 deletions(-)
 create mode 100644 src/components/edit_status_modal/edit_status_modal.js
 create mode 100644 src/components/edit_status_modal/edit_status_modal.vue
 create mode 100644 src/components/status_history_modal/status_history_modal.js
 create mode 100644 src/components/status_history_modal/status_history_modal.vue
 create mode 100644 src/modules/editStatus.js
 create mode 100644 src/modules/statusHistory.js

diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index f666a4ef..bfc41ac4 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -10,3 +10,5 @@ Contributors of this project.
 - shpuld (shpuld@shitposter.club): CSS and styling
 - Vincent Guth (https://unsplash.com/photos/XrwVIFy6rTw): Background images.
 - hj (hj@shigusegubu.club): Code
+- Sean King (seanking@freespeechextremist.com): Code
+- Tusooa Zhu (tusooa@kazv.moe): Code
diff --git a/src/App.js b/src/App.js
index 4304787f..3690b944 100644
--- a/src/App.js
+++ b/src/App.js
@@ -10,7 +10,9 @@ import MobilePostStatusButton from './components/mobile_post_status_button/mobil
 import MobileNav from './components/mobile_nav/mobile_nav.vue'
 import DesktopNav from './components/desktop_nav/desktop_nav.vue'
 import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
+import EditStatusModal from './components/edit_status_modal/edit_status_modal.vue'
 import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
+import StatusHistoryModal from './components/status_history_modal/status_history_modal.vue'
 import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
 import { windowWidth, windowHeight } from './services/window_utils/window_utils'
 import { mapGetters } from 'vuex'
@@ -33,6 +35,8 @@ export default {
     SettingsModal,
     UserReportingModal,
     PostStatusModal,
+    EditStatusModal,
+    StatusHistoryModal,
     GlobalNoticeList
   },
   data: () => ({
@@ -83,6 +87,7 @@ export default {
       return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
     },
     showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
+    editingAvailable () { return this.$store.state.instance.editingAvailable },
     layoutType () { return this.$store.state.interface.layoutType },
     privateMode () { return this.$store.state.instance.private },
     reverseLayout () {
diff --git a/src/App.vue b/src/App.vue
index ed4f318e..e1c0f5dc 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -58,6 +58,8 @@
     <MobilePostStatusButton />
     <UserReportingModal />
     <PostStatusModal />
+    <EditStatusModal v-if="editingAvailable" />
+    <StatusHistoryModal v-if="editingAvailable" />
     <SettingsModal />
     <UpdateNotification />
     <GlobalNoticeList />
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index d7c549d6..c12c70f1 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -273,6 +273,7 @@ const getNodeInfo = async ({ store }) => {
       store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
       store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
       store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
+      store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') })
       store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
       store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
       store.dispatch('setInstanceOption', { name: 'translationEnabled', value: features.includes('akkoma:machine_translation') })
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index 5450f7a1..0568c6e2 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -132,6 +132,9 @@ const Attachment = {
     ...mapGetters(['mergedConfig'])
   },
   watch: {
+    'attachment.description' (newVal) {
+      this.localDescription = newVal
+    },
     localDescription (newVal) {
       this.onEdit(newVal)
     }
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 2ef2977a..f8df9eb5 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,6 +1,8 @@
 import { reduce, filter, findIndex, clone, get } from 'lodash'
 import Status from '../status/status.vue'
 import ThreadTree from '../thread_tree/thread_tree.vue'
+import { WSConnectionStatus } from '../../services/api/api.service.js'
+import { mapGetters, mapState } from 'vuex'
 
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
@@ -77,6 +79,9 @@ const conversation = {
       const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2
       return maxDepth >= 1 ? maxDepth : 1
     },
+    streamingEnabled () {
+      return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
+    },
     displayStyle () {
       return this.$store.getters.mergedConfig.conversationDisplay
     },
@@ -339,7 +344,11 @@ const conversation = {
     },
     maybeHighlight () {
       return this.isExpanded ? this.highlight : null
-    }
+    },
+    ...mapGetters(['mergedConfig']),
+    ...mapState({
+      mastoUserSocketStatus: state => state.api.mastoUserSocketStatus
+    })
   },
   components: {
     Status,
@@ -395,6 +404,11 @@ const conversation = {
     setHighlight (id) {
       if (!id) return
       this.highlight = id
+
+      if (!this.streamingEnabled) {
+        this.$store.dispatch('fetchStatus', id)
+      }
+
       this.$store.dispatch('fetchFavsAndRepeats', id)
       this.$store.dispatch('fetchEmojiReactionsBy', id)
     },
diff --git a/src/components/edit_status_modal/edit_status_modal.js b/src/components/edit_status_modal/edit_status_modal.js
new file mode 100644
index 00000000..75adfea7
--- /dev/null
+++ b/src/components/edit_status_modal/edit_status_modal.js
@@ -0,0 +1,75 @@
+import PostStatusForm from '../post_status_form/post_status_form.vue'
+import Modal from '../modal/modal.vue'
+import statusPosterService from '../../services/status_poster/status_poster.service.js'
+import get from 'lodash/get'
+
+const EditStatusModal = {
+  components: {
+    PostStatusForm,
+    Modal
+  },
+  data () {
+    return {
+      resettingForm: false
+    }
+  },
+  computed: {
+    isLoggedIn () {
+      return !!this.$store.state.users.currentUser
+    },
+    modalActivated () {
+      return this.$store.state.editStatus.modalActivated
+    },
+    isFormVisible () {
+      return this.isLoggedIn && !this.resettingForm && this.modalActivated
+    },
+    params () {
+      return this.$store.state.editStatus.params || {}
+    }
+  },
+  watch: {
+    params (newVal, oldVal) {
+      if (get(newVal, 'statusId') !== get(oldVal, 'statusId')) {
+        this.resettingForm = true
+        this.$nextTick(() => {
+          this.resettingForm = false
+        })
+      }
+    },
+    isFormVisible (val) {
+      if (val) {
+        this.$nextTick(() => this.$el && this.$el.querySelector('textarea').focus())
+      }
+    }
+  },
+  methods: {
+    doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) {
+      const params = {
+        store: this.$store,
+        statusId: this.$store.state.editStatus.params.statusId,
+        status,
+        spoilerText,
+        sensitive,
+        poll,
+        media,
+        contentType
+      }
+
+      return statusPosterService.editStatus(params)
+        .then((data) => {
+          return data
+        })
+        .catch((err) => {
+          console.error('Error editing status', err)
+          return {
+            error: err.message
+          }
+        })
+    },
+    closeModal () {
+      this.$store.dispatch('closeEditStatusModal')
+    }
+  }
+}
+
+export default EditStatusModal
diff --git a/src/components/edit_status_modal/edit_status_modal.vue b/src/components/edit_status_modal/edit_status_modal.vue
new file mode 100644
index 00000000..00dde7de
--- /dev/null
+++ b/src/components/edit_status_modal/edit_status_modal.vue
@@ -0,0 +1,48 @@
+<template>
+  <Modal
+    v-if="isFormVisible"
+    class="edit-form-modal-view"
+    @backdropClicked="closeModal"
+  >
+    <div class="edit-form-modal-panel panel">
+      <div class="panel-heading">
+        {{ $t('post_status.edit_status') }}
+      </div>
+      <PostStatusForm
+        class="panel-body"
+        v-bind="params"
+        @posted="closeModal"
+        :disablePolls="true"
+        :disableVisibilitySelector="true"
+        :post-handler="doEditStatus"
+      />
+    </div>
+  </Modal>
+</template>
+
+<script src="./edit_status_modal.js"></script>
+
+<style lang="scss">
+.modal-view.edit-form-modal-view {
+  align-items: flex-start;
+}
+.edit-form-modal-panel {
+  flex-shrink: 0;
+  margin-top: 25%;
+  margin-bottom: 2em;
+  width: 100%;
+  max-width: 700px;
+
+  @media (orientation: landscape) {
+    margin-top: 8%;
+  }
+
+  .form-bottom-left {
+    max-width: 6.5em;
+
+    .emoji-icon {
+      justify-content: right;
+    }
+  }
+}
+</style>
diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js
index 042a96a1..f24e261b 100644
--- a/src/components/extra_buttons/extra_buttons.js
+++ b/src/components/extra_buttons/extra_buttons.js
@@ -7,7 +7,8 @@ import {
   faEyeSlash,
   faThumbtack,
   faShareAlt,
-  faExternalLinkAlt
+  faExternalLinkAlt,
+  faHistory
 } from '@fortawesome/free-solid-svg-icons'
 import {
   faBookmark as faBookmarkReg,
@@ -22,7 +23,8 @@ library.add(
   faThumbtack,
   faShareAlt,
   faExternalLinkAlt,
-  faFlag
+  faFlag,
+  faHistory
 )
 
 const ExtraButtons = {
@@ -101,6 +103,25 @@ const ExtraButtons = {
     },
     reportStatus () {
       this.$store.dispatch('openUserReportingModal', { userId: this.status.user.id, statusIds: [this.status.id] })
+    },
+    editStatus () {
+      this.$store.dispatch('fetchStatusSource', { id: this.status.id })
+        .then(data => this.$store.dispatch('openEditStatusModal', {
+          statusId: this.status.id,
+          subject: data.spoiler_text,
+          statusText: data.text,
+          statusIsSensitive: this.status.nsfw,
+          statusPoll: this.status.poll,
+          statusFiles: [...this.status.attachments],
+          visibility: this.status.visibility,
+          statusContentType: data.content_type
+        }))
+    },
+    showStatusHistory () {
+      const originalStatus = { ...this.status }
+      const stripFieldsList = ['attachments', 'created_at', 'emojis', 'text', 'raw_html', 'nsfw', 'poll', 'summary', 'summary_raw_html']
+      stripFieldsList.forEach(p => delete originalStatus[p])
+      this.$store.dispatch('openStatusHistoryModal', originalStatus)
     }
   },
   computed: {
@@ -134,7 +155,11 @@ const ExtraButtons = {
     },
     shouldConfirmDelete () {
       return this.$store.getters.mergedConfig.modalOnDelete
-    }
+    },
+    isEdited () {
+      return this.status.edited_at !== null
+    },
+    editingAvailable () { return this.$store.state.instance.editingAvailable }
   }
 }
 
diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue
index b1cbe8dc..a15f0bf2 100644
--- a/src/components/extra_buttons/extra_buttons.vue
+++ b/src/components/extra_buttons/extra_buttons.vue
@@ -73,6 +73,28 @@
             icon="bookmark"
           /><span>{{ $t("status.unbookmark") }}</span>
         </button>
+        <button
+          v-if="ownStatus && editingAvailable"
+          class="button-default dropdown-item dropdown-item-icon"
+          @click.prevent="editStatus"
+          @click="close"
+        >
+          <FAIcon
+            fixed-width
+            icon="pen"
+          /><span>{{ $t("status.edit") }}</span>
+        </button>
+        <button
+          v-if="isEdited && editingAvailable"
+          class="button-default dropdown-item dropdown-item-icon"
+          @click.prevent="showStatusHistory"
+          @click="close"
+        >
+          <FAIcon
+            fixed-width
+            icon="history"
+          /><span>{{ $t("status.edit_history") }}</span>
+        </button>
         <button
           v-if="canDelete"
           class="button-default dropdown-item dropdown-item-icon"
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 4e59e430..f023397e 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -55,6 +55,14 @@ const pxStringToNumber = (str) => {
 
 const PostStatusForm = {
   props: [
+    'statusId',
+    'statusText',
+    'statusIsSensitive',
+    'statusPoll',
+    'statusFiles',
+    'statusMediaDescriptions',
+    'statusScope',
+    'statusContentType',
     'replyTo',
     'quoteId',
     'repliedUser',
@@ -63,6 +71,7 @@ const PostStatusForm = {
     'subject',
     'disableSubject',
     'disableScopeSelector',
+    'disableVisibilitySelector',
     'disableNotice',
     'disableLockWarning',
     'disablePolls',
@@ -120,23 +129,40 @@ const PostStatusForm = {
 
     const { postContentType: contentType, sensitiveByDefault, sensitiveIfSubject } = this.$store.getters.mergedConfig
 
+    let statusParams = {
+      spoilerText: this.subject || '',
+      status: statusText,
+      sensitiveByDefault,
+      nsfw: !!sensitiveByDefault,
+      files: [],
+      poll: {},
+      mediaDescriptions: {},
+      visibility: this.suggestedVisibility(),
+      contentType
+    }
+
+    if (this.statusId) {
+      const statusContentType = this.statusContentType || contentType
+      statusParams = {
+        spoilerText: this.subject || '',
+        status: this.statusText || '',
+        sensitiveIfSubject,
+        nsfw: this.statusIsSensitive || !!sensitiveByDefault,
+        files: this.statusFiles || [],
+        poll: this.statusPoll || {},
+        mediaDescriptions: this.statusMediaDescriptions || {},
+        visibility: this.statusScope || this.suggestedVisibility(),
+        contentType: statusContentType
+      }
+    }
+
     return {
       dropFiles: [],
       uploadingFiles: false,
       error: null,
       posting: false,
       highlighted: 0,
-      newStatus: {
-        spoilerText: this.subject || '',
-        status: statusText,
-        sensitiveIfSubject,
-        nsfw: !!sensitiveByDefault,
-        files: [],
-        poll: {},
-        mediaDescriptions: {},
-        visibility: this.suggestedVisibility(),
-        contentType
-      },
+      newStatus: statusParams,
       caret: 0,
       pollFormVisible: false,
       showDropIcon: 'hide',
@@ -232,6 +258,9 @@ const PostStatusForm = {
     uploadFileLimitReached () {
       return this.newStatus.files.length >= this.fileLimit
     },
+    isEdit () {
+      return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
+    },
     ...mapGetters(['mergedConfig']),
     ...mapState({
       mobileLayout: state => state.interface.mobileLayout
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 4824b723..6c505ddc 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -66,6 +66,13 @@
           <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
           <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
         </p>
+        <div
+          v-if="isEdit"
+          class="visibility-notice edit-warning"
+        >
+          <p>{{ $t('post_status.edit_remote_warning') }}</p>
+          <p>{{ $t('post_status.edit_unsupported_warning') }}</p>
+        </div>
         <div
           v-if="!disablePreview"
           class="preview-heading faint"
@@ -180,6 +187,7 @@
           class="visibility-tray"
         >
           <scope-selector
+            v-if="!disableVisibilitySelector"
             :show-all="showAllScopes"
             :user-default="userDefaultScope"
             :original-scope="copyMessageScope"
@@ -420,6 +428,16 @@
     align-items: baseline;
   }
 
+  .visibility-notice.edit-warning {
+    > :first-child {
+      margin-top: 0;
+    }
+
+    > :last-child {
+      margin-bottom: 0;
+    }
+  }
+
   .media-upload-icon, .poll-icon, .emoji-icon {
     font-size: 1.85em;
     line-height: 1.1;
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 2959c3fd..a794b284 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -437,6 +437,12 @@ const Status = {
     },
     visibilityLocalized () {
       return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
+    },
+    isEdited () {
+      return this.status.edited_at !== null
+    },
+    editingAvailable () {
+      return this.$store.state.instance.editingAvailable
     }
   },
   methods: {
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index cc9d4eb7..448577d3 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -160,7 +160,8 @@
     margin-right: 0.2em;
   }
 
-  & .heading-reply-row {
+  & .heading-reply-row,
+  & .heading-edited-row {
     position: relative;
     align-content: baseline;
     font-size: 0.85em;
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index aef375fa..7366670b 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -329,6 +329,30 @@
                 class="mentions-line"
               />
             </div>
+            <div
+              v-if="isEdited && editingAvailable && !isPreview"
+              class="heading-edited-row"
+            >
+              <i18n-t
+                keypath="status.edited_at"
+                tag="span"
+              >
+                <template #time>
+                  <i18n-t
+                    keypath="time.in_past"
+                    tag="span"
+                  >
+                    <template>
+                      <Timeago
+                        :time="status.edited_at"
+                        :auto-update="60"
+                        :long-format="true"
+                      />
+                    </template>
+                  </i18n-t>
+                </template>
+              </i18n-t>
+            </div>
           </div>
 
           <StatusContent
diff --git a/src/components/status_history_modal/status_history_modal.js b/src/components/status_history_modal/status_history_modal.js
new file mode 100644
index 00000000..3941a56f
--- /dev/null
+++ b/src/components/status_history_modal/status_history_modal.js
@@ -0,0 +1,60 @@
+import { get } from 'lodash'
+import Modal from '../modal/modal.vue'
+import Status from '../status/status.vue'
+
+const StatusHistoryModal = {
+  components: {
+    Modal,
+    Status
+  },
+  data () {
+    return {
+      statuses: []
+    }
+  },
+  computed: {
+    modalActivated () {
+      return this.$store.state.statusHistory.modalActivated
+    },
+    params () {
+      return this.$store.state.statusHistory.params
+    },
+    statusId () {
+      return this.params.id
+    },
+    historyCount () {
+      return this.statuses.length
+    },
+    history () {
+      return this.statuses
+    }
+  },
+  watch: {
+    params (newVal, oldVal) {
+      const newStatusId = get(newVal, 'id') !== get(oldVal, 'id')
+      if (newStatusId) {
+        this.resetHistory()
+      }
+
+      if (newStatusId || get(newVal, 'edited_at') !== get(oldVal, 'edited_at')) {
+        this.fetchStatusHistory()
+      }
+    }
+  },
+  methods: {
+    resetHistory () {
+      this.statuses = []
+    },
+    fetchStatusHistory () {
+      this.$store.dispatch('fetchStatusHistory', this.params)
+        .then(data => {
+          this.statuses = data
+        })
+    },
+    closeModal () {
+      this.$store.dispatch('closeStatusHistoryModal')
+    }
+  }
+}
+
+export default StatusHistoryModal
diff --git a/src/components/status_history_modal/status_history_modal.vue b/src/components/status_history_modal/status_history_modal.vue
new file mode 100644
index 00000000..e2729369
--- /dev/null
+++ b/src/components/status_history_modal/status_history_modal.vue
@@ -0,0 +1,46 @@
+<template>
+  <Modal
+    v-if="modalActivated"
+    class="status-history-modal-view"
+    @backdropClicked="closeModal"
+  >
+    <div class="status-history-modal-panel panel">
+      <div class="panel-heading">
+        {{ $tc('status.edit_history_modal_title', historyCount - 1, { historyCount: historyCount - 1 }) }}
+      </div>
+      <div class="panel-body">
+        <div
+          v-if="historyCount > 0"
+          class="history-body"
+        >
+          <status
+            v-for="status in history"
+            :key="status.id"
+            :statusoid="status"
+            :isPreview="true"
+            class="conversation-status status-fadein panel-body"
+        />
+        </div>
+      </div>
+    </div>
+  </Modal>
+</template>
+
+<script src="./status_history_modal.js"></script>
+
+<style lang="scss">
+.modal-view.status-history-modal-view {
+  align-items: flex-start;
+}
+.status-history-modal-panel {
+  flex-shrink: 0;
+  margin-top: 25%;
+  margin-bottom: 2em;
+  width: 100%;
+  max-width: 700px;
+
+  @media (orientation: landscape) {
+    margin-top: 8%;
+  }
+}
+</style>
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 49c45014..95dac57c 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -284,6 +284,9 @@
         "direct_warning_to_all": "This post will be visible to all the mentioned users.",
         "direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
         "empty_status_error": "Can't send a post with no content and no files",
+        "edit_status": "Edit Status",
+        "edit_remote_warning": "Other instances may not support edits!",
+        "edit_unsupported_warning": "Polls and mentions will not be changed by editing.",
         "media_description": "Media description",
         "media_description_error": "Failed to update media, try again",
         "media_not_sensitive_warning": "You have a Content Warning, but the attachments are not marked as sensitive!",
@@ -833,6 +836,10 @@
         "delete_confirm_accept_button": "Yes, delete it",
         "delete_confirm_cancel_button": "No, keep it",
         "delete_confirm_title": "Confirm deletion",
+        "edit": "Edit",
+        "edited_at": "Edited {time}",
+        "edit_history": "Edit History",
+        "edit_history_modal_title": "Edited {historyCount} time | Edited {historyCount} times",
         "expand": "Expand",
         "external_source": "External source",
         "favorites": "Favorites",
diff --git a/src/main.js b/src/main.js
index 356a45da..e4a793f6 100644
--- a/src/main.js
+++ b/src/main.js
@@ -19,6 +19,8 @@ import reportsModule from './modules/reports.js'
 import pollsModule from './modules/polls.js'
 import postStatusModule from './modules/postStatus.js'
 import announcementsModule from './modules/announcements.js'
+import editStatusModule from './modules/editStatus.js'
+import statusHistoryModule from './modules/statusHistory.js'
 
 import { createI18n } from 'vue-i18n'
 
@@ -81,7 +83,9 @@ const persistedStateOptions = {
       reports: reportsModule,
       polls: pollsModule,
       postStatus: postStatusModule,
-      announcements: announcementsModule
+      announcements: announcementsModule,
+      editStatus: editStatusModule,
+      statusHistory: statusHistoryModule
     },
     plugins,
     strict: false // Socket modifies itself, let's ignore this for now.
diff --git a/src/modules/api.js b/src/modules/api.js
index ab0c8555..6c896c79 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -98,6 +98,13 @@ const api = {
                   showImmediately: timelineData.visibleStatuses.length === 0,
                   timeline: 'friends'
                 })
+              } else if (message.event === 'status.update') {
+                dispatch('addNewStatuses', {
+                  statuses: [message.status],
+                  userId: false,
+                  showImmediately: message.status.id in timelineData.visibleStatusesObject,
+                  timeline: 'friends'
+                })
               } else if (message.event === 'delete') {
                 dispatch('deleteStatusById', message.id)
               }
diff --git a/src/modules/editStatus.js b/src/modules/editStatus.js
new file mode 100644
index 00000000..fd316519
--- /dev/null
+++ b/src/modules/editStatus.js
@@ -0,0 +1,25 @@
+const editStatus = {
+  state: {
+    params: null,
+    modalActivated: false
+  },
+  mutations: {
+    openEditStatusModal (state, params) {
+      state.params = params
+      state.modalActivated = true
+    },
+    closeEditStatusModal (state) {
+      state.modalActivated = false
+    }
+  },
+  actions: {
+    openEditStatusModal ({ commit }, params) {
+      commit('openEditStatusModal', params)
+    },
+    closeEditStatusModal ({ commit }) {
+      commit('closeEditStatusModal')
+    }
+  }
+}
+
+export default editStatus
diff --git a/src/modules/statusHistory.js b/src/modules/statusHistory.js
new file mode 100644
index 00000000..db3d6d4b
--- /dev/null
+++ b/src/modules/statusHistory.js
@@ -0,0 +1,25 @@
+const statusHistory = {
+  state: {
+    params: {},
+    modalActivated: false
+  },
+  mutations: {
+    openStatusHistoryModal (state, params) {
+      state.params = params
+      state.modalActivated = true
+    },
+    closeStatusHistoryModal (state) {
+      state.modalActivated = false
+    }
+  },
+  actions: {
+    openStatusHistoryModal ({ commit }, params) {
+      commit('openStatusHistoryModal', params)
+    },
+    closeStatusHistoryModal ({ commit }) {
+      commit('closeStatusHistoryModal')
+    }
+  }
+}
+
+export default statusHistory
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index 8f012191..1080462c 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -251,6 +251,9 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
     'status': (status) => {
       addStatus(status, showImmediately)
     },
+    'edit': (status) => {
+      addStatus(status, showImmediately)
+    },
     'retweet': (status) => {
       // RetweetedStatuses are never shown immediately
       const retweetedStatus = addStatus(status.retweeted_status, false, false)
@@ -607,6 +610,12 @@ const statuses = {
       return rootState.api.backendInteractor.fetchStatus({ id })
         .then((status) => dispatch('addNewStatuses', { statuses: [status] }))
     },
+    fetchStatusSource ({ rootState, dispatch }, status) {
+      return apiService.fetchStatusSource({ id: status.id, credentials: rootState.users.currentUser.credentials })
+    },
+    fetchStatusHistory ({ rootState, dispatch }, status) {
+      return apiService.fetchStatusHistory({ status })
+    },
     deleteStatus ({ rootState, commit }, status) {
       commit('setDeleted', { status })
       apiService.deleteStatus({ id: status.id, credentials: rootState.users.currentUser.credentials })
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index cb11a05f..8c7f80db 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -1,5 +1,5 @@
 import { each, map, concat, last, get } from 'lodash'
-import { parseStatus, parseUser, parseNotification, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
+import { parseStatus, parseSource, parseUser, parseNotification, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
 import { RegistrationError, StatusCodeError } from '../errors/errors'
 
 /* eslint-env browser */
@@ -52,6 +52,8 @@ const MASTODON_USER_HOME_TIMELINE_URL = '/api/v1/timelines/home'
 const AKKOMA_BUBBLE_TIMELINE_URL = '/api/v1/timelines/bubble'
 const MASTODON_STATUS_URL = id => `/api/v1/statuses/${id}`
 const MASTODON_STATUS_CONTEXT_URL = id => `/api/v1/statuses/${id}/context`
+const MASTODON_STATUS_SOURCE_URL = id => `/api/v1/statuses/${id}/source`
+const MASTODON_STATUS_HISTORY_URL = id => `/api/v1/statuses/${id}/history`
 const MASTODON_USER_URL = id => `/api/v1/accounts/${id}?with_relationships=true`
 const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
 const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
@@ -512,6 +514,31 @@ const fetchStatus = ({ id, credentials }) => {
     .then((data) => parseStatus(data))
 }
 
+const fetchStatusSource = ({ id, credentials }) => {
+  let url = MASTODON_STATUS_SOURCE_URL(id)
+  return fetch(url, { headers: authHeaders(credentials) })
+    .then((data) => {
+      if (data.ok) {
+        return data
+      }
+      throw new Error('Error fetching source', data)
+    })
+    .then((data) => data.json())
+    .then((data) => parseSource(data))
+}
+
+const fetchStatusHistory = ({ status, credentials }) => {
+  let url = MASTODON_STATUS_HISTORY_URL(status.id)
+  return promisedRequest({ url, credentials })
+    .then((data) => {
+      data.reverse()
+      return data.map((item) => {
+        item.originalStatus = status
+        return parseStatus(item)
+      })
+    })
+}
+
 const tagUser = ({ tag, credentials, user }) => {
   const screenName = user.screen_name
   const form = {
@@ -837,6 +864,54 @@ const postStatus = ({
     .then((data) => data.error ? data : parseStatus(data))
 }
 
+const editStatus = ({
+  id,
+  credentials,
+  status,
+  spoilerText,
+  sensitive,
+  poll,
+  mediaIds = [],
+  contentType
+}) => {
+  const form = new FormData()
+  const pollOptions = poll.options || []
+
+  form.append('status', status)
+  if (spoilerText) form.append('spoiler_text', spoilerText)
+  if (sensitive) form.append('sensitive', sensitive)
+  if (contentType) form.append('content_type', contentType)
+  mediaIds.forEach(val => {
+    form.append('media_ids[]', val)
+  })
+
+  if (pollOptions.some(option => option !== '')) {
+    const normalizedPoll = {
+      expires_in: poll.expiresIn,
+      multiple: poll.multiple
+    }
+    Object.keys(normalizedPoll).forEach(key => {
+      form.append(`poll[${key}]`, normalizedPoll[key])
+    })
+
+    pollOptions.forEach(option => {
+      form.append('poll[options][]', option)
+    })
+  }
+
+  let putHeaders = authHeaders(credentials)
+
+  return fetch(MASTODON_STATUS_URL(id), {
+    body: form,
+    method: 'PUT',
+    headers: putHeaders
+  })
+    .then((response) => {
+      return response.json()
+    })
+    .then((data) => data.error ? data : parseStatus(data))
+}
+
 const deleteStatus = ({ id, credentials }) => {
   return fetch(MASTODON_DELETE_URL(id), {
     headers: authHeaders(credentials),
@@ -1393,7 +1468,8 @@ const MASTODON_STREAMING_EVENTS = new Set([
   'update',
   'notification',
   'delete',
-  'filters_changed'
+  'filters_changed',
+  'status.update'
 ])
 
 const PLEROMA_STREAMING_EVENTS = new Set([
@@ -1474,6 +1550,8 @@ export const handleMastoWS = (wsEvent) => {
     const data = payload ? JSON.parse(payload) : null
     if (event === 'update') {
       return { event, status: parseStatus(data) }
+    } else if (event === 'status.update') {
+      return { event, status: parseStatus(data) }
     } else if (event === 'notification') {
       return { event, notification: parseNotification(data) }
     }
@@ -1498,6 +1576,8 @@ const apiService = {
   fetchPinnedStatuses,
   fetchConversation,
   fetchStatus,
+  fetchStatusSource,
+  fetchStatusHistory,
   fetchFriends,
   exportFriends,
   fetchFollowers,
@@ -1518,6 +1598,7 @@ const apiService = {
   bookmarkStatus,
   unbookmarkStatus,
   postStatus,
+  editStatus,
   deleteStatus,
   uploadMedia,
   setMediaDescription,
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index b66191bf..b1aded33 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -242,6 +242,16 @@ export const parseAttachment = (data) => {
   return output
 }
 
+export const parseSource = (data) => {
+  const output = {}
+
+  output.text = data.text
+  output.spoiler_text = data.spoiler_text
+  output.content_type = data.content_type
+
+  return output
+}
+
 export const parseStatus = (data) => {
   const output = {}
   const masto = data.hasOwnProperty('account')
@@ -263,6 +273,8 @@ export const parseStatus = (data) => {
 
     output.tags = data.tags
 
+    output.edited_at = data.edited_at
+
     if (data.pleroma) {
       const { pleroma } = data
       output.text = pleroma.content ? data.pleroma.content['text/plain'] : data.content
@@ -374,6 +386,10 @@ export const parseStatus = (data) => {
   output.favoritedBy = []
   output.rebloggedBy = []
 
+  if (data.hasOwnProperty('originalStatus')) {
+    Object.assign(output, data.originalStatus)
+  }
+
   return output
 }
 
diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js
index d1c5db19..aaef5a7a 100644
--- a/src/services/status_poster/status_poster.service.js
+++ b/src/services/status_poster/status_poster.service.js
@@ -49,6 +49,47 @@ const postStatus = ({
     })
 }
 
+const editStatus = ({
+  store,
+  statusId,
+  status,
+  spoilerText,
+  sensitive,
+  poll,
+  media = [],
+  contentType = 'text/plain'
+}) => {
+  const mediaIds = map(media, 'id')
+
+  return apiService.editStatus({
+    id: statusId,
+    credentials: store.state.users.currentUser.credentials,
+    status,
+    spoilerText,
+    sensitive,
+    poll,
+    mediaIds,
+    contentType
+  })
+    .then((data) => {
+      if (!data.error) {
+        store.dispatch('addNewStatuses', {
+          statuses: [data],
+          timeline: 'friends',
+          showImmediately: true,
+          noIdUpdate: true // To prevent missing notices on next pull.
+        })
+      }
+      return data
+    })
+    .catch((err) => {
+      console.error('Error editing status', err)
+      return {
+        error: err.message
+      }
+    })
+}
+
 const uploadMedia = ({ store, formData }) => {
   const credentials = store.state.users.currentUser.credentials
   return apiService.uploadMedia({ credentials, formData })
@@ -61,6 +102,7 @@ const setMediaDescription = ({ store, id, description }) => {
 
 const statusPosterService = {
   postStatus,
+  editStatus,
   uploadMedia,
   setMediaDescription
 }