about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--Gemfile8
-rw-r--r--Gemfile.lock34
-rw-r--r--app/controllers/api/v1/media_controller.rb6
-rw-r--r--app/controllers/api/v1/reports_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses_controller.rb53
-rw-r--r--app/javascript/mastodon/actions/compose.js41
-rw-r--r--app/javascript/mastodon/actions/statuses.js37
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js6
-rw-r--r--app/javascript/mastodon/containers/status_container.js5
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js6
-rw-r--r--app/javascript/mastodon/features/compose/containers/compose_form_container.js1
-rw-r--r--app/javascript/mastodon/features/compose/containers/reply_indicator_container.js17
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js7
-rw-r--r--app/javascript/mastodon/features/status/index.js6
-rw-r--r--app/javascript/mastodon/reducers/compose.js45
-rw-r--r--app/models/media_attachment.rb4
-rw-r--r--app/models/poll.rb6
-rw-r--r--app/models/report.rb9
-rw-r--r--app/models/status.rb10
-rw-r--r--app/policies/status_policy.rb2
-rw-r--r--app/services/activitypub/process_status_update_service.rb20
-rw-r--r--app/services/process_hashtags_service.rb42
-rw-r--r--app/services/report_service.rb12
-rw-r--r--app/services/update_status_service.rb151
-rw-r--r--app/workers/activitypub/status_update_distribution_worker.rb29
-rw-r--r--chart/templates/ingress.yaml24
-rw-r--r--config/locales/en.yml3
-rw-r--r--config/routes.rb2
-rw-r--r--spec/controllers/api/v1/media_controller_spec.rb21
-rw-r--r--spec/controllers/api/v1/statuses_controller_spec.rb17
-rw-r--r--spec/policies/status_policy_spec.rb16
-rw-r--r--spec/services/activitypub/process_status_update_service_spec.rb248
-rw-r--r--spec/services/update_status_service_spec.rb140
-rw-r--r--spec/workers/activitypub/status_update_distribution_worker_spec.rb48
-rw-r--r--yarn.lock18
35 files changed, 974 insertions, 122 deletions
diff --git a/Gemfile b/Gemfile
index 7713bfb2b..ae999d964 100644
--- a/Gemfile
+++ b/Gemfile
@@ -6,7 +6,7 @@ ruby '>= 2.5.0', '< 3.1.0'
 gem 'pkg-config', '~> 1.4'
 gem 'rexml', '~> 3.2'
 
-gem 'puma', '~> 5.5'
+gem 'puma', '~> 5.6'
 gem 'rails', '~> 6.1.4'
 gem 'sprockets', '~> 3.7.2'
 gem 'thor', '~> 1.2'
@@ -18,7 +18,7 @@ gem 'makara', '~> 0.5'
 gem 'pghero', '~> 2.8'
 gem 'dotenv-rails', '~> 2.7'
 
-gem 'aws-sdk-s3', '~> 1.111', require: false
+gem 'aws-sdk-s3', '~> 1.112', require: false
 gem 'fog-core', '<= 2.1.0'
 gem 'fog-openstack', '~> 0.3', require: false
 gem 'kt-paperclip', '~> 7.0'
@@ -26,7 +26,7 @@ gem 'blurhash', '~> 0.1'
 
 gem 'active_model_serializers', '~> 0.10'
 gem 'addressable', '~> 2.8'
-gem 'bootsnap', '~> 1.10.2', require: false
+gem 'bootsnap', '~> 1.10.3', require: false
 gem 'browser'
 gem 'charlock_holmes', '~> 0.7.7'
 gem 'chewy', '~> 7.2'
@@ -100,7 +100,7 @@ gem 'rdf-normalize', '~> 0.5'
 gem 'redcarpet', '~> 3.5'
 
 group :development, :test do
-  gem 'fabrication', '~> 2.24'
+  gem 'fabrication', '~> 2.27'
   gem 'fuubar', '~> 2.5'
   gem 'i18n-tasks', '~> 0.9', require: false
   gem 'pry-byebug', '~> 3.9'
diff --git a/Gemfile.lock b/Gemfile.lock
index 2baa12038..0e973e41f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -79,17 +79,17 @@ GEM
       encryptor (~> 3.0.0)
     awrence (1.1.1)
     aws-eventstream (1.2.0)
-    aws-partitions (1.549.0)
-    aws-sdk-core (3.125.5)
+    aws-partitions (1.553.0)
+    aws-sdk-core (3.126.0)
       aws-eventstream (~> 1, >= 1.0.2)
       aws-partitions (~> 1, >= 1.525.0)
       aws-sigv4 (~> 1.1)
       jmespath (~> 1.0)
-    aws-sdk-kms (1.53.0)
-      aws-sdk-core (~> 3, >= 3.125.0)
+    aws-sdk-kms (1.54.0)
+      aws-sdk-core (~> 3, >= 3.126.0)
       aws-sigv4 (~> 1.1)
-    aws-sdk-s3 (1.111.3)
-      aws-sdk-core (~> 3, >= 3.125.0)
+    aws-sdk-s3 (1.112.0)
+      aws-sdk-core (~> 3, >= 3.126.0)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.4)
     aws-sigv4 (1.4.0)
@@ -104,7 +104,7 @@ GEM
       debug_inspector (>= 0.0.1)
     blurhash (0.1.5)
       ffi (~> 1.14)
-    bootsnap (1.10.2)
+    bootsnap (1.10.3)
       msgpack (~> 1.2)
     brakeman (5.2.1)
     browser (4.2.0)
@@ -209,7 +209,7 @@ GEM
     et-orbi (1.2.6)
       tzinfo
     excon (0.76.0)
-    fabrication (2.24.0)
+    fabrication (2.27.0)
     faker (2.19.0)
       i18n (>= 1.6, < 2)
     faraday (1.8.0)
@@ -407,14 +407,14 @@ GEM
     openssl (2.2.0)
     openssl-signature_algorithm (0.4.0)
     orm_adapter (0.5.0)
-    ox (2.14.6)
+    ox (2.14.7)
     parallel (1.21.0)
     parser (3.1.0.0)
       ast (~> 2.4.1)
     parslet (2.0.0)
     pastel (0.8.0)
       tty-color (~> 0.5)
-    pg (1.3.0)
+    pg (1.3.1)
     pghero (2.8.2)
       activerecord (>= 5)
     pkg-config (1.4.7)
@@ -436,7 +436,7 @@ GEM
     pry-rails (0.3.9)
       pry (>= 0.10.4)
     public_suffix (4.0.6)
-    puma (5.5.2)
+    puma (5.6.1)
       nio4r (~> 2.0)
     pundit (2.1.1)
       activesupport (>= 3.0.0)
@@ -531,7 +531,7 @@ GEM
     rspec-support (3.10.3)
     rspec_junit_formatter (0.5.1)
       rspec-core (>= 2, < 4, != 2.12.0)
-    rubocop (1.25.0)
+    rubocop (1.25.1)
       parallel (~> 1.10)
       parser (>= 3.1.0.0)
       rainbow (>= 2.2.2, < 4.0)
@@ -563,7 +563,7 @@ GEM
       railties (>= 4.0.0)
     securecompare (1.0.0)
     semantic_range (3.0.0)
-    sidekiq (6.4.0)
+    sidekiq (6.4.1)
       connection_pool (>= 2.2.2)
       rack (~> 2.0)
       redis (>= 4.2.0)
@@ -682,11 +682,11 @@ DEPENDENCIES
   active_record_query_trace (~> 1.8)
   addressable (~> 2.8)
   annotate (~> 3.1)
-  aws-sdk-s3 (~> 1.111)
+  aws-sdk-s3 (~> 1.112)
   better_errors (~> 2.9)
   binding_of_caller (~> 1.0)
   blurhash (~> 0.1)
-  bootsnap (~> 1.10.2)
+  bootsnap (~> 1.10.3)
   brakeman (~> 5.2)
   browser
   bullet (~> 7.0)
@@ -709,7 +709,7 @@ DEPENDENCIES
   doorkeeper (~> 5.5)
   dotenv-rails (~> 2.7)
   ed25519 (~> 1.3)
-  fabrication (~> 2.24)
+  fabrication (~> 2.27)
   faker (~> 2.19)
   fast_blank (~> 1.0)
   fastimage
@@ -756,7 +756,7 @@ DEPENDENCIES
   private_address_check (~> 0.5)
   pry-byebug (~> 3.9)
   pry-rails (~> 0.3)
-  puma (~> 5.5)
+  puma (~> 5.6)
   pundit (~> 2.1)
   rack (~> 2.2.3)
   rack-attack (~> 6.5)
diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb
index a2a919a3e..72094790f 100644
--- a/app/controllers/api/v1/media_controller.rb
+++ b/app/controllers/api/v1/media_controller.rb
@@ -20,7 +20,7 @@ class Api::V1::MediaController < Api::BaseController
   end
 
   def update
-    @media_attachment.update!(media_attachment_params)
+    @media_attachment.update!(updateable_media_attachment_params)
     render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment
   end
 
@@ -42,6 +42,10 @@ class Api::V1::MediaController < Api::BaseController
     params.permit(:file, :thumbnail, :description, :focus)
   end
 
+  def updateable_media_attachment_params
+    params.permit(:thumbnail, :description, :focus)
+  end
+
   def file_type_error
     { error: 'File type of uploaded media could not be verified' }
   end
diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb
index e10083d45..052d70cc8 100644
--- a/app/controllers/api/v1/reports_controller.rb
+++ b/app/controllers/api/v1/reports_controller.rb
@@ -33,6 +33,6 @@ class Api::V1::ReportsController < Api::BaseController
   end
 
   def report_params
-    params.permit(:account_id, :comment, :forward, status_ids: [])
+    params.permit(:account_id, :comment, :category, :forward, status_ids: [], rule_ids: [])
   end
 end
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index b1390ae48..c928a24de 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -3,8 +3,8 @@
 class Api::V1::StatusesController < Api::BaseController
   include Authorization
 
-  before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :destroy]
-  before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only:   [:create, :destroy]
+  before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
+  before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only:   [:create, :update, :destroy]
   before_action :require_user!, except:  [:show, :context]
   before_action :set_status, only:       [:show, :context]
   before_action :set_thread, only:       [:create]
@@ -35,25 +35,45 @@ class Api::V1::StatusesController < Api::BaseController
   end
 
   def create
-    @status = PostStatusService.new.call(current_user.account,
-                                         text: status_params[:status],
-                                         thread: @thread,
-                                         media_ids: status_params[:media_ids],
-                                         sensitive: status_params[:sensitive],
-                                         spoiler_text: status_params[:spoiler_text],
-                                         visibility: status_params[:visibility],
-                                         scheduled_at: status_params[:scheduled_at],
-                                         application: doorkeeper_token.application,
-                                         poll: status_params[:poll],
-                                         content_type: status_params[:content_type],
-                                         idempotency: request.headers['Idempotency-Key'],
-                                         with_rate_limit: true)
+    @status = PostStatusService.new.call(
+      current_user.account,
+      text: status_params[:status],
+      thread: @thread,
+      media_ids: status_params[:media_ids],
+      sensitive: status_params[:sensitive],
+      spoiler_text: status_params[:spoiler_text],
+      visibility: status_params[:visibility],
+      language: status_params[:language],
+      scheduled_at: status_params[:scheduled_at],
+      application: doorkeeper_token.application,
+      poll: status_params[:poll],
+      content_type: status_params[:content_type],
+      idempotency: request.headers['Idempotency-Key'],
+      with_rate_limit: true
+    )
 
     render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
   end
 
+  def update
+    @status = Status.where(account: current_account).find(params[:id])
+    authorize @status, :update?
+
+    UpdateStatusService.new.call(
+      @status,
+      current_account.id,
+      text: status_params[:status],
+      media_ids: status_params[:media_ids],
+      sensitive: status_params[:sensitive],
+      spoiler_text: status_params[:spoiler_text],
+      poll: status_params[:poll]
+    )
+
+    render json: @status, serializer: REST::StatusSerializer
+  end
+
   def destroy
-    @status = Status.where(account_id: current_user.account).find(params[:id])
+    @status = Status.where(account: current_account).find(params[:id])
     authorize @status, :destroy?
 
     @status.discard
@@ -85,6 +105,7 @@ class Api::V1::StatusesController < Api::BaseController
       :sensitive,
       :spoiler_text,
       :visibility,
+      :language,
       :scheduled_at,
       :content_type,
       media_ids: [],
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 7c3bbcbd8..f3129f8d9 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -70,6 +70,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.' },
@@ -83,6 +85,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,
@@ -137,8 +148,9 @@ export function directCompose(account, routerHistory) {
 
 export function submitCompose(routerHistory) {
   return function (dispatch, getState) {
-    const status = getState().getIn(['compose', 'text'], '');
-    const media  = getState().getIn(['compose', 'media_attachments']);
+    const status   = getState().getIn(['compose', 'text'], '');
+    const media    = getState().getIn(['compose', 'media_attachments']);
+    const statusId = getState().getIn(['compose', 'id'], null);
 
     if ((!status || !status.length) && media.size === 0) {
       return;
@@ -146,15 +158,18 @@ export function submitCompose(routerHistory) {
 
     dispatch(submitComposeRequest());
 
-    api(getState).post('/api/v1/statuses', {
-      status,
-      in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
-      media_ids: media.map(item => item.get('id')),
-      sensitive: getState().getIn(['compose', 'sensitive']),
-      spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
-      visibility: getState().getIn(['compose', 'privacy']),
-      poll: getState().getIn(['compose', 'poll'], null),
-    }, {
+    api(getState).request({
+      url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
+      method: statusId === null ? 'post' : 'put',
+      data: {
+        status,
+        in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
+        media_ids: media.map(item => item.get('id')),
+        sensitive: getState().getIn(['compose', 'sensitive']),
+        spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
+        visibility: getState().getIn(['compose', 'privacy']),
+        poll: getState().getIn(['compose', 'poll'], null),
+      },
       headers: {
         'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
       },
@@ -176,11 +191,11 @@ export function submitCompose(routerHistory) {
         }
       };
 
-      if (response.data.visibility !== 'direct') {
+      if (statusId === null && response.data.visibility !== 'direct') {
         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');
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 20d71362e..adc24eabf 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -2,7 +2,7 @@ import api from '../api';
 
 import { deleteFromTimelines } from './timelines';
 import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } 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';
@@ -30,6 +30,10 @@ export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
 
 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,
@@ -84,6 +88,37 @@ export function redraft(status, raw_text) {
   };
 };
 
+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/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 4e19cc0e4..e3a7d763f 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -12,6 +12,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}' },
@@ -137,6 +138,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);
   }
@@ -255,6 +260,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/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index 9abdec138..ef0aca13a 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -24,6 +24,7 @@ import {
   hideStatus,
   revealStatus,
   toggleStatusCollapse,
+  editStatus,
 } from '../actions/statuses';
 import {
   unmuteAccount,
@@ -142,6 +143,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onEdit (status, history) {
+    dispatch(editStatus(status.get('id'), history));
+  },
+
   onDirect (account, router) {
     dispatch(directCompose(account, router));
   },
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 647d0fba2..311d00626 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -29,6 +29,7 @@ const messages = defineMessages({
   spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
   publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
   publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
+  saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
 });
 
 export default @injectIntl
@@ -50,6 +51,7 @@ class ComposeForm extends ImmutablePureComponent {
     preselectDate: PropTypes.instanceOf(Date),
     isSubmitting: PropTypes.bool,
     isChangingUpload: PropTypes.bool,
+    isEditing: PropTypes.bool,
     isUploading: PropTypes.bool,
     onChange: PropTypes.func.isRequired,
     onSubmit: PropTypes.func.isRequired,
@@ -200,7 +202,9 @@ class ComposeForm extends ImmutablePureComponent {
     const disabled = this.props.isSubmitting;
     let publishText = '';
 
-    if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
+    if (this.props.isEditing) {
+      publishText = intl.formatMessage(messages.saveChanges);
+    } else if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
       publishText = <span className='compose-form__publish-private'><Icon id='lock' /> {intl.formatMessage(messages.publish)}</span>;
     } else {
       publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
index c44850294..1be7633cc 100644
--- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
@@ -21,6 +21,7 @@ const mapStateToProps = state => ({
   caretPosition: state.getIn(['compose', 'caretPosition']),
   preselectDate: state.getIn(['compose', 'preselectDate']),
   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']),
   showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
diff --git a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
index 5eb1eb72a..a1302b2d4 100644
--- a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
+++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
@@ -6,9 +6,20 @@ import ReplyIndicator from '../components/reply_indicator';
 const makeMapStateToProps = () => {
   const getStatus = makeGetStatus();
 
-  const mapStateToProps = state => ({
-    status: getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) }),
-  });
+  const mapStateToProps = state => {
+    let statusId = state.getIn(['compose', 'id'], null);
+    let editing  = true;
+
+    if (statusId === null) {
+      statusId = state.getIn(['compose', 'in_reply_to']);
+      editing  = false;
+    }
+
+    return {
+      status: getStatus(state, { id: statusId }),
+      editing,
+    };
+  };
 
   return mapStateToProps;
 };
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index a15a4d567..3f3ec0e71 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/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' },
@@ -59,6 +60,7 @@ class ActionBar extends React.PureComponent {
     onFavourite: PropTypes.func.isRequired,
     onBookmark: PropTypes.func.isRequired,
     onDelete: PropTypes.func.isRequired,
+    onEdit: PropTypes.func.isRequired,
     onDirect: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
     onMute: PropTypes.func,
@@ -98,6 +100,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);
   }
@@ -209,6 +215,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/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index f342a3641..4d7f24834 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -29,6 +29,7 @@ import {
   muteStatus,
   unmuteStatus,
   deleteStatus,
+  editStatus,
   hideStatus,
   revealStatus,
 } from '../../actions/statuses';
@@ -273,6 +274,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));
   }
@@ -567,6 +572,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/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 06a908e9d..ea882a71f 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -43,6 +43,7 @@ import {
   INIT_MEDIA_EDIT_MODAL,
   COMPOSE_CHANGE_MEDIA_DESCRIPTION,
   COMPOSE_CHANGE_MEDIA_FOCUS,
+  COMPOSE_SET_STATUS,
 } from '../actions/compose';
 import { TIMELINE_DELETE } from '../actions/timelines';
 import { STORE_HYDRATE } from '../actions/store';
@@ -58,6 +59,7 @@ const initialState = ImmutableMap({
   spoiler: false,
   spoiler_text: '',
   privacy: null,
+  id: null,
   text: '',
   focusDate: null,
   caretPosition: null,
@@ -107,6 +109,7 @@ function statusToTextMentions(state, status) {
 
 function clearAll(state) {
   return state.withMutations(map => {
+    map.set('id', null);
     map.set('text', '');
     map.set('spoiler', false);
     map.set('spoiler_text', '');
@@ -313,6 +316,7 @@ export default function compose(state = initialState, action) {
     return state.set('is_composing', action.value);
   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')));
@@ -329,21 +333,12 @@ export default function compose(state = initialState, action) {
         map.set('spoiler_text', '');
       }
     });
-  case COMPOSE_REPLY_CANCEL:
-  case COMPOSE_RESET:
-    return state.withMutations(map => {
-      map.set('in_reply_to', null);
-      map.set('text', '');
-      map.set('spoiler', false);
-      map.set('spoiler_text', '');
-      map.set('privacy', state.get('default_privacy'));
-      map.set('poll', null);
-      map.set('idempotencyKey', uuid());
-    });
   case COMPOSE_SUBMIT_REQUEST:
     return state.set('is_submitting', true);
   case COMPOSE_UPLOAD_CHANGE_REQUEST:
     return state.set('is_changing_upload', true);
+  case COMPOSE_REPLY_CANCEL:
+  case COMPOSE_RESET:
   case COMPOSE_SUBMIT_SUCCESS:
     return clearAll(state);
   case COMPOSE_SUBMIT_FAIL:
@@ -462,6 +457,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/models/media_attachment.rb b/app/models/media_attachment.rb
index 14e6cabae..9eaacdc03 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -208,6 +208,10 @@ class MediaAttachment < ApplicationRecord
     file.blank? && remote_url.present?
   end
 
+  def significantly_changed?
+    description_previously_changed? || thumbnail_updated_at_previously_changed? || file_meta_previously_changed?
+  end
+
   def larger_media_format?
     video? || gifv? || audio?
   end
diff --git a/app/models/poll.rb b/app/models/poll.rb
index 71b5e191f..ba08309a1 100644
--- a/app/models/poll.rb
+++ b/app/models/poll.rb
@@ -83,6 +83,12 @@ class Poll < ApplicationRecord
     end
   end
 
+  def reset_votes!
+    self.cached_tallies = options.map { 0 }
+    self.votes_count = 0
+    votes.delete_all unless new_record?
+  end
+
   private
 
   def prepare_cached_tallies
diff --git a/app/models/report.rb b/app/models/report.rb
index ceb15133b..3dd8a6fdd 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -39,6 +39,9 @@ class Report < ApplicationRecord
   scope :with_accounts, -> { includes([:account, :target_account, :action_taken_by_account, :assigned_account].index_with({ user: [:invite_request, :invite] })) }
 
   validates :comment, length: { maximum: 1_000 }
+  validates :rule_ids, absence: true, unless: :violation?
+
+  validate :validate_rule_ids
 
   enum category: {
     other: 0,
@@ -122,4 +125,10 @@ class Report < ApplicationRecord
   def set_uri
     self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil? && account.local?
   end
+
+  def validate_rule_ids
+    return unless violation?
+
+    errors.add(:rule_ids, I18n.t('reports.errors.invalid_rules')) unless rules.size == rule_ids.size
+  end
 end
diff --git a/app/models/status.rb b/app/models/status.rb
index 9bb2b3746..e5a0beab6 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -215,6 +215,16 @@ class Status < ApplicationRecord
     public_visibility? || unlisted_visibility?
   end
 
+  def snapshot!(media_attachments_changed: false, account_id: nil, at_time: nil)
+    edits.create!(
+      text: text,
+      spoiler_text: spoiler_text,
+      media_attachments_changed: media_attachments_changed,
+      account_id: account_id || self.account_id,
+      created_at: at_time || edited_at
+    )
+  end
+
   def edited?
     edited_at.present?
   end
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index d0359580d..8746fb2c6 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -39,7 +39,7 @@ class StatusPolicy < ApplicationPolicy
   alias unreblog? destroy?
 
   def update?
-    staff?
+    staff? || owned?
   end
 
   private
diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb
index b1cea1cdf..7438a7c53 100644
--- a/app/services/activitypub/process_status_update_service.rb
+++ b/app/services/activitypub/process_status_update_service.rb
@@ -95,10 +95,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
 
       # If for some reasons the options were changed, it invalidates all previous
       # votes, so we need to remove them
-      if poll_parser.significantly_changes?(poll)
-        @poll_changed = true
-        poll.votes.delete_all unless poll.new_record?
-      end
+      @poll_changed = true if poll_parser.significantly_changes?(poll)
 
       poll.last_fetched_at = Time.now.utc
       poll.options         = poll_parser.options
@@ -106,6 +103,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
       poll.expires_at      = poll_parser.expires_at
       poll.voters_count    = poll_parser.voters_count
       poll.cached_tallies  = poll_parser.cached_tallies
+      poll.reset_votes! if @poll_changed
       poll.save!
 
       @status.poll_id = poll.id
@@ -217,24 +215,18 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
 
     return if @status.edits.any?
 
-    @status.edits.create(
-      text: @status.text,
-      spoiler_text: @status.spoiler_text,
+    @status.snapshot!(
       media_attachments_changed: false,
-      account_id: @account.id,
-      created_at: @status.created_at
+      at_time: @status.created_at
     )
   end
 
   def create_edit!
     return unless significant_changes?
 
-    @status_edit = @status.edits.create(
-      text: @status.text,
-      spoiler_text: @status.spoiler_text,
+    @status.snapshot!(
       media_attachments_changed: @media_attachments_changed || @poll_changed,
-      account_id: @account.id,
-      created_at: @status.edited_at
+      account_id: @account.id
     )
   end
 
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index 47277c56c..43d7bcca6 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -1,20 +1,40 @@
 # frozen_string_literal: true
 
 class ProcessHashtagsService < BaseService
-  def call(status, tags = [])
-    tags    = Extractor.extract_hashtags(status.text) if status.local?
-    records = []
-
-    Tag.find_or_create_by_names(tags) do |tag|
-      status.tags << tag
-      records << tag
-      tag.update(last_status_at: status.created_at) if tag.last_status_at.nil? || (tag.last_status_at < status.created_at && tag.last_status_at < 12.hours.ago)
+  def call(status, raw_tags = [])
+    @status        = status
+    @account       = status.account
+    @raw_tags      = status.local? ? Extractor.extract_hashtags(status.text) : raw_tags
+    @previous_tags = status.tags.to_a
+    @current_tags  = []
+
+    assign_tags!
+    update_featured_tags!
+  end
+
+  private
+
+  def assign_tags!
+    @status.tags = @current_tags = Tag.find_or_create_by_names(@raw_tags)
+  end
+
+  def update_featured_tags!
+    return unless @status.distributable?
+
+    added_tags = @current_tags - @previous_tags
+
+    unless added_tags.empty?
+      @account.featured_tags.where(tag_id: added_tags.map(&:id)).each do |featured_tag|
+        featured_tag.increment(@status.created_at)
+      end
     end
 
-    return unless status.distributable?
+    removed_tags = @previous_tags - @current_tags
 
-    status.account.featured_tags.where(tag_id: records.map(&:id)).each do |featured_tag|
-      featured_tag.increment(status.created_at)
+    unless removed_tags.empty?
+      @account.featured_tags.where(tag_id: removed_tags.map(&:id)).each do |featured_tag|
+        featured_tag.decrement(@status.id)
+      end
     end
   end
 end
diff --git a/app/services/report_service.rb b/app/services/report_service.rb
index bc0a8b464..caf99ab6e 100644
--- a/app/services/report_service.rb
+++ b/app/services/report_service.rb
@@ -8,13 +8,15 @@ class ReportService < BaseService
     @target_account = target_account
     @status_ids     = options.delete(:status_ids) || []
     @comment        = options.delete(:comment) || ''
+    @category       = options.delete(:category) || 'other'
+    @rule_ids       = options.delete(:rule_ids)
     @options        = options
 
     raise ActiveRecord::RecordNotFound if @target_account.suspended?
 
     create_report!
     notify_staff!
-    forward_to_origin! if !@target_account.local? && ActiveModel::Type::Boolean.new.cast(@options[:forward])
+    forward_to_origin! if forward?
 
     @report
   end
@@ -27,7 +29,9 @@ class ReportService < BaseService
       status_ids: @status_ids,
       comment: @comment,
       uri: @options[:uri],
-      forwarded: ActiveModel::Type::Boolean.new.cast(@options[:forward])
+      forwarded: forward?,
+      category: @category,
+      rule_ids: @rule_ids
     )
   end
 
@@ -48,6 +52,10 @@ class ReportService < BaseService
     )
   end
 
+  def forward?
+    !@target_account.local? && ActiveModel::Type::Boolean.new.cast(@options[:forward])
+  end
+
   def payload
     Oj.dump(serialize_payload(@report, ActivityPub::FlagSerializer, account: some_local_account))
   end
diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb
new file mode 100644
index 000000000..238ef0755
--- /dev/null
+++ b/app/services/update_status_service.rb
@@ -0,0 +1,151 @@
+# frozen_string_literal: true
+
+class UpdateStatusService < BaseService
+  include Redisable
+  include LanguagesHelper
+
+  # @param [Status] status
+  # @param [Integer] account_id
+  # @param [Hash] options
+  # @option options [Array<Integer>] :media_ids
+  # @option options [Hash] :poll
+  # @option options [String] :text
+  # @option options [String] :spoiler_text
+  # @option options [Boolean] :sensitive
+  # @option options [String] :language
+  def call(status, account_id, options = {})
+    @status                    = status
+    @options                   = options
+    @account_id                = account_id
+    @media_attachments_changed = false
+    @poll_changed              = false
+
+    Status.transaction do
+      create_previous_edit!
+      update_media_attachments!
+      update_poll!
+      update_immediate_attributes!
+      create_edit!
+    end
+
+    queue_poll_notifications!
+    reset_preview_card!
+    update_metadata!
+    broadcast_updates!
+
+    @status
+  end
+
+  private
+
+  def update_media_attachments!
+    previous_media_attachments = @status.media_attachments.to_a
+    next_media_attachments     = validate_media!
+    removed_media_attachments  = previous_media_attachments - next_media_attachments
+    added_media_attachments    = next_media_attachments - previous_media_attachments
+
+    MediaAttachment.where(id: removed_media_attachments.map(&:id)).update_all(status_id: nil)
+    MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id)
+
+    @status.media_attachments.reload
+    @media_attachments_changed = true if removed_media_attachments.any? || added_media_attachments.any?
+  end
+
+  def validate_media!
+    return [] if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable)
+
+    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 || @options[:poll].present?
+
+    media_attachments = @status.account.media_attachments.where(status_id: [nil, @status.id]).where(scheduled_status_id: nil).where(id: @options[:media_ids].take(4).map(&:to_i)).to_a
+
+    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.images_and_video') if media_attachments.size > 1 && media_attachments.find(&:audio_or_video?)
+    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.not_ready') if media_attachments.any?(&:not_processed?)
+
+    media_attachments
+  end
+
+  def update_poll!
+    previous_poll        = @status.preloadable_poll
+    @previous_expires_at = previous_poll&.expires_at
+
+    if @options[:poll].present?
+      poll = previous_poll || @status.account.polls.new(status: @status, votes_count: 0)
+
+      # If for some reasons the options were changed, it invalidates all previous
+      # votes, so we need to remove them
+      @poll_changed = true if @options[:poll][:options] != poll.options || ActiveModel::Type::Boolean.new.cast(@options[:poll][:multiple]) != poll.multiple
+
+      poll.options     = @options[:poll][:options]
+      poll.hide_totals = @options[:poll][:hide_totals] || false
+      poll.multiple    = @options[:poll][:multiple] || false
+      poll.expires_in  = @options[:poll][:expires_in]
+      poll.reset_votes! if @poll_changed
+      poll.save!
+
+      @status.poll_id = poll.id
+    elsif previous_poll.present?
+      previous_poll.destroy
+      @poll_changed = true
+      @status.poll_id = nil
+    end
+  end
+
+  def update_immediate_attributes!
+    @status.text         = @options[:text].presence || @options.delete(:spoiler_text) || ''
+    @status.spoiler_text = @options[:spoiler_text] || ''
+    @status.sensitive    = @options[:sensitive] || @options[:spoiler_text].present?
+    @status.language     = valid_locale_or_nil(@options[:language] || @status.language || @status.account.user&.preferred_posting_language || I18n.default_locale)
+    @status.edited_at    = Time.now.utc
+
+    @status.save!
+  end
+
+  def reset_preview_card!
+    return unless @status.text_previously_changed?
+
+    @status.preview_cards.clear
+    LinkCrawlWorker.perform_async(@status.id)
+  end
+
+  def update_metadata!
+    ProcessHashtagsService.new.call(@status)
+    ProcessMentionsService.new.call(@status)
+  end
+
+  def broadcast_updates!
+    DistributionWorker.perform_async(@status.id, { 'update' => true })
+    ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id)
+  end
+
+  def queue_poll_notifications!
+    poll = @status.preloadable_poll
+
+    # If the poll had no expiration date set but now has, or now has a sooner
+    # expiration date, and people have voted, schedule a notification
+
+    return unless poll.present? && poll.expires_at.present? && poll.votes.exists?
+
+    PollExpirationNotifyWorker.remove_from_scheduled(poll.id) if @previous_expires_at.present? && @previous_expires_at > poll.expires_at
+    PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id)
+  end
+
+  def create_previous_edit!
+    # We only need to create a previous edit when no previous edits exist, e.g.
+    # when the status has never been edited. For other cases, we always create
+    # an edit, so the step can be skipped
+
+    return if @status.edits.any?
+
+    @status.snapshot!(
+      media_attachments_changed: false,
+      at_time: @status.created_at
+    )
+  end
+
+  def create_edit!
+    @status.snapshot!(
+      media_attachments_changed: @media_attachments_changed || @poll_changed,
+      account_id: @account_id
+    )
+  end
+end
diff --git a/app/workers/activitypub/status_update_distribution_worker.rb b/app/workers/activitypub/status_update_distribution_worker.rb
new file mode 100644
index 000000000..a79ede2bf
--- /dev/null
+++ b/app/workers/activitypub/status_update_distribution_worker.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class ActivityPub::StatusUpdateDistributionWorker < ActivityPub::DistributionWorker
+  # Distribute an profile update to servers that might have a copy
+  # of the account in question
+  def perform(status_id, options = {})
+    @options = options.with_indifferent_access
+    @status  = Status.find(status_id)
+    @account = @status.account
+
+    distribute!
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+
+  protected
+
+  def activity
+    ActivityPub::ActivityPresenter.new(
+      id: [ActivityPub::TagManager.instance.uri_for(@status), '#updates/', @status.edited_at.to_i].join,
+      type: 'Update',
+      actor: ActivityPub::TagManager.instance.uri_for(@status.account),
+      published: @status.edited_at,
+      to: ActivityPub::TagManager.instance.to(@status),
+      cc: ActivityPub::TagManager.instance.cc(@status),
+      virtual_object: @status
+    )
+  end
+end
diff --git a/chart/templates/ingress.yaml b/chart/templates/ingress.yaml
index 8930d2c10..7295297fb 100644
--- a/chart/templates/ingress.yaml
+++ b/chart/templates/ingress.yaml
@@ -2,7 +2,9 @@
 {{- $fullName := include "mastodon.fullname" . -}}
 {{- $webPort := .Values.mastodon.web.port -}}
 {{- $streamingPort := .Values.mastodon.streaming.port -}}
-{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
+{{- if or (.Capabilities.APIVersions.Has "networking.k8s.io/v1/Ingress") (not (.Capabilities.APIVersions.Has "networking.k8s.io/v1beta1/Ingress")) }}
+apiVersion: networking.k8s.io/v1
+{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
 apiVersion: networking.k8s.io/v1beta1
 {{- else -}}
 apiVersion: extensions/v1beta1
@@ -35,12 +37,32 @@ spec:
           {{- range .paths }}
           - path: {{ .path }}
             backend:
+              {{- if or ($.Capabilities.APIVersions.Has "networking.k8s.io/v1/Ingress") (not ($.Capabilities.APIVersions.Has "networking.k8s.io/v1beta1/Ingress")) }}
+              service:
+                name: {{ $fullName }}-web
+                port:
+                  number: {{ $webPort }}
+              {{- else }}
               serviceName: {{ $fullName }}-web
               servicePort: {{ $webPort }}
+              {{- end }}
+            {{- if or ($.Capabilities.APIVersions.Has "networking.k8s.io/v1/Ingress") (not ($.Capabilities.APIVersions.Has "networking.k8s.io/v1beta1/Ingress")) }}
+            pathType: ImplementationSpecific
+            {{- end }}
           - path: {{ .path }}api/v1/streaming
             backend:
+              {{- if or ($.Capabilities.APIVersions.Has "networking.k8s.io/v1/Ingress") (not ($.Capabilities.APIVersions.Has "networking.k8s.io/v1beta1/Ingress")) }}
+              service:
+                name: {{ $fullName }}-streaming
+                port:
+                  number: {{ $streamingPort }}
+              {{- else }}
               serviceName: {{ $fullName }}-streaming
               servicePort: {{ $streamingPort }}
+              {{- end }}
+            {{- if or ($.Capabilities.APIVersions.Has "networking.k8s.io/v1/Ingress") (not ($.Capabilities.APIVersions.Has "networking.k8s.io/v1beta1/Ingress")) }}
+            pathType: ImplementationSpecific
+            {{- end }}
           {{- end }}
     {{- end }}
 {{- end }}
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 600090e78..ccaff84b4 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1225,6 +1225,9 @@ en:
     reply:
       proceed: Proceed to reply
       prompt: 'You want to reply to this post:'
+  reports:
+    errors:
+      invalid_rules: does not reference valid rules
   scheduled_statuses:
     over_daily_limit: You have exceeded the limit of %{limit} scheduled posts for today
     over_total_limit: You have exceeded the limit of %{limit} scheduled posts
diff --git a/config/routes.rb b/config/routes.rb
index d0eeda1e8..0c7b27cb8 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -335,7 +335,7 @@ Rails.application.routes.draw do
 
     # JSON / REST API
     namespace :v1 do
-      resources :statuses, only: [:create, :show, :destroy] do
+      resources :statuses, only: [:create, :show, :update, :destroy] do
         scope module: :statuses do
           resources :reblogged_by, controller: :reblogged_by_accounts, only: :index
           resources :favourited_by, controller: :favourited_by_accounts, only: :index
diff --git a/spec/controllers/api/v1/media_controller_spec.rb b/spec/controllers/api/v1/media_controller_spec.rb
index d8d732630..a1f6ddb24 100644
--- a/spec/controllers/api/v1/media_controller_spec.rb
+++ b/spec/controllers/api/v1/media_controller_spec.rb
@@ -110,21 +110,24 @@ RSpec.describe Api::V1::MediaController, type: :controller do
       end
     end
 
-    context 'when not attached to a status' do
-      let(:media) { Fabricate(:media_attachment, status: nil, account: user.account) }
+    context 'when the author \'s' do
+      let(:status) { nil }
+      let(:media)  { Fabricate(:media_attachment, status: status, account: user.account) }
 
-      it 'updates the description' do
+      before do
         put :update, params: { id: media.id, description: 'Lorem ipsum!!!' }
+      end
+
+      it 'updates the description' do
         expect(media.reload.description).to eq 'Lorem ipsum!!!'
       end
-    end
 
-    context 'when attached to a status' do
-      let(:media) { Fabricate(:media_attachment, status: Fabricate(:status), account: user.account) }
+      context 'when already attached to a status' do
+        let(:status) { Fabricate(:status, account: user.account) }
 
-      it 'returns http not found' do
-        put :update, params: { id: media.id, description: 'Lorem ipsum!!!' }
-        expect(response).to have_http_status(:not_found)
+        it 'returns http not found' do
+          expect(response).to have_http_status(:not_found)
+        end
       end
     end
   end
diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb
index 2679ab017..190dfad11 100644
--- a/spec/controllers/api/v1/statuses_controller_spec.rb
+++ b/spec/controllers/api/v1/statuses_controller_spec.rb
@@ -102,6 +102,23 @@ RSpec.describe Api::V1::StatusesController, type: :controller do
         expect(Status.find_by(id: status.id)).to be nil
       end
     end
+
+    describe 'PUT #update' do
+      let(:scopes) { 'write:statuses' }
+      let(:status) { Fabricate(:status, account: user.account) }
+
+      before do
+        put :update, params: { id: status.id, status: 'I am updated' }
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+
+      it 'updates the status' do
+        expect(status.reload.text).to eq 'I am updated'
+      end
+    end
   end
 
   context 'without an oauth token' do
diff --git a/spec/policies/status_policy_spec.rb b/spec/policies/status_policy_spec.rb
index 8bce29cad..865c693aa 100644
--- a/spec/policies/status_policy_spec.rb
+++ b/spec/policies/status_policy_spec.rb
@@ -137,7 +137,7 @@ RSpec.describe StatusPolicy, type: :model do
     end
   end
 
-  permissions :index?, :update? do
+  permissions :index? do
     it 'grants access if staff' do
       expect(subject).to permit(admin.account)
     end
@@ -146,4 +146,18 @@ RSpec.describe StatusPolicy, type: :model do
       expect(subject).to_not permit(alice)
     end
   end
+
+  permissions :update? do
+    it 'grants access if staff' do
+      expect(subject).to permit(admin.account, status)
+    end
+
+    it 'grants access if owner' do
+      expect(subject).to permit(status.account, status)
+    end
+
+    it 'denies access unless staff' do
+      expect(subject).to_not permit(bob, status)
+    end
+  end
 end
diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb
new file mode 100644
index 000000000..6ee1dcb43
--- /dev/null
+++ b/spec/services/activitypub/process_status_update_service_spec.rb
@@ -0,0 +1,248 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
+  let!(:status) { Fabricate(:status, text: 'Hello world', account: Fabricate(:account, domain: 'example.com')) }
+
+  let(:alice) { Fabricate(:account) }
+  let(:bob) { Fabricate(:account) }
+
+  let(:mentions) { [] }
+  let(:tags) { [] }
+  let(:media_attachments) { [] }
+
+  before do
+    mentions.each { |a| Fabricate(:mention, status: status, account: a) }
+    tags.each { |t| status.tags << t }
+    media_attachments.each { |m| status.media_attachments << m }
+  end
+
+  let(:payload) do
+    {
+      '@context': 'https://www.w3.org/ns/activitystreams',
+      id: 'foo',
+      type: 'Note',
+      summary: 'Show more',
+      content: 'Hello universe',
+      updated: '2021-09-08T22:39:25Z',
+      tag: [
+        { type: 'Hashtag', name: 'hoge' },
+        { type: 'Mention', href: ActivityPub::TagManager.instance.uri_for(alice) },
+      ],
+    }
+  end
+
+  let(:json) { Oj.load(Oj.dump(payload)) }
+
+  subject { described_class.new }
+
+  describe '#call' do
+    it 'updates text' do
+      subject.call(status, json)
+      expect(status.reload.text).to eq 'Hello universe'
+    end
+
+    it 'updates content warning' do
+      subject.call(status, json)
+      expect(status.reload.spoiler_text).to eq 'Show more'
+    end
+
+    context 'originally without tags' do
+      before do
+        subject.call(status, json)
+      end
+
+      it 'updates tags' do
+        expect(status.tags.reload.map(&:name)).to eq %w(hoge)
+      end
+    end
+
+    context 'originally with tags' do
+      let(:tags) { [Fabricate(:tag, name: 'test'), Fabricate(:tag, name: 'foo')] }
+
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'foo',
+          type: 'Note',
+          summary: 'Show more',
+          content: 'Hello universe',
+          updated: '2021-09-08T22:39:25Z',
+          tag: [
+            { type: 'Hashtag', name: 'foo' },
+          ],
+        }
+      end
+
+      before do
+        subject.call(status, json)
+      end
+
+      it 'updates tags' do
+        expect(status.tags.reload.map(&:name)).to eq %w(foo)
+      end
+    end
+
+    context 'originally without mentions' do
+      before do
+        subject.call(status, json)
+      end
+
+      it 'updates mentions' do
+        expect(status.active_mentions.reload.map(&:account_id)).to eq [alice.id]
+      end
+    end
+
+    context 'originally with mentions' do
+      let(:mentions) { [alice, bob] }
+
+      before do
+        subject.call(status, json)
+      end
+
+      it 'updates mentions' do
+        expect(status.active_mentions.reload.map(&:account_id)).to eq [alice.id]
+      end
+    end
+
+    context 'originally without media attachments' do
+      before do
+        allow(RedownloadMediaWorker).to receive(:perform_async)
+        subject.call(status, json)
+      end
+
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'foo',
+          type: 'Note',
+          content: 'Hello universe',
+          updated: '2021-09-08T22:39:25Z',
+          attachment: [
+            { type: 'Image', mediaType: 'image/png', url: 'https://example.com/foo.png' },
+          ]
+        }
+      end
+
+      it 'updates media attachments' do
+        media_attachment = status.media_attachments.reload.first
+
+        expect(media_attachment).to_not be_nil
+        expect(media_attachment.remote_url).to eq 'https://example.com/foo.png'
+      end
+
+      it 'queues download of media attachments' do
+        expect(RedownloadMediaWorker).to have_received(:perform_async)
+      end
+
+      it 'records media change in edit' do
+        expect(status.edits.reload.last.media_attachments_changed).to be true
+      end
+    end
+
+    context 'originally with media attachments' do
+      let(:media_attachments) { [Fabricate(:media_attachment, remote_url: 'https://example.com/foo.png'), Fabricate(:media_attachment, remote_url: 'https://example.com/unused.png')] }
+
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'foo',
+          type: 'Note',
+          content: 'Hello universe',
+          updated: '2021-09-08T22:39:25Z',
+          attachment: [
+            { type: 'Image', mediaType: 'image/png', url: 'https://example.com/foo.png', name: 'A picture' },
+          ]
+        }
+      end
+
+      before do
+        allow(RedownloadMediaWorker).to receive(:perform_async)
+        subject.call(status, json)
+      end
+
+      it 'updates the existing media attachment in-place' do
+        media_attachment = status.media_attachments.reload.first
+
+        expect(media_attachment).to_not be_nil
+        expect(media_attachment.remote_url).to eq 'https://example.com/foo.png'
+        expect(media_attachment.description).to eq 'A picture'
+      end
+
+      it 'does not queue redownload for the existing media attachment' do
+        expect(RedownloadMediaWorker).to_not have_received(:perform_async)
+      end
+
+      it 'updates media attachments' do
+        expect(status.media_attachments.reload.map(&:remote_url)).to eq %w(https://example.com/foo.png)
+      end
+
+      it 'records media change in edit' do
+        expect(status.edits.reload.last.media_attachments_changed).to be true
+      end
+    end
+
+    context 'originally with a poll' do
+      before do
+        poll = Fabricate(:poll, status: status)
+        status.update(preloadable_poll: poll)
+        subject.call(status, json)
+      end
+
+      it 'removes poll' do
+        expect(status.reload.poll).to eq nil
+      end
+
+      it 'records media change in edit' do
+        expect(status.edits.reload.last.media_attachments_changed).to be true
+      end
+    end
+
+    context 'originally without a poll' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'foo',
+          type: 'Question',
+          content: 'Hello universe',
+          updated: '2021-09-08T22:39:25Z',
+          closed: true,
+          oneOf: [
+            { type: 'Note', name: 'Foo' },
+            { type: 'Note', name: 'Bar' },
+            { type: 'Note', name: 'Baz' },
+          ],
+        }
+      end
+
+      before do
+        subject.call(status, json)
+      end
+
+      it 'creates a poll' do
+        poll = status.reload.poll
+
+        expect(poll).to_not be_nil
+        expect(poll.options).to eq %w(Foo Bar Baz)
+      end
+
+      it 'records media change in edit' do
+        expect(status.edits.reload.last.media_attachments_changed).to be true
+      end
+    end
+
+    it 'creates edit history' do
+      subject.call(status, json)
+      expect(status.edits.reload.map(&:text)).to eq ['Hello world', 'Hello universe']
+    end
+
+    it 'sets edited timestamp' do
+      subject.call(status, json)
+      expect(status.reload.edited_at.to_s).to eq '2021-09-08 22:39:25 UTC'
+    end
+
+    it 'records that no media has been changed in edit' do
+      subject.call(status, json)
+      expect(status.edits.reload.last.media_attachments_changed).to be false
+    end
+  end
+end
diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb
new file mode 100644
index 000000000..fe1b60d24
--- /dev/null
+++ b/spec/services/update_status_service_spec.rb
@@ -0,0 +1,140 @@
+require 'rails_helper'
+
+RSpec.describe UpdateStatusService, type: :service do
+  subject { described_class.new }
+
+  context 'when text changes' do
+    let!(:status) { Fabricate(:status, text: 'Foo') }
+    let(:preview_card) { Fabricate(:preview_card) }
+
+    before do
+      status.preview_cards << preview_card
+      subject.call(status, status.account_id, text: 'Bar')
+    end
+
+    it 'updates text' do
+      expect(status.reload.text).to eq 'Bar'
+    end
+
+    it 'resets preview card' do
+      expect(status.reload.preview_card).to be_nil
+    end
+
+    it 'saves edit history' do
+      expect(status.edits.pluck(:text, :media_attachments_changed)).to eq [['Foo', false], ['Bar', false]]
+    end
+  end
+
+  context 'when content warning changes' do
+    let!(:status) { Fabricate(:status, text: 'Foo', spoiler_text: '') }
+    let(:preview_card) { Fabricate(:preview_card) }
+
+    before do
+      status.preview_cards << preview_card
+      subject.call(status, status.account_id, text: 'Foo', spoiler_text: 'Bar')
+    end
+
+    it 'updates content warning' do
+      expect(status.reload.spoiler_text).to eq 'Bar'
+    end
+
+    it 'saves edit history' do
+      expect(status.edits.pluck(:text, :spoiler_text, :media_attachments_changed)).to eq [['Foo', '', false], ['Foo', 'Bar', false]]
+    end
+  end
+
+  context 'when media attachments change' do
+    let!(:status) { Fabricate(:status, text: 'Foo') }
+    let!(:detached_media_attachment) { Fabricate(:media_attachment, account: status.account) }
+    let!(:attached_media_attachment) { Fabricate(:media_attachment, account: status.account) }
+
+    before do
+      status.media_attachments << detached_media_attachment
+      subject.call(status, status.account_id, text: 'Foo', media_ids: [attached_media_attachment.id])
+    end
+
+    it 'updates media attachments' do
+      expect(status.media_attachments.to_a).to eq [attached_media_attachment]
+    end
+
+    it 'detaches detached media attachments' do
+      expect(detached_media_attachment.reload.status_id).to be_nil
+    end
+
+    it 'attaches attached media attachments' do
+      expect(attached_media_attachment.reload.status_id).to eq status.id
+    end
+
+    it 'saves edit history' do
+      expect(status.edits.pluck(:text, :media_attachments_changed)).to eq [['Foo', false], ['Foo', true]]
+    end
+  end
+
+  context 'when poll changes' do
+    let(:account) { Fabricate(:account) }
+    let!(:status) { Fabricate(:status, text: 'Foo', account: account, poll_attributes: {options: %w(Foo Bar), account: account, multiple: false, hide_totals: false, expires_at: 7.days.from_now }) }
+    let!(:poll)   { status.poll }
+    let!(:voter) { Fabricate(:account) }
+
+    before do
+      status.update(poll: poll)
+      VoteService.new.call(voter, poll, [0])
+      subject.call(status, status.account_id, text: 'Foo', poll: { options: %w(Bar Baz Foo), expires_in: 5.days.to_i })
+    end
+
+    it 'updates poll' do
+      poll = status.poll.reload
+      expect(poll.options).to eq %w(Bar Baz Foo)
+    end
+
+    it 'resets votes' do
+      poll = status.poll.reload
+      expect(poll.votes_count).to eq 0
+      expect(poll.votes.count).to eq 0
+      expect(poll.cached_tallies).to eq [0, 0, 0]
+    end
+
+    it 'saves edit history' do
+      expect(status.edits.pluck(:text, :media_attachments_changed)).to eq [['Foo', false], ['Foo', true]]
+    end
+  end
+
+  context 'when mentions in text change' do
+    let!(:account) { Fabricate(:account) }
+    let!(:alice) { Fabricate(:account, username: 'alice') }
+    let!(:bob) { Fabricate(:account, username: 'bob') }
+    let!(:status) { PostStatusService.new.call(account, text: 'Hello @alice') }
+
+    before do
+      subject.call(status, status.account_id, text: 'Hello @bob')
+    end
+
+    it 'changes mentions' do
+      expect(status.active_mentions.pluck(:account_id)).to eq [bob.id]
+    end
+
+    it 'keeps old mentions as silent mentions' do
+      expect(status.mentions.pluck(:account_id)).to eq [alice.id, bob.id]
+    end
+  end
+
+  context 'when hashtags in text change' do
+    let!(:account) { Fabricate(:account) }
+    let!(:status) { PostStatusService.new.call(account, text: 'Hello #foo') }
+
+    before do
+      subject.call(status, status.account_id, text: 'Hello #bar')
+    end
+
+    it 'changes tags' do
+      expect(status.tags.pluck(:name)).to eq %w(bar)
+    end
+  end
+
+  it 'notifies ActivityPub about the update' do
+    status = Fabricate(:status, text: 'Foo')
+    allow(ActivityPub::DistributionWorker).to receive(:perform_async)
+    subject.call(status, status.account_id, text: 'Bar')
+    expect(ActivityPub::DistributionWorker).to have_received(:perform_async)
+  end
+end
diff --git a/spec/workers/activitypub/status_update_distribution_worker_spec.rb b/spec/workers/activitypub/status_update_distribution_worker_spec.rb
new file mode 100644
index 000000000..6633b601f
--- /dev/null
+++ b/spec/workers/activitypub/status_update_distribution_worker_spec.rb
@@ -0,0 +1,48 @@
+require 'rails_helper'
+
+describe ActivityPub::StatusUpdateDistributionWorker do
+  subject { described_class.new }
+
+  let(:status)   { Fabricate(:status, text: 'foo') }
+  let(:follower) { Fabricate(:account, protocol: :activitypub, inbox_url: 'http://example.com') }
+
+  describe '#perform' do
+    before do
+      follower.follow!(status.account)
+
+      status.snapshot!
+      status.text = 'bar'
+      status.edited_at = Time.now.utc
+      status.snapshot!
+      status.save!
+    end
+
+    context 'with public status' do
+      before do
+        status.update(visibility: :public)
+      end
+
+      it 'delivers to followers' do
+        expect(ActivityPub::DeliveryWorker).to receive(:push_bulk) do |items, &block|
+          expect(items.map(&block)).to match([[kind_of(String), status.account.id, 'http://example.com', anything]])
+        end
+
+        subject.perform(status.id)
+      end
+    end
+
+    context 'with private status' do
+      before do
+        status.update(visibility: :private)
+      end
+
+      it 'delivers to followers' do
+        expect(ActivityPub::DeliveryWorker).to receive(:push_bulk) do |items, &block|
+          expect(items.map(&block)).to match([[kind_of(String), status.account.id, 'http://example.com', anything]])
+        end
+
+        subject.perform(status.id)
+      end
+    end
+  end
+end
diff --git a/yarn.lock b/yarn.lock
index cf7f2d16e..1aff13f4d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2911,20 +2911,10 @@ caniuse-api@^3.0.0:
     lodash.memoize "^4.1.2"
     lodash.uniq "^4.5.0"
 
-caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001219:
-  version "1.0.30001228"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001228.tgz#bfdc5942cd3326fa51ee0b42fbef4da9d492a7fa"
-  integrity sha512-QQmLOGJ3DEgokHbMSA8cj2a+geXqmnpyOFT0lhQV6P3/YOJvGDEwoedcwxEQ30gJIwIIunHIicunJ2rzK5gB2A==
-
-caniuse-lite@^1.0.30001271:
-  version "1.0.30001274"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001274.tgz#26ca36204d15b17601ba6fc35dbdad950a647cc7"
-  integrity sha512-+Nkvv0fHyhISkiMIjnyjmf5YJcQ1IQHZN6U9TLUMroWR38FNwpsC51Gb68yueafX1V6ifOisInSgP9WJFS13ew==
-
-caniuse-lite@^1.0.30001286:
-  version "1.0.30001300"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001300.tgz#11ab6c57d3eb6f964cba950401fd00a146786468"
-  integrity sha512-cVjiJHWGcNlJi8TZVKNMnvMid3Z3TTdDHmLDzlOdIiZq138Exvo0G+G0wTdVYolxKb4AYwC+38pxodiInVtJSA==
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001271, caniuse-lite@^1.0.30001286:
+  version "1.0.30001310"
+  resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001310.tgz"
+  integrity sha512-cb9xTV8k9HTIUA3GnPUJCk0meUnrHL5gy5QePfDjxHyNBcnzPzrHFv5GqfP7ue5b1ZyzZL0RJboD6hQlPXjhjg==
 
 chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
   version "1.1.3"