about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/invites_controller.rb6
-rw-r--r--app/controllers/authorize_follows_controller.rb71
-rw-r--r--app/controllers/authorize_interactions_controller.rb71
-rw-r--r--app/controllers/intents_controller.rb2
-rw-r--r--app/controllers/remote_follow_controller.rb1
-rw-r--r--app/controllers/remote_interaction_controller.rb53
-rw-r--r--app/helpers/home_helper.rb10
-rw-r--r--app/helpers/settings_helper.rb1
-rw-r--r--app/helpers/stream_entries_helper.rb2
-rw-r--r--app/javascript/core/public.js14
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js4
-rw-r--r--app/javascript/flavours/glitch/features/composer/index.js110
-rw-r--r--app/javascript/flavours/glitch/features/composer/spoiler/index.js7
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/index.js9
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/item/index.js57
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js122
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/modal_root.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js2
-rw-r--r--app/javascript/flavours/glitch/selectors/index.js3
-rw-r--r--app/javascript/flavours/glitch/styles/components/composer.scss31
-rw-r--r--app/javascript/flavours/glitch/styles/components/modal.scss36
-rw-r--r--app/javascript/mastodon/actions/statuses.js6
-rw-r--r--app/javascript/mastodon/components/media_gallery.js2
-rw-r--r--app/javascript/mastodon/components/status.js2
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js4
-rw-r--r--app/javascript/mastodon/components/status_content.js4
-rw-r--r--app/javascript/mastodon/containers/status_container.js6
-rw-r--r--app/javascript/mastodon/features/compose/components/reply_indicator.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/upload.js13
-rw-r--r--app/javascript/mastodon/features/compose/containers/upload_container.js5
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js1
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js4
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js2
-rw-r--r--app/javascript/mastodon/features/status/index.js6
-rw-r--r--app/javascript/mastodon/features/ui/components/boost_modal.js2
-rw-r--r--app/javascript/mastodon/locales/ka.json311
-rw-r--r--app/javascript/mastodon/locales/whitelist_ka.json2
-rw-r--r--app/javascript/packs/public.js7
-rw-r--r--app/javascript/styles/mastodon/components.scss12
-rw-r--r--app/javascript/styles/mastodon/stream_entries.scss29
-rw-r--r--app/models/favourite.rb4
-rw-r--r--app/models/remote_follow.rb4
-rw-r--r--app/models/status.rb10
-rw-r--r--app/models/user.rb9
-rw-r--r--app/policies/invite_policy.rb4
-rw-r--r--app/serializers/webfinger_serializer.rb2
-rw-r--r--app/services/process_mentions_service.rb2
-rw-r--r--app/views/admin/invites/index.html.haml28
-rw-r--r--app/views/authorize_follows/show.html.haml17
-rw-r--r--app/views/authorize_interactions/_post_follow_actions.html.haml (renamed from app/views/authorize_follows/_post_follow_actions.html.haml)4
-rw-r--r--app/views/authorize_interactions/error.html.haml (renamed from app/views/authorize_follows/error.html.haml)0
-rw-r--r--app/views/authorize_interactions/show.html.haml18
-rw-r--r--app/views/authorize_interactions/success.html.haml (renamed from app/views/authorize_follows/success.html.haml)6
-rw-r--r--app/views/layouts/modal.html.haml2
-rw-r--r--app/views/layouts/public.html.haml2
-rw-r--r--app/views/remote_interaction/new.html.haml17
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml15
-rw-r--r--app/views/stream_entries/_simple_status.html.haml10
-rw-r--r--app/views/stream_entries/_status.html.haml6
-rw-r--r--app/views/stream_entries/show.html.haml2
-rw-r--r--app/views/well_known/webfinger/show.xml.ruby2
61 files changed, 996 insertions, 202 deletions
diff --git a/app/controllers/admin/invites_controller.rb b/app/controllers/admin/invites_controller.rb
index faccaa7c8..44a8eec77 100644
--- a/app/controllers/admin/invites_controller.rb
+++ b/app/controllers/admin/invites_controller.rb
@@ -30,6 +30,12 @@ module Admin
       redirect_to admin_invites_path
     end
 
+    def deactivate_all
+      authorize :invite, :deactivate_all?
+      Invite.available.in_batches.update_all(expires_at: Time.now.utc)
+      redirect_to admin_invites_path
+    end
+
     private
 
     def resource_params
diff --git a/app/controllers/authorize_follows_controller.rb b/app/controllers/authorize_follows_controller.rb
deleted file mode 100644
index 95052df7c..000000000
--- a/app/controllers/authorize_follows_controller.rb
+++ /dev/null
@@ -1,71 +0,0 @@
-# frozen_string_literal: true
-
-class AuthorizeFollowsController < ApplicationController
-  layout 'modal'
-
-  before_action :authenticate_user!
-  before_action :set_pack
-  before_action :set_body_classes
-
-  def show
-    @account = located_account || render(:error)
-  end
-
-  def create
-    @account = follow_attempt.try(:target_account)
-
-    if @account.nil?
-      render :error
-    else
-      render :success
-    end
-  rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
-    render :error
-  end
-
-  private
-
-  def set_pack
-    use_pack 'modal'
-  end
-
-  def follow_attempt
-    FollowService.new.call(current_account, acct_without_prefix)
-  end
-
-  def located_account
-    if acct_param_is_url?
-      account_from_remote_fetch
-    else
-      account_from_remote_follow
-    end
-  end
-
-  def account_from_remote_fetch
-    FetchRemoteAccountService.new.call(acct_without_prefix)
-  end
-
-  def account_from_remote_follow
-    ResolveAccountService.new.call(acct_without_prefix)
-  end
-
-  def acct_param_is_url?
-    parsed_uri.path && %w(http https).include?(parsed_uri.scheme)
-  end
-
-  def parsed_uri
-    Addressable::URI.parse(acct_without_prefix).normalize
-  end
-
-  def acct_without_prefix
-    acct_params.gsub(/\Aacct:/, '')
-  end
-
-  def acct_params
-    params.fetch(:acct, '')
-  end
-
-  def set_body_classes
-    @body_classes = 'modal-layout'
-  end
-end
diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb
new file mode 100644
index 000000000..20b3fa94b
--- /dev/null
+++ b/app/controllers/authorize_interactions_controller.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+class AuthorizeInteractionsController < ApplicationController
+  include Authorization
+
+  layout 'modal'
+
+  before_action :authenticate_user!
+  before_action :set_body_classes
+  before_action :set_resource
+  before_action :set_pack
+
+  def show
+    if @resource.is_a?(Account)
+      render :show
+    elsif @resource.is_a?(Status)
+      redirect_to web_url("statuses/#{@resource.id}")
+    else
+      render :error
+    end
+  end
+
+  def create
+    if @resource.is_a?(Account) && FollowService.new.call(current_account, @resource)
+      render :success
+    else
+      render :error
+    end
+  rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
+    render :error
+  end
+
+  private
+
+  def set_resource
+    @resource = located_resource || render(:error)
+    authorize(@resource, :show?) if @resource.is_a?(Status)
+  end
+
+  def located_resource
+    if uri_param_is_url?
+      ResolveURLService.new.call(uri_param)
+    else
+      account_from_remote_follow
+    end
+  end
+
+  def account_from_remote_follow
+    ResolveAccountService.new.call(uri_param)
+  end
+
+  def uri_param_is_url?
+    parsed_uri.path && %w(http https).include?(parsed_uri.scheme)
+  end
+
+  def parsed_uri
+    Addressable::URI.parse(uri_param).normalize
+  end
+
+  def uri_param
+    params[:uri] || params.fetch(:acct, '').gsub(/\Aacct:/, '')
+  end
+
+  def set_body_classes
+    @body_classes = 'modal-layout'
+  end
+
+  def set_pack
+    use_pack 'modal'
+  end
+end
diff --git a/app/controllers/intents_controller.rb b/app/controllers/intents_controller.rb
index 56129d69a..9f41cf48a 100644
--- a/app/controllers/intents_controller.rb
+++ b/app/controllers/intents_controller.rb
@@ -8,7 +8,7 @@ class IntentsController < ApplicationController
     if uri.scheme == 'web+mastodon'
       case uri.host
       when 'follow'
-        return redirect_to authorize_follow_path(acct: uri.query_values['uri'].gsub(/\Aacct:/, ''))
+        return redirect_to authorize_interaction_path(uri: uri.query_values['uri'].gsub(/\Aacct:/, ''))
       when 'share'
         return redirect_to share_path(text: uri.query_values['text'])
       end
diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb
index 128e80a67..17bc1940a 100644
--- a/app/controllers/remote_follow_controller.rb
+++ b/app/controllers/remote_follow_controller.rb
@@ -47,5 +47,6 @@ class RemoteFollowController < ApplicationController
 
   def set_body_classes
     @body_classes = 'modal-layout'
+    @hide_header  = true
   end
 end
diff --git a/app/controllers/remote_interaction_controller.rb b/app/controllers/remote_interaction_controller.rb
new file mode 100644
index 000000000..6861f3f21
--- /dev/null
+++ b/app/controllers/remote_interaction_controller.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+class RemoteInteractionController < ApplicationController
+  include Authorization
+
+  layout 'modal'
+
+  before_action :set_status
+  before_action :set_body_classes
+  before_action :set_pack
+
+  def new
+    @remote_follow = RemoteFollow.new(session_params)
+  end
+
+  def create
+    @remote_follow = RemoteFollow.new(resource_params)
+
+    if @remote_follow.valid?
+      session[:remote_follow] = @remote_follow.acct
+      redirect_to @remote_follow.interact_address_for(@status)
+    else
+      render :new
+    end
+  end
+
+  private
+
+  def resource_params
+    params.require(:remote_follow).permit(:acct)
+  end
+
+  def session_params
+    { acct: session[:remote_follow] }
+  end
+
+  def set_status
+    @status = Status.find(params[:id])
+    authorize @status, :show?
+  rescue Mastodon::NotPermittedError
+    # Reraise in order to get a 404
+    raise ActiveRecord::RecordNotFound
+  end
+
+  def set_body_classes
+    @body_classes = 'modal-layout'
+    @hide_header  = true
+  end
+
+  def set_pack
+    use_pack 'modal'
+  end
+end
diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb
index 8449f6c8a..f5b501235 100644
--- a/app/helpers/home_helper.rb
+++ b/app/helpers/home_helper.rb
@@ -38,4 +38,14 @@ module HomeHelper
       end
     end
   end
+
+  def obscured_counter(count)
+    if count <= 0
+      0
+    elsif count == 1
+      1
+    else
+      '1+'
+    end
+  end
 end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index 8528be267..14ca2333e 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -26,6 +26,7 @@ module SettingsHelper
     io: 'Ido',
     it: 'Italiano',
     ja: '日本語',
+    ka: 'ქართული',
     ko: '한국어',
     nl: 'Nederlands',
     no: 'Norsk',
diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb
index 121644263..9ded69436 100644
--- a/app/helpers/stream_entries_helper.rb
+++ b/app/helpers/stream_entries_helper.rb
@@ -19,7 +19,7 @@ module StreamEntriesHelper
           safe_join([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('settings.edit_profile')])
         end
       elsif current_account.following?(account) || current_account.requested?(account)
-        link_to account_unfollow_path(account), class: 'button logo-button', data: { method: :post } do
+        link_to account_unfollow_path(account), class: 'button logo-button button--destructive', data: { method: :post } do
           safe_join([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('accounts.unfollow')])
         end
       else
diff --git a/app/javascript/core/public.js b/app/javascript/core/public.js
index 4ac9eab3e..b5014a8c7 100644
--- a/app/javascript/core/public.js
+++ b/app/javascript/core/public.js
@@ -37,3 +37,17 @@ delegate(document, '.status__content__spoiler-link', 'click', ({ target }) => {
 
   return false;
 });
+
+delegate(document, '.modal-button', 'click', e => {
+  e.preventDefault();
+
+  let href;
+
+  if (e.target.nodeName !== 'A') {
+    href = e.target.parentNode.href;
+  } else {
+    href = e.target.href;
+  }
+
+  window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
+});
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index b7f706a83..f6c8086fe 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -211,11 +211,11 @@ export function uploadCompose(files) {
   };
 };
 
-export function changeUploadCompose(id, description) {
+export function changeUploadCompose(id, params) {
   return (dispatch, getState) => {
     dispatch(changeUploadComposeRequest());
 
-    api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
+    api(getState).put(`/api/v1/media/${id}`, params).then(response => {
       dispatch(changeUploadComposeSuccess(response.data));
     }).catch(error => {
       dispatch(changeUploadComposeFail(id, error));
diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js
index 2678ffd53..f312e9d59 100644
--- a/app/javascript/flavours/glitch/features/composer/index.js
+++ b/app/javascript/flavours/glitch/features/composer/index.js
@@ -95,28 +95,71 @@ function mapStateToProps (state) {
 };
 
 //  Dispatch mapping.
-const mapDispatchToProps = {
-  onCancelReply: cancelReplyCompose,
-  onChangeAdvancedOption: changeComposeAdvancedOption,
-  onChangeDescription: changeUploadCompose,
-  onChangeSensitivity: changeComposeSensitivity,
-  onChangeSpoilerText: changeComposeSpoilerText,
-  onChangeSpoilerness: changeComposeSpoilerness,
-  onChangeText: changeCompose,
-  onChangeVisibility: changeComposeVisibility,
-  onClearSuggestions: clearComposeSuggestions,
-  onCloseModal: closeModal,
-  onFetchSuggestions: fetchComposeSuggestions,
-  onInsertEmoji: insertEmojiCompose,
-  onMount: mountCompose,
-  onOpenActionsModal: openModal.bind(null, 'ACTIONS'),
-  onOpenDoodleModal: openModal.bind(null, 'DOODLE', { noEsc: true }),
-  onSelectSuggestion: selectComposeSuggestion,
-  onSubmit: submitCompose,
-  onUndoUpload: undoUploadCompose,
-  onUnmount: unmountCompose,
-  onUpload: uploadCompose,
-};
+const mapDispatchToProps = (dispatch) => ({
+  onCancelReply() {
+    dispatch(cancelReplyCompose());
+  },
+  onChangeAdvancedOption(option, value) {
+    dispatch(changeComposeAdvancedOption(option, value));
+  },
+  onChangeDescription(id, description) {
+    dispatch(changeUploadCompose(id, { description }));
+  },
+  onChangeSensitivity() {
+    dispatch(changeComposeSensitivity());
+  },
+  onChangeSpoilerText(text) {
+    dispatch(changeComposeSpoilerText(text));
+  },
+  onChangeSpoilerness() {
+    dispatch(changeComposeSpoilerness());
+  },
+  onChangeText(text) {
+    dispatch(changeCompose(text));
+  },
+  onChangeVisibility(value) {
+    dispatch(changeComposeVisibility(value));
+  },
+  onClearSuggestions() {
+    dispatch(clearComposeSuggestions());
+  },
+  onCloseModal() {
+    dispatch(closeModal());
+  },
+  onFetchSuggestions(token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
+  onInsertEmoji(position, emoji) {
+    dispatch(insertEmojiCompose(position, emoji));
+  },
+  onMount() {
+    dispatch(mountCompose());
+  },
+  onOpenActionModal(props) {
+    dispatch(openModal('ACTIONS', props));
+  },
+  onOpenDoodleModal() {
+    dispatch(openModal('DOODLE', { noEsc: true }));
+  },
+  onOpenFocalPointModal(id) {
+    dispatch(openModal('FOCAL_POINT', { id }));
+  },
+  onSelectSuggestion(position, token, suggestion) {
+    dispatch(selectComposeSuggestion(position, token, suggestion));
+  },
+  onSubmit() {
+    dispatch(submitCompose());
+  },
+  onUndoUpload(id) {
+    dispatch(undoUploadCompose(id));
+  },
+  onUnmount() {
+    dispatch(unmountCompose());
+  },
+  onUpload(files) {
+    dispatch(uploadCompose(files));
+  },
+});
 
 //  Handlers.
 const handlers = {
@@ -194,6 +237,13 @@ const handlers = {
       this.textarea = textareaComponent.textarea;
     }
   },
+
+  //  Sets a reference to the CW field.
+  handleRefSpoilerText (spoilerComponent) {
+    if (spoilerComponent) {
+      this.spoilerText = spoilerComponent.spoilerText;
+    }
+  }
 };
 
 //  The component.
@@ -206,6 +256,7 @@ class Composer extends React.Component {
 
     //  Instance variables.
     this.textarea = null;
+    this.spoilerText = null;
   }
 
   //  Tells our state the composer has been mounted.
@@ -234,6 +285,7 @@ class Composer extends React.Component {
   componentDidUpdate (prevProps) {
     const {
       textarea,
+      spoilerText,
     } = this;
     const {
       focusDate,
@@ -265,6 +317,16 @@ class Composer extends React.Component {
     //  Refocuses the textarea after submitting.
     } else if (textarea && prevProps.isSubmitting && !isSubmitting) {
       textarea.focus();
+    } else if (this.props.spoiler !== prevProps.spoiler) {
+      if (this.props.spoiler) {
+        if (spoilerText) {
+          spoilerText.focus();
+        }
+      } else {
+        if (textarea) {
+          textarea.focus();
+        }
+      }
     }
   }
 
@@ -276,6 +338,7 @@ class Composer extends React.Component {
       handleSelect,
       handleSubmit,
       handleRefTextarea,
+      handleRefSpoilerText,
     } = this.handlers;
     const {
       acceptContentTypes,
@@ -299,6 +362,7 @@ class Composer extends React.Component {
       onFetchSuggestions,
       onOpenActionsModal,
       onOpenDoodleModal,
+      onOpenFocalPointModal,
       onUndoUpload,
       onUpload,
       privacy,
@@ -334,6 +398,7 @@ class Composer extends React.Component {
           onChange={handleChangeSpoiler}
           onSubmit={handleSubmit}
           text={spoilerText}
+          ref={handleRefSpoilerText}
         />
         <ComposerTextarea
           advancedOptions={advancedOptions}
@@ -357,6 +422,7 @@ class Composer extends React.Component {
             intl={intl}
             media={media}
             onChangeDescription={onChangeDescription}
+            onOpenFocalPointModal={onOpenFocalPointModal}
             onRemove={onUndoUpload}
             progress={progress}
             uploading={isUploading}
diff --git a/app/javascript/flavours/glitch/features/composer/spoiler/index.js b/app/javascript/flavours/glitch/features/composer/spoiler/index.js
index d0e74b957..a7fecbcf5 100644
--- a/app/javascript/flavours/glitch/features/composer/spoiler/index.js
+++ b/app/javascript/flavours/glitch/features/composer/spoiler/index.js
@@ -33,6 +33,10 @@ const handlers = {
       onSubmit();
     }
   },
+
+  handleRefSpoilerText (spoilerText) {
+    this.spoilerText = spoilerText;
+  },
 };
 
 //  The component.
@@ -46,7 +50,7 @@ export default class ComposerSpoiler extends React.PureComponent {
 
   //  Rendering.
   render () {
-    const { handleKeyDown } = this.handlers;
+    const { handleKeyDown, handleRefSpoilerText } = this.handlers;
     const {
       hidden,
       intl,
@@ -68,6 +72,7 @@ export default class ComposerSpoiler extends React.PureComponent {
             placeholder={intl.formatMessage(messages.placeholder)}
             type='text'
             value={text}
+            ref={handleRefSpoilerText}
           />
         </label>
       </div>
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/index.js
index 53b14acc7..f3cadc2f5 100644
--- a/app/javascript/flavours/glitch/features/composer/upload_form/index.js
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/index.js
@@ -13,6 +13,7 @@ export default function ComposerUploadForm ({
   intl,
   media,
   onChangeDescription,
+  onOpenFocalPointModal,
   onRemove,
   progress,
   uploading,
@@ -31,8 +32,12 @@ export default function ComposerUploadForm ({
               key={item.get('id')}
               id={item.get('id')}
               intl={intl}
+              focusX={item.getIn(['meta', 'focus', 'x'])}
+              focusY={item.getIn(['meta', 'focus', 'y'])}
+              mediaType={item.get('type')}
               preview={item.get('preview_url')}
               onChangeDescription={onChangeDescription}
+              onOpenFocalPointModal={onOpenFocalPointModal}
               onRemove={onRemove}
             />
           ))}
@@ -46,8 +51,8 @@ export default function ComposerUploadForm ({
 ComposerUploadForm.propTypes = {
   intl: PropTypes.object.isRequired,
   media: ImmutablePropTypes.list,
-  onChangeDescription: PropTypes.func,
-  onRemove: PropTypes.func,
+  onChangeDescription: PropTypes.func.isRequired,
+  onRemove: PropTypes.func.isRequired,
   progress: PropTypes.number,
   uploading: PropTypes.bool,
 };
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js
index ec67b8ef8..5addccfb1 100644
--- a/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js
@@ -25,6 +25,10 @@ const messages = defineMessages({
     defaultMessage: 'Describe for the visually impaired',
     id: 'upload_form.description',
   },
+  crop: {
+    defaultMessage: 'Crop',
+    id: 'upload_form.focus',
+  },
 });
 
 //  Handlers.
@@ -37,11 +41,10 @@ const handlers = {
       onChangeDescription,
     } = this.props;
     const { dirtyDescription } = this.state;
+
+    this.setState({ dirtyDescription: null, focused: false });
+
     if (id && onChangeDescription && dirtyDescription !== null) {
-      this.setState({
-        dirtyDescription: null,
-        focused: false,
-      });
       onChangeDescription(id, dirtyDescription);
     }
   },
@@ -77,6 +80,17 @@ const handlers = {
       onRemove(id);
     }
   },
+
+  //  Opens the focal point modal.
+  handleFocalPointClick () {
+    const {
+      id,
+      onOpenFocalPointModal,
+    } = this.props;
+    if (id && onOpenFocalPointModal) {
+      onOpenFocalPointModal(id);
+    }
+  },
 };
 
 //  The component.
@@ -102,18 +116,25 @@ export default class ComposerUploadFormItem extends React.PureComponent {
       handleMouseEnter,
       handleMouseLeave,
       handleRemove,
+      handleFocalPointClick,
     } = this.handlers;
     const {
-      description,
       intl,
       preview,
+      focusX,
+      focusY,
+      mediaType,
     } = this.props;
     const {
       focused,
       hovered,
       dirtyDescription,
     } = this.state;
-    const computedClass = classNames('composer--upload_form--item', { active: hovered || focused });
+    const active = hovered || focused;
+    const computedClass = classNames('composer--upload_form--item', { active });
+    const x = ((focusX /  2) + .5) * 100;
+    const y = ((focusY / -2) + .5) * 100;
+    const description = dirtyDescription || (dirtyDescription !== '' && this.props.description) || '';
 
     //  The result.
     return (
@@ -136,15 +157,15 @@ export default class ComposerUploadFormItem extends React.PureComponent {
               style={{
                 transform: `scale(${scale})`,
                 backgroundImage: preview ? `url(${preview})` : null,
+                backgroundPosition: `${x}% ${y}%`
               }}
             >
-              <IconButton
-                className='close'
-                icon='times'
-                onClick={handleRemove}
-                size={36}
-                title={intl.formatMessage(messages.undo)}
-              />
+              <div className={classNames('composer--upload_form--actions', { active })}>
+                <button className='icon-button' onClick={handleRemove}>
+                  <i className='fa fa-times' /> <FormattedMessage {...messages.undo} />
+                </button>
+                {mediaType === 'image' && <button className='icon-button' onClick={handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage {...messages.crop} /></button>}
+              </div>
               <label>
                 <span style={{ display: 'none' }}><FormattedMessage {...messages.description} /></span>
                 <input
@@ -154,7 +175,7 @@ export default class ComposerUploadFormItem extends React.PureComponent {
                   onFocus={handleFocus}
                   placeholder={intl.formatMessage(messages.description)}
                   type='text'
-                  value={dirtyDescription || description || ''}
+                  value={description}
                 />
               </label>
             </div>
@@ -171,7 +192,11 @@ ComposerUploadFormItem.propTypes = {
   description: PropTypes.string,
   id: PropTypes.string,
   intl: PropTypes.object.isRequired,
-  onChangeDescription: PropTypes.func,
-  onRemove: PropTypes.func,
+  onChangeDescription: PropTypes.func.isRequired,
+  onOpenFocalPointModal: PropTypes.func.isRequired,
+  onRemove: PropTypes.func.isRequired,
+  focusX: PropTypes.number,
+  focusY: PropTypes.number,
+  mediaType: PropTypes.string,
   preview: PropTypes.string,
 };
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
new file mode 100644
index 000000000..57c92cc66
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -0,0 +1,122 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { connect } from 'react-redux';
+import ImageLoader from './image_loader';
+import classNames from 'classnames';
+import { changeUploadCompose } from 'flavours/glitch/actions/compose';
+import { getPointerPosition } from 'flavours/glitch/features/video';
+
+const mapStateToProps = (state, { id }) => ({
+  media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+});
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+
+  onSave: (x, y) => {
+    dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
+  },
+
+});
+
+@connect(mapStateToProps, mapDispatchToProps)
+export default class FocalPointModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+  };
+
+  state = {
+    x: 0,
+    y: 0,
+    focusX: 0,
+    focusY: 0,
+    dragging: false,
+  };
+
+  componentWillMount () {
+    this.updatePositionFromMedia(this.props.media);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.media.get('id') !== nextProps.media.get('id')) {
+      this.updatePositionFromMedia(nextProps.media);
+    }
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('mousemove', this.handleMouseMove);
+    document.removeEventListener('mouseup', this.handleMouseUp);
+  }
+
+  handleMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseMove);
+    document.addEventListener('mouseup', this.handleMouseUp);
+
+    this.updatePosition(e);
+    this.setState({ dragging: true });
+  }
+
+  handleMouseMove = e => {
+    this.updatePosition(e);
+  }
+
+  handleMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseMove);
+    document.removeEventListener('mouseup', this.handleMouseUp);
+
+    this.setState({ dragging: false });
+    this.props.onSave(this.state.focusX, this.state.focusY);
+  }
+
+  updatePosition = e => {
+    const { x, y } = getPointerPosition(this.node, e);
+    const focusX   = (x - .5) *  2;
+    const focusY   = (y - .5) * -2;
+
+    this.setState({ x, y, focusX, focusY });
+  }
+
+  updatePositionFromMedia = media => {
+    const focusX = media.getIn(['meta', 'focus', 'x']);
+    const focusY = media.getIn(['meta', 'focus', 'y']);
+
+    if (focusX && focusY) {
+      const x = (focusX /  2) + .5;
+      const y = (focusY / -2) + .5;
+
+      this.setState({ x, y, focusX, focusY });
+    } else {
+      this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 });
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  render () {
+    const { media } = this.props;
+    const { x, y, dragging } = this.state;
+
+    const width  = media.getIn(['meta', 'original', 'width']) || null;
+    const height = media.getIn(['meta', 'original', 'height']) || null;
+
+    return (
+      <div className='modal-root__modal video-modal focal-point-modal'>
+        <div className={classNames('focal-point', { dragging })} ref={this.setRef}>
+          <ImageLoader
+            previewSrc={media.get('preview_url')}
+            src={media.get('url')}
+            width={width}
+            height={height}
+          />
+
+          <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
+          <div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
index e54ab9a52..23a7603d8 100644
--- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
@@ -11,6 +11,7 @@ import BoostModal from './boost_modal';
 import FavouriteModal from './favourite_modal';
 import DoodleModal from './doodle_modal';
 import ConfirmationModal from './confirmation_modal';
+import FocalPointModal from './focal_point_modal';
 import {
   OnboardingModal,
   MuteModal,
@@ -34,6 +35,7 @@ const MODAL_COMPONENTS = {
   'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
   'EMBED': EmbedModal,
   'LIST_EDITOR': ListEditor,
+  'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
 };
 
 export default class ModalRoot extends React.PureComponent {
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index 53dd65b07..8b997bf4d 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -371,7 +371,7 @@ export default function compose(state = initialState, action) {
       .set('is_submitting', false)
       .update('media_attachments', list => list.map(item => {
         if (item.get('id') === action.media.id) {
-          return item.set('description', action.media.description);
+          return fromJS(action.media);
         }
 
         return item;
diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js
index 42881d9ed..d1a88a2fc 100644
--- a/app/javascript/flavours/glitch/selectors/index.js
+++ b/app/javascript/flavours/glitch/selectors/index.js
@@ -1,5 +1,6 @@
 import { createSelector } from 'reselect';
 import { List as ImmutableList } from 'immutable';
+import { me } from 'flavours/glitch/util/initial_state';
 
 const getAccountBase         = (state, id) => state.getIn(['accounts', id], null);
 const getAccountCounters     = (state, id) => state.getIn(['accounts_counters', id], null);
@@ -77,7 +78,7 @@ export const makeGetStatus = () => {
         return null;
       }
 
-      const regex  = regexFromFilters(filters);
+      const regex  = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters);
       let filtered = false;
 
       if (statusReblog) {
diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss
index 77ba34672..fab94d8c3 100644
--- a/app/javascript/flavours/glitch/styles/components/composer.scss
+++ b/app/javascript/flavours/glitch/styles/components/composer.scss
@@ -255,11 +255,12 @@
   & > div {
     position: relative;
     border-radius: 4px;
-    height: 100px;
+    height: 140px;
     width: 100%;
     background-position: center;
     background-size: cover;
     background-repeat: no-repeat;
+    overflow: hidden;
 
     input {
       display: block;
@@ -298,6 +299,34 @@
   }
 }
 
+.composer--upload_form--actions {
+  background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  opacity: 0;
+  transition: opacity .1s ease;
+
+  .icon-button {
+    flex: 0 1 auto;
+    color: $ui-secondary-color;
+    font-size: 14px;
+    font-weight: 500;
+    padding: 10px;
+    font-family: inherit;
+
+    &:hover,
+    &:focus,
+    &:active {
+      color: lighten($ui-secondary-color, 4%);
+    }
+  }
+
+  &.active {
+    opacity: 1;
+  }
+}
+
 .composer--upload_form--progress {
   display: flex;
   padding: 10px;
diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss
index 49ed47440..1bfedc383 100644
--- a/app/javascript/flavours/glitch/styles/components/modal.scss
+++ b/app/javascript/flavours/glitch/styles/components/modal.scss
@@ -763,3 +763,39 @@
     }
   }
 }
+
+.focal-point {
+  position: relative;
+  cursor: pointer;
+  overflow: hidden;
+
+  &.dragging {
+    cursor: move;
+  }
+
+  img {
+    max-width: 80vw;
+    max-height: 80vh;
+    width: auto;
+    height: auto;
+    margin: auto;
+  }
+
+  &__reticle {
+    position: absolute;
+    width: 100px;
+    height: 100px;
+    transform: translate(-50%, -50%);
+    background: url('~/images/reticle.png') no-repeat 0 0;
+    border-radius: 50%;
+    box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
+  }
+
+  &__overlay {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+  }
+}
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 3e1e5f270..8d5e72bec 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -140,7 +140,7 @@ export function redraft(status) {
   };
 };
 
-export function deleteStatus(id, withRedraft = false) {
+export function deleteStatus(id, router, withRedraft = false) {
   return (dispatch, getState) => {
     const status = getState().getIn(['statuses', id]);
 
@@ -153,6 +153,10 @@ export function deleteStatus(id, withRedraft = false) {
 
       if (withRedraft) {
         dispatch(redraft(status));
+
+        if (!getState().getIn(['compose', 'mounted'])) {
+          router.push('/statuses/new');
+        }
       }
     }).catch(error => {
       dispatch(deleteStatusFail(id, error));
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index 1d351279f..63bc4a59b 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -50,7 +50,7 @@ class Item extends React.PureComponent {
   handleClick = (e) => {
     const { index, onClick } = this.props;
 
-    if (e.button === 0) {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
       onClick(index);
     }
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 922b609ec..e653906f1 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -65,7 +65,7 @@ export default class Status extends ImmutablePureComponent {
   }
 
   handleAccountClick = (e) => {
-    if (this.context.router && e.button === 0) {
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       const id = e.currentTarget.getAttribute('data-id');
       e.preventDefault();
       this.context.router.history.push(`/accounts/${id}`);
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index c799d4e98..6d44a4b45 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -96,11 +96,11 @@ export default class StatusActionBar extends ImmutablePureComponent {
   }
 
   handleDeleteClick = () => {
-    this.props.onDelete(this.props.status);
+    this.props.onDelete(this.props.status, this.context.router.history);
   }
 
   handleRedraftClick = () => {
-    this.props.onDelete(this.props.status, true);
+    this.props.onDelete(this.props.status, this.context.router.history, true);
   }
 
   handlePinClick = () => {
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index 9b86592f6..81013747e 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -64,7 +64,7 @@ export default class StatusContent extends React.PureComponent {
   }
 
   onMentionClick = (mention, e) => {
-    if (this.context.router && e.button === 0) {
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
       this.context.router.history.push(`/accounts/${mention.get('id')}`);
     }
@@ -73,7 +73,7 @@ export default class StatusContent extends React.PureComponent {
   onHashtagClick = (hashtag, e) => {
     hashtag = hashtag.replace(/^#/, '').toLowerCase();
 
-    if (this.context.router && e.button === 0) {
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
       this.context.router.history.push(`/timelines/tag/${hashtag}`);
     }
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index eb6329fdc..ed375c3e5 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -93,14 +93,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }));
   },
 
-  onDelete (status, withRedraft = false) {
+  onDelete (status, history, withRedraft = false) {
     if (!deleteModal) {
-      dispatch(deleteStatus(status.get('id'), withRedraft));
+      dispatch(deleteStatus(status.get('id'), history, withRedraft));
     } else {
       dispatch(openModal('CONFIRM', {
         message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
         confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
-        onConfirm: () => dispatch(deleteStatus(status.get('id'), withRedraft)),
+        onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
       }));
     }
   },
diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js
index 5b4b81eac..6f358a98b 100644
--- a/app/javascript/mastodon/features/compose/components/reply_indicator.js
+++ b/app/javascript/mastodon/features/compose/components/reply_indicator.js
@@ -30,7 +30,7 @@ export default class ReplyIndicator extends ImmutablePureComponent {
   }
 
   handleAccountClick = (e) => {
-    if (e.button === 0) {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
       this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
     }
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js
index bfa2b4727..3d09217dc 100644
--- a/app/javascript/mastodon/features/compose/components/upload.js
+++ b/app/javascript/mastodon/features/compose/components/upload.js
@@ -20,6 +20,7 @@ export default class Upload extends ImmutablePureComponent {
     onUndo: PropTypes.func.isRequired,
     onDescriptionChange: PropTypes.func.isRequired,
     onOpenFocalPoint: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired,
   };
 
   state = {
@@ -28,6 +29,17 @@ export default class Upload extends ImmutablePureComponent {
     dirtyDescription: null,
   };
 
+  handleKeyDown = (e) => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      this.handleSubmit();
+    }
+  }
+
+  handleSubmit = () => {
+    this.handleInputBlur();
+    this.props.onSubmit();
+  }
+
   handleUndoClick = () => {
     this.props.onUndo(this.props.media.get('id'));
   }
@@ -93,6 +105,7 @@ export default class Upload extends ImmutablePureComponent {
                     onFocus={this.handleInputFocus}
                     onChange={this.handleInputChange}
                     onBlur={this.handleInputBlur}
+                    onKeyDown={this.handleKeyDown}
                   />
                 </label>
               </div>
diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js
index d6b57e5ff..9f3aab4bc 100644
--- a/app/javascript/mastodon/features/compose/containers/upload_container.js
+++ b/app/javascript/mastodon/features/compose/containers/upload_container.js
@@ -2,6 +2,7 @@ import { connect } from 'react-redux';
 import Upload from '../components/upload';
 import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
 import { openModal } from '../../../actions/modal';
+import { submitCompose } from '../../../actions/compose';
 
 const mapStateToProps = (state, { id }) => ({
   media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
@@ -21,6 +22,10 @@ const mapDispatchToProps = dispatch => ({
     dispatch(openModal('FOCAL_POINT', { id }));
   },
 
+  onSubmit () {
+    dispatch(submitCompose());
+  },
+
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(Upload);
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index 074ab01c8..95af8997e 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -139,6 +139,7 @@ export default class GettingStarted extends ImmutablePureComponent {
             {multiColumn && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
             <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>
             <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this instance' /></a> · </li>
+            <li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
             <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
             <li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
             <li><a href='https://github.com/tootsuite/documentation#documentation' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index 541499668..f5977c02c 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -65,11 +65,11 @@ export default class ActionBar extends React.PureComponent {
   }
 
   handleDeleteClick = () => {
-    this.props.onDelete(this.props.status);
+    this.props.onDelete(this.props.status, this.context.router.history);
   }
 
   handleRedraftClick = () => {
-    this.props.onDelete(this.props.status, true);
+    this.props.onDelete(this.props.status, this.context.router.history, true);
   }
 
   handleDirectClick = () => {
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index 417719004..12ffb7579 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -26,7 +26,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
   };
 
   handleAccountClick = (e) => {
-    if (e.button === 0) {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
       this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
     }
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index 0ffeaa4dc..e506733b4 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -174,16 +174,16 @@ export default class Status extends ImmutablePureComponent {
     }
   }
 
-  handleDeleteClick = (status, withRedraft = false) => {
+  handleDeleteClick = (status, history, withRedraft = false) => {
     const { dispatch, intl } = this.props;
 
     if (!deleteModal) {
-      dispatch(deleteStatus(status.get('id'), withRedraft));
+      dispatch(deleteStatus(status.get('id'), history, withRedraft));
     } else {
       dispatch(openModal('CONFIRM', {
         message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
         confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
-        onConfirm: () => dispatch(deleteStatus(status.get('id'), withRedraft)),
+        onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
       }));
     }
   }
diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js
index 0e9592c97..1c90d10dd 100644
--- a/app/javascript/mastodon/features/ui/components/boost_modal.js
+++ b/app/javascript/mastodon/features/ui/components/boost_modal.js
@@ -37,7 +37,7 @@ export default class BoostModal extends ImmutablePureComponent {
   }
 
   handleAccountClick = (e) => {
-    if (e.button === 0) {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
       this.props.onClose();
       this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
diff --git a/app/javascript/mastodon/locales/ka.json b/app/javascript/mastodon/locales/ka.json
new file mode 100644
index 000000000..494270b9d
--- /dev/null
+++ b/app/javascript/mastodon/locales/ka.json
@@ -0,0 +1,311 @@
+{
+  "account.badges.bot": "ბოტი",
+  "account.block": "დაბლოკე @{name}",
+  "account.block_domain": "დაიმალოს ყველაფერი დომენიდან {domain}",
+  "account.blocked": "დაიბლოკა",
+  "account.direct": "პირდაპირი წერილი @{name}-ს",
+  "account.disclaimer_full": "ქვემოთ მოცემულმა ინფორმაციამ შეიძლება სრულად არ ასახოს მომხმარებლის პროფილი.",
+  "account.domain_blocked": "დომენი დამალულია",
+  "account.edit_profile": "პროფილის ცვლილება",
+  "account.endorse": "გამორჩევა პროფილზე",
+  "account.follow": "გაყოლა",
+  "account.followers": "მიმდევრები",
+  "account.follows": "მიდევნებები",
+  "account.follows_you": "მოგყვებათ",
+  "account.hide_reblogs": "დაიმალოს ბუსტები @{name}-სგან",
+  "account.media": "მედია",
+  "account.mention": "ასახელეთ @{name}",
+  "account.moved_to": "{name} გადავიდა:",
+  "account.mute": "გააჩუმე @{name}",
+  "account.mute_notifications": "გააჩუმე შეტყობინებები @{name}-სგან",
+  "account.muted": "გაჩუმებული",
+  "account.posts": "ტუტები",
+  "account.posts_with_replies": "ტუტები და პასუხები",
+  "account.report": "დაარეპორტე @{name}",
+  "account.requested": "დამტკიცების მოლოდინში. დააწკაპუნეთ რომ უარყოთ დადევნების მოთხონვა",
+  "account.share": "გააზიარე @{name}-ის პროფილი",
+  "account.show_reblogs": "აჩვენე ბუსტები @{name}-სგან",
+  "account.unblock": "განბლოკე @{name}",
+  "account.unblock_domain": "გამოაჩინე {domain}",
+  "account.unendorse": "არ გამოირჩეს პროფილზე",
+  "account.unfollow": "ნუღარ მიჰყვები",
+  "account.unmute": "ნუღარ აჩუმებ @{name}-ს",
+  "account.unmute_notifications": "ნუღარ აჩუმებ შეტყობინებებს @{name}-სგან",
+  "account.view_full_profile": "სრული პროფილის ჩვენება",
+  "alert.unexpected.message": "წარმოიშვა მოულოდნელი შეცდომა.",
+  "alert.unexpected.title": "უპს!",
+  "boost_modal.combo": "შეგიძლიათ დააჭიროთ {combo}-ს რათა შემდეგ ჯერზე გამოტოვოთ ეს",
+  "bundle_column_error.body": "ამ კომპონენტის ჩატვირთვისას რაღაც აირია.",
+  "bundle_column_error.retry": "სცადეთ კიდევ ერთხელ",
+  "bundle_column_error.title": "ქსელის შეცდომა",
+  "bundle_modal_error.close": "დახურვა",
+  "bundle_modal_error.message": "ამ კომპონენტის ჩატვირთვისას რაღაც აირია.",
+  "bundle_modal_error.retry": "სცადეთ კიდევ ერთხელ",
+  "column.blocks": "დაბლოკილი მომხმარებლები",
+  "column.community": "ლოკალური თაიმლაინი",
+  "column.direct": "პირდაპირი წერილები",
+  "column.domain_blocks": "დამალული დომენები",
+  "column.favourites": "ფავორიტები",
+  "column.follow_requests": "დადევნების მოთხოვნები",
+  "column.home": "სახლი",
+  "column.lists": "სიები",
+  "column.mutes": "გაჩუმებული მომხმარებლები",
+  "column.notifications": "შეტყობინებები",
+  "column.pins": "აპინული ტუტები",
+  "column.public": "ფედერალური თაიმლაინი",
+  "column_back_button.label": "უკან",
+  "column_header.hide_settings": "პარამეტრების დამალვა",
+  "column_header.moveLeft_settings": "სვეტის მარცხნივ გადატანა",
+  "column_header.moveRight_settings": "სვეტის მარჯვნივ გადატანა",
+  "column_header.pin": "აპინვა",
+  "column_header.show_settings": "პარამეტრების ჩვენება",
+  "column_header.unpin": "პინის მოხსნა",
+  "column_subheading.settings": "პარამეტრები",
+  "community.column_settings.media_only": "მხოლოდ მედია",
+  "compose_form.direct_message_warning": "ეს ტუტი გაეგზავნება მხოლოდ ნახსენებ მომხმარებლებს.",
+  "compose_form.direct_message_warning_learn_more": "გაიგე მეტი",
+  "compose_form.hashtag_warning": "ეს ტუტი არ მოექცევა ჰეშტეგების ქვეს, რამეთუ ის არაა მითითებული. მხოლოდ ღია ტუტები მოიძებნება ჰეშტეგით.",
+  "compose_form.lock_disclaimer": "თქვენი ანგარიში არაა {locked}. ნებისმიერს შეიძლია გამოგყვეთ, რომ იხილოს თქვენი მიმდევრებზე გათვლილი პოსტები.",
+  "compose_form.lock_disclaimer.lock": "ჩაკეტილი",
+  "compose_form.placeholder": "რაზე ფიქრობ?",
+  "compose_form.publish": "ტუტი",
+  "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive.marked": "მედია მონიშნულია მგრძნობიარედ",
+  "compose_form.sensitive.unmarked": "მედია არაა მონიშნული მგრძნობიარედ",
+  "compose_form.spoiler.marked": "გაფრთხილების უკან ტექსტი დამალულია",
+  "compose_form.spoiler.unmarked": "ტექსტი არაა დამალული",
+  "compose_form.spoiler_placeholder": "თქვენი გაფრთხილება დაწერეთ აქ",
+  "confirmation_modal.cancel": "უარყოფა",
+  "confirmations.block.confirm": "ბლოკი",
+  "confirmations.block.message": "დარწმუნებული ხართ, გსურთ დაბლოკოთ {name}?",
+  "confirmations.delete.confirm": "გაუქმება",
+  "confirmations.delete.message": "დარწმუნებული ხართ, გსურთ გააუქმოთ ეს სტატუსი?",
+  "confirmations.delete_list.confirm": "გაუქმება",
+  "confirmations.delete_list.message": "დარწმუნებული ხართ, გსურთ სამუდამოდ გააუქმოთ ეს სია?",
+  "confirmations.domain_block.confirm": "მთელი დომენის დამალვა",
+  "confirmations.domain_block.message": "ნაღდად, ნაღდად, დარწმუნებული ხართ, გსურთ დაბლოკოთ მთელი {domain}? უმეტეს შემთხვევაში რამდენიმე გამიზნული ბლოკი ან გაჩუმება საკმარისი და უკეთესია. კონტენტს ამ დომენიდან ვერ იხილავთ ვერც ერთ ღია თაიმლაინზე ან თქვენს შეტყობინებებში. ამ დომენიდან არსებული მიმდევრები ამოიშლება.",
+  "confirmations.mute.confirm": "გაჩუმება",
+  "confirmations.mute.message": "დარწმუნებული ხართ, გსურთ გააჩუმოთ {name}?",
+  "confirmations.redraft.confirm": "გაუქმება და გადანაწილება",
+  "confirmations.redraft.message": "დარწმუნებული ხართ, გსურთ გააუქმოთ ეს სტატუსი და გადაანაწილოთ? დაკარგავთ ყველა პასუხს, ბუსტს და მასზედ არსებულ ფავორიტს.",
+  "confirmations.unfollow.confirm": "ნუღარ მიჰყვები",
+  "confirmations.unfollow.message": "დარწმუნებული ხართ, აღარ გსურთ მიჰყვებოდეთ {name}-ს?",
+  "embed.instructions": "ეს სტატუსი ჩასვით თქვენს ვებ-საიტზე შემდეგი კოდის კოპირებით.",
+  "embed.preview": "ესაა თუ როგორც გამოჩნდება:",
+  "emoji_button.activity": "აქტივობა",
+  "emoji_button.custom": "პერსონალიზირებული",
+  "emoji_button.flags": "დროშები",
+  "emoji_button.food": "საჭმელი და სასლმელი",
+  "emoji_button.label": "ემოჯის ჩასმა",
+  "emoji_button.nature": "ბუმება",
+  "emoji_button.not_found": "არაა ემოჯი!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "ობიექტები",
+  "emoji_button.people": "ხალხი",
+  "emoji_button.recent": "ხშირად გამოყენებული",
+  "emoji_button.search": "ძებნა...",
+  "emoji_button.search_results": "ძებნის შედეგები",
+  "emoji_button.symbols": "სიმბოლოები",
+  "emoji_button.travel": "მოგზაურობა და ადგილები",
+  "empty_column.community": "ლოკალური თაიმლაინი ცარიელია. დაწერეთ რაიმე ღიად ან ქენით რაიმე სხვა!",
+  "empty_column.direct": "ჯერ პირდაპირი წერილები არ გაქვთ. როდესაც მიიღებთ ან გააგზავნით, გამოჩნდება აქ.",
+  "empty_column.hashtag": "ამ ჰეშტეგში ჯერ არაფერია.",
+  "empty_column.home": "თქვენი სახლის თაიმლაინი ცარიელია! ესტუმრეთ {public}-ს ან დასაწყისისთვის გამოიყენეთ ძებნა, რომ შეხვდეთ სხვა მომხმარებლებს.",
+  "empty_column.home.public_timeline": "ღია თაიმლაინი",
+  "empty_column.list": "ამ სიაში ჯერ არაფერია. როდესაც სიის წევრები დაპოსტავენ ახალ სტატუსებს, ისინი გამოჩნდებიან აქ.",
+  "empty_column.notifications": "ჯერ შეტყობინებები არ გაქვთ. საუბრის დასაწყებად იურთიერთქმედეთ სხვებთან.",
+  "empty_column.public": "აქ არაფერია! შესავსებად, დაწერეთ რაიმე ღიად ან ხელით გაჰყევით მომხმარებლებს სხვა ინსტანციებისგან",
+  "follow_request.authorize": "ავტორიზაცია",
+  "follow_request.reject": "უარყოფა",
+  "getting_started.developers": "დეველოპერები",
+  "getting_started.documentation": "დოკუმენტაცია",
+  "getting_started.find_friends": "იპოვეთ მეგობრები ტვიტერიდან",
+  "getting_started.heading": "დაწყება",
+  "getting_started.invite": "ხალხის მოწვევა",
+  "getting_started.open_source_notice": "მასტოდონი ღია პროგრამაა. შეგიძლიათ შეუწყოთ ხელი ან შექმნათ პრობემის რეპორტი {github}-ზე.",
+  "getting_started.security": "უსაფრთხოება",
+  "getting_started.terms": "მომსახურების პირობები",
+  "home.column_settings.basic": "ძირითადი",
+  "home.column_settings.show_reblogs": "ბუსტების ჩვენება",
+  "home.column_settings.show_replies": "პასუხების ჩვენება",
+  "keyboard_shortcuts.back": "უკან გადასასვლელად",
+  "keyboard_shortcuts.boost": "დასაბუსტად",
+  "keyboard_shortcuts.column": "ერთ-ერთი სვეტში სტატუსზე ფოკუსირებისთვის",
+  "keyboard_shortcuts.compose": "შედგენის ტექსტ-არეაზე ფოკუსირებისთვის",
+  "keyboard_shortcuts.description": "აღწერილობა",
+  "keyboard_shortcuts.down": "სიაში ქვემოთ გადასაადგილებლად",
+  "keyboard_shortcuts.enter": "სტატუსის გასახსნელად",
+  "keyboard_shortcuts.favourite": "ფავორიტად ქცევისთვის",
+  "keyboard_shortcuts.heading": "კლავიატურის სწრაფი ბმულები",
+  "keyboard_shortcuts.hotkey": "ცხელი კლავიში",
+  "keyboard_shortcuts.legend": "ამ ლეგენდის გამოსაჩენად",
+  "keyboard_shortcuts.mention": "ავტორის დასახელებლად",
+  "keyboard_shortcuts.profile": "ავტორის პროფილის გასახსნელად",
+  "keyboard_shortcuts.reply": "პასუხისთვის",
+  "keyboard_shortcuts.search": "ძიებაზე ფოკუსირებისთვის",
+  "keyboard_shortcuts.toggle_hidden": "გაფრთხილების უკან ტექსტის გამოსაჩენად/დასამალვად",
+  "keyboard_shortcuts.toot": "ახალი ტუტის დასაწყებად",
+  "keyboard_shortcuts.unfocus": "შედგენის ტექსტ-არეაზე ფოკუსის მოსაშორებლად",
+  "keyboard_shortcuts.up": "სიაში ზემოთ გადასაადგილებლად",
+  "lightbox.close": "დახურვა",
+  "lightbox.next": "შემდეგი",
+  "lightbox.previous": "წინა",
+  "lists.account.add": "სიაში დამატება",
+  "lists.account.remove": "სიიდან ამოშლა",
+  "lists.delete": "სიის წაშლა",
+  "lists.edit": "სიის შეცვლა",
+  "lists.new.create": "სიის დამატება",
+  "lists.new.title_placeholder": "ახალი სიის სათაური",
+  "lists.search": "ძებნა ადამიანებს შორის რომელთაც მიჰყვებით",
+  "lists.subheading": "თქვენი სიები",
+  "loading_indicator.label": "იტვირთება...",
+  "media_gallery.toggle_visible": "ხილვადობის ჩართვა",
+  "missing_indicator.label": "არაა ნაპოვნი",
+  "missing_indicator.sublabel": "ამ რესურსის პოვნა ვერ მოხერხდა",
+  "mute_modal.hide_notifications": "დავმალოთ შეტყობინებები ამ მომხმარებლისგან?",
+  "navigation_bar.blocks": "დაბლოკილი მომხმარებლები",
+  "navigation_bar.community_timeline": "ლოკალური თაიმლაინი",
+  "navigation_bar.direct": "პირდაპირი წერილები",
+  "navigation_bar.discover": "აღმოაჩინე",
+  "navigation_bar.domain_blocks": "დამალული დომენები",
+  "navigation_bar.edit_profile": "შეცვალე პროფილი",
+  "navigation_bar.favourites": "ფავორიტები",
+  "navigation_bar.filters": "გაჩუმებული სიტყვები",
+  "navigation_bar.follow_requests": "დადევნების მოთხოვნები",
+  "navigation_bar.info": "ამ ინსტანციის შესახებ",
+  "navigation_bar.keyboard_shortcuts": "ცხელი კლავიშები",
+  "navigation_bar.lists": "სიები",
+  "navigation_bar.logout": "გასვლა",
+  "navigation_bar.mutes": "გაჩუმებული მომხმარებლები",
+  "navigation_bar.personal": "პირადი",
+  "navigation_bar.pins": "აპინული ტუტები",
+  "navigation_bar.preferences": "პრეფერენსიები",
+  "navigation_bar.public_timeline": "ფედერალური თაიმლაინი",
+  "navigation_bar.security": "უსაფრთხოება",
+  "notification.favourite": "{name}-მა თქვენი სტატუსი აქცია ფავორიტად",
+  "notification.follow": "{name} გამოგყვათ",
+  "notification.mention": "{name}-მა გასახელათ",
+  "notification.reblog": "{name}-მა დაბუსტა თქვენი სტატუსი",
+  "notifications.clear": "შეტყობინებების გასუფთავება",
+  "notifications.clear_confirmation": "დარწმუნებული ხართ, გსურთ სამუდამოდ წაშალოთ ყველა თქვენი შეტყობინება?",
+  "notifications.column_settings.alert": "დესკტოპ შეტყობინებები",
+  "notifications.column_settings.favourite": "ფავორიტები:",
+  "notifications.column_settings.follow": "ახალი მიმდევრები:",
+  "notifications.column_settings.mention": "ხსენებები:",
+  "notifications.column_settings.push": "ფუშ შეტყობინებები",
+  "notifications.column_settings.push_meta": "ეს მოწყობილობა",
+  "notifications.column_settings.reblog": "ბუსტები:",
+  "notifications.column_settings.show": "გამოჩნდეს სვეტში",
+  "notifications.column_settings.sound": "ხმის დაკვრა",
+  "notifications.group": "{count} შეტყობინება",
+  "onboarding.done": "დასასრული",
+  "onboarding.next": "შემდეგი",
+  "onboarding.page_five.public_timelines": "ლოკალური თაიმლაინი {domain}-ზე საჯარო პოსტებს აჩვენებს ყველასგან. ფედერალური თაიმლაინი {domain}-ზე აჩვენებს საჯარო პოსტებს ყველასგან ვინც მიჰყვება. ეს საჯარო თაიმლაინებია, ახალი ადამიანების აღმოჩენის კარგი გზაა.",
+  "onboarding.page_four.home": "სახლის თაიმლაინი აჩვენებს პოსტებს ადამიანებისგან, რომლებსაც მიჰყვებით.",
+  "onboarding.page_four.notifications": "შეტყობინებების სვეტი აჩვენებს სხვის ურთიერთქმედებებს თქვენთან.",
+  "onboarding.page_one.federation": "მასტოდონი დამოუკიდებელი სერვერების ქსელია, რომლებიც ერთიანდებიან ერთი დიდი სოციალური ქსელის შექმნისთვის. ამ სერვერებს ჩვენ ვეძახით ინსტანციებს.",
+  "onboarding.page_one.full_handle": "თქვენი სრული სახელური",
+  "onboarding.page_one.handle_hint": "ეს არის ის რასაც ეტყოდით თქვენს მეგობრებს რომ მოძიონ.",
+  "onboarding.page_one.welcome": "კეთილი იყოს თქვენი მასტოდონში მობრძანება!",
+  "onboarding.page_six.admin": "თქვენი ინსტანციის ადმინისტრატორია {admin}.",
+  "onboarding.page_six.almost_done": "თითქმის დასრულდა...",
+  "onboarding.page_six.appetoot": "ბონ აპეტუტ!",
+  "onboarding.page_six.apps_available": "ხელმისაწვდომია {apps} აი-ოსისთვის, ანდროიდისთვის და სხვა პლატფორმებისთვის.",
+  "onboarding.page_six.github": "მასტოდონი უფასო ღია პროგრამაა. შეგიძლიათ დაარეპორტოთ შეცდომები, მოითხოვოთ ფუნქციები, შეუწყოთ ხელი კოდს {github}-ზე.",
+  "onboarding.page_six.guidelines": "საზოგადოების სახელმძღვანელო",
+  "onboarding.page_six.read_guidelines": "გთხოვთ გაეცნოთ {domain}-ს {guidelines}!",
+  "onboarding.page_six.various_app": "მობაილ აპები",
+  "onboarding.page_three.profile": "შეცვალეთ თქვენი პროფილი რომ შეცვალოთ ავატარი, ბიოგრაფია და დისპლეის სახელი. იქ, ასევე იხილავთ სხვა პრეფერენსიების.",
+  "onboarding.page_three.search": "გამოიყენეთ ძიება რომ იპოვნოთ ადამიანები და იხილოთ ჰეშტეგები, ისეთები როგორებიცაა {illustration} და {introductions}. რომ მოძებნოთ ადამიანი ვინც არაა ამ ინსტანციაზე, გამოიყენეთ სრული სახელური.",
+  "onboarding.page_two.compose": "პოსტები შექმენით კომპოზიციის სვეტიდან. შეგიძლიათ ატვირთოთ სურათები, შეცვალოთ კონფიდენციალურობა და ქვემოთ მოცემული პიქტოგრამით დაამატოთ კონტენტის გაფრთხილება.",
+  "onboarding.skip": "გამოტოვება",
+  "privacy.change": "სტატუსის კონფიდენციალურობის მითითება",
+  "privacy.direct.long": "დაიპოსტოს მხოლოდ დასახელებულ მომხმარებლებთან",
+  "privacy.direct.short": "პირდაპირი",
+  "privacy.private.long": "დაიპოსტოს მხოლოდ მიმდევრებთან",
+  "privacy.private.short": "მხოლოდ-მიმდევრებისთვის",
+  "privacy.public.long": "დაიპოსტოს საჯარო თაიმლაინებზე",
+  "privacy.public.short": "საჯარო",
+  "privacy.unlisted.long": "არ დაიპოსტოს საჯარო თაიმლაინებზე",
+  "privacy.unlisted.short": "ჩამოუთვლელი",
+  "regeneration_indicator.label": "იტვირთება…",
+  "regeneration_indicator.sublabel": "თქვენი სახლის ლენტა მზადდება!",
+  "relative_time.days": "{number}დღ",
+  "relative_time.hours": "{number}სთ",
+  "relative_time.just_now": "ახლა",
+  "relative_time.minutes": "{number}წთ",
+  "relative_time.seconds": "{number}წმ",
+  "reply_indicator.cancel": "უარყოფა",
+  "report.forward": "ფორვარდი {target}-ს",
+  "report.forward_hint": "ანგარიში სხვა სერვერიდანაა. გავაგზავნოთ რეპორტის ანონიმური ასლიც?",
+  "report.hint": "რეპორტი გაეგზავნება თქვენი ინსტანციის მოდერატორებს. ქვემოთ შეგიძლიათ დაამატოთ მიზეზი თუ რატომ არეპორტებთ ამ ანგარიშს:",
+  "report.placeholder": "დამატებითი კომენტარები",
+  "report.submit": "დასრულება",
+  "report.target": "არეპორტებთ {target}",
+  "search.placeholder": "ძებნა",
+  "search_popout.search_format": "დეტალური ძებნის ფორმა",
+  "search_popout.tips.full_text": "მარტივი ტექსტი აბრუნებს სტატუსებს რომლებიც შექმენით, აქციეთ ფავორიტად, დაბუსტეთ, ან რაშიც ასახელეთ, ასევე ემთხვევა მომხმარებლის სახელებს, დისპლეი სახელებს, და ჰეშტეგებს.",
+  "search_popout.tips.hashtag": "ჰეშტეგი",
+  "search_popout.tips.status": "სტატუსი",
+  "search_popout.tips.text": "მარტივი ტექსტი აბრუნებს დამთხვეულ დისპლეი სახელებს, მომხმარებლის სახელებს და ჰეშტეგებს",
+  "search_popout.tips.user": "მომხმარებელი",
+  "search_results.accounts": "ხალხი",
+  "search_results.hashtags": "ჰეშტეგები",
+  "search_results.statuses": "ტუტები",
+  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "standalone.public_title": "შიდა ხედი...",
+  "status.block": "დაბლოკე @{name}",
+  "status.cancel_reblog_private": "ბუსტის მოშორება",
+  "status.cannot_reblog": "ეს პოსტი ვერ დაიბუსტება",
+  "status.delete": "წაშლა",
+  "status.direct": "პირდაპირი წერილი @{name}-ს",
+  "status.embed": "ჩართვა",
+  "status.favourite": "ფავორიტი",
+  "status.filtered": "ფილტრირებული",
+  "status.load_more": "მეტის ჩატვირთვა",
+  "status.media_hidden": "მედია დამალულია",
+  "status.mention": "ასახელე @{name}",
+  "status.more": "მეტი",
+  "status.mute": "გააჩუმე @{name}",
+  "status.mute_conversation": "გააჩუმე საუბარი",
+  "status.open": "ამ სტატუსის გაფართოება",
+  "status.pin": "აპინე პროფილზე",
+  "status.pinned": "აპინული ტუტი",
+  "status.reblog": "ბუსტი",
+  "status.reblog_private": "დაიბუსტოს საწყის აუდიტორიაზე",
+  "status.reblogged_by": "{name} დაიბუსტა",
+  "status.redraft": "გაუქმდეს და გადანაწილდეს",
+  "status.reply": "პასუხი",
+  "status.replyAll": "უპასუხე თემას",
+  "status.report": "დაარეპორტე @{name}",
+  "status.sensitive_toggle": "დააწკაპუნეთ სანახავად",
+  "status.sensitive_warning": "მგრძნობიარე კონტენტი",
+  "status.share": "გაზიარება",
+  "status.show_less": "აჩვენე ნაკლები",
+  "status.show_less_all": "აჩვენე ნაკლები ყველაზე",
+  "status.show_more": "აჩვენე მეტი",
+  "status.show_more_all": "აჩვენე მეტი ყველაზე",
+  "status.unmute_conversation": "საუბარზე გაჩუმების მოშორება",
+  "status.unpin": "პროფილიდან პინის მოშორება",
+  "tabs_bar.federated_timeline": "ფედერალური",
+  "tabs_bar.home": "სახლი",
+  "tabs_bar.local_timeline": "ლოკალური",
+  "tabs_bar.notifications": "შეტყობინებები",
+  "tabs_bar.search": "ძებნა",
+  "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} საუბრობს",
+  "ui.beforeunload": "თქვენი დრაფტი გაუქმდება თუ დატოვებთ მასტოდონს.",
+  "upload_area.title": "გადმოწიეთ და ჩააგდეთ ასატვირთათ",
+  "upload_button.label": "მედიის დამატება",
+  "upload_form.description": "აღწერილობა ვიზუალურად უფასურისთვის",
+  "upload_form.focus": "კროპი",
+  "upload_form.undo": "გაუქმება",
+  "upload_progress.label": "იტვირთება...",
+  "video.close": "ვიდეოს დახურვა",
+  "video.exit_fullscreen": "სრულ ეკრანზე ჩვენების გათიშვა",
+  "video.expand": "ვიდეოს გაფართოება",
+  "video.fullscreen": "ჩვენება სრულ ეკრანზე",
+  "video.hide": "ვიდეოს დამალვა",
+  "video.mute": "ხმის გაჩუმება",
+  "video.pause": "პაუზა",
+  "video.play": "დაკვრა",
+  "video.unmute": "ხმის გაჩუმების მოშორება"
+}
diff --git a/app/javascript/mastodon/locales/whitelist_ka.json b/app/javascript/mastodon/locales/whitelist_ka.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_ka.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 7d632776e..e5fdc7f83 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -51,13 +51,6 @@ function main() {
       }, datetime, now, datetime.getFullYear());
     });
 
-    [].forEach.call(document.querySelectorAll('.modal-button'), (content) => {
-      content.addEventListener('click', (e) => {
-        e.preventDefault();
-        window.open(e.target.href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
-      });
-    });
-
     const reactComponents = document.querySelectorAll('[data-component]');
     if (reactComponents.length > 0) {
       import(/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container')
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 547bcfd1e..64a00c2c3 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -35,6 +35,17 @@
     transition: all 200ms ease-out;
   }
 
+  &--destructive {
+    transition: none;
+
+    &:active,
+    &:focus,
+    &:hover {
+      background-color: $error-red;
+      transition: none;
+    }
+  }
+
   &:disabled {
     background-color: $ui-primary-color;
     cursor: default;
@@ -628,6 +639,7 @@
   overflow: hidden;
   white-space: pre-wrap;
   padding-top: 2px;
+  color: $primary-text-color;
 
   &:focus {
     outline: 0;
diff --git a/app/javascript/styles/mastodon/stream_entries.scss b/app/javascript/styles/mastodon/stream_entries.scss
index 9e2aa720c..14306c8bd 100644
--- a/app/javascript/styles/mastodon/stream_entries.scss
+++ b/app/javascript/styles/mastodon/stream_entries.scss
@@ -3,6 +3,7 @@
   border-radius: 4px;
   overflow: hidden;
   margin-bottom: 10px;
+  text-align: left;
 
   @media screen and (max-width: $no-gap-breakpoint) {
     margin-bottom: 0;
@@ -36,7 +37,8 @@
 
     &:last-child {
       .detailed-status,
-      .status {
+      .status,
+      .load-more {
         border-bottom: 0;
         border-radius: 0 0 4px 4px;
       }
@@ -44,13 +46,15 @@
 
     &:first-child {
       .detailed-status,
-      .status {
+      .status,
+      .load-more {
         border-radius: 4px 4px 0 0;
       }
 
       &:last-child {
         .detailed-status,
-        .status {
+        .status,
+        .load-more {
           border-radius: 4px;
         }
       }
@@ -58,11 +62,16 @@
 
     @media screen and (max-width: 740px) {
       .detailed-status,
-      .status {
+      .status,
+      .load-more {
         border-radius: 0 !important;
       }
     }
   }
+
+  &--highlighted .entry {
+    background: lighten($ui-base-color, 8%);
+  }
 }
 
 .button.logo-button {
@@ -101,6 +110,18 @@
     }
   }
 
+  &.button--destructive {
+    &:active,
+    &:focus,
+    &:hover {
+      background: $error-red;
+
+      svg path:last-child {
+        fill: $error-red;
+      }
+    }
+  }
+
   @media screen and (max-width: $no-gap-breakpoint) {
     svg {
       display: none;
diff --git a/app/models/favourite.rb b/app/models/favourite.rb
index ce7a6a336..17f8c9fa6 100644
--- a/app/models/favourite.rb
+++ b/app/models/favourite.rb
@@ -32,11 +32,11 @@ class Favourite < ApplicationRecord
   private
 
   def increment_cache_counters
-    status.increment_count!(:favourites_count)
+    status&.increment_count!(:favourites_count)
   end
 
   def decrement_cache_counters
     return if association(:status).loaded? && (status.marked_for_destruction? || status.marked_for_mass_destruction?)
-    status.decrement_count!(:favourites_count)
+    status&.decrement_count!(:favourites_count)
   end
 end
diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb
index 070144e2d..2537de36c 100644
--- a/app/models/remote_follow.rb
+++ b/app/models/remote_follow.rb
@@ -22,6 +22,10 @@ class RemoteFollow
     addressable_template.expand(uri: account.local_username_and_domain).to_s
   end
 
+  def interact_address_for(status)
+    addressable_template.expand(uri: ActivityPub::TagManager.instance.uri_for(status)).to_s
+  end
+
   private
 
   def populate_template
diff --git a/app/models/status.rb b/app/models/status.rb
index 01615c876..8cd6d3862 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -416,6 +416,8 @@ class Status < ApplicationRecord
   private
 
   def update_status_stat!(attrs)
+    return if marked_for_destruction? || destroyed?
+
     record = status_stat || build_status_stat
     record.update(attrs)
   end
@@ -482,8 +484,8 @@ class Status < ApplicationRecord
       Account.where(id: account_id).update_all('statuses_count = COALESCE(statuses_count, 0) + 1')
     end
 
-    reblog.increment_count!(:reblogs_count) if reblog?
-    thread.increment_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?)
+    reblog&.increment_count!(:reblogs_count) if reblog?
+    thread&.increment_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?)
   end
 
   def decrement_counter_caches
@@ -495,7 +497,7 @@ class Status < ApplicationRecord
       Account.where(id: account_id).update_all('statuses_count = GREATEST(COALESCE(statuses_count, 0) - 1, 0)')
     end
 
-    reblog.decrement_count!(:reblogs_count) if reblog?
-    thread.decrement_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?)
+    reblog&.decrement_count!(:reblogs_count) if reblog?
+    thread&.decrement_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?)
   end
 end
diff --git a/app/models/user.rb b/app/models/user.rb
index 3e1b82962..8b65a900c 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -42,7 +42,14 @@ class User < ApplicationRecord
   include Settings::Extend
   include Omniauthable
 
-  ACTIVE_DURATION = 7.days
+  # The home and list feeds will be stored in Redis for this amount
+  # of time, and status fan-out to followers will include only people
+  # within this time frame. Lowering the duration may improve performance
+  # if lots of people sign up, but not a lot of them check their feed
+  # every day. Raising the duration reduces the amount of expensive
+  # RegenerationWorker jobs that need to be run when those people come
+  # to check their feed
+  ACTIVE_DURATION = ENV.fetch('USER_ACTIVE_DAYS', 7).to_i.days
 
   devise :two_factor_authenticatable,
          otp_secret_encryption_key: Rails.configuration.x.otp_secret
diff --git a/app/policies/invite_policy.rb b/app/policies/invite_policy.rb
index a2a65f934..14236f78b 100644
--- a/app/policies/invite_policy.rb
+++ b/app/policies/invite_policy.rb
@@ -9,6 +9,10 @@ class InvitePolicy < ApplicationPolicy
     min_required_role?
   end
 
+  def deactivate_all?
+    admin?
+  end
+
   def destroy?
     owner? || (Setting.min_invite_role == 'admin' ? admin? : staff?)
   end
diff --git a/app/serializers/webfinger_serializer.rb b/app/serializers/webfinger_serializer.rb
index f80d12c02..8c0b07702 100644
--- a/app/serializers/webfinger_serializer.rb
+++ b/app/serializers/webfinger_serializer.rb
@@ -20,7 +20,7 @@ class WebfingerSerializer < ActiveModel::Serializer
       { rel: 'self', type: 'application/activity+json', href: account_url(object) },
       { rel: 'salmon', href: api_salmon_url(object.id) },
       { rel: 'magic-public-key', href: "data:application/magic-public-key,#{object.magic_key}" },
-      { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_follow_url}?acct={uri}" },
+      { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" },
     ]
   end
 end
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index 2ed6698cf..b4641c4b4 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -25,7 +25,7 @@ class ProcessMentionsService < BaseService
         end
       end
 
-      next match if mention_undeliverable?(mentioned_account)
+      next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended
 
       mentions << mentioned_account.mentions.where(status: status).first_or_create(status: status)
 
diff --git a/app/views/admin/invites/index.html.haml b/app/views/admin/invites/index.html.haml
index 944a60471..42159e9f3 100644
--- a/app/views/admin/invites/index.html.haml
+++ b/app/views/admin/invites/index.html.haml
@@ -9,22 +9,28 @@
       %li= filter_link_to t('admin.invites.filter.available'), available: 1, expired: nil
       %li= filter_link_to t('admin.invites.filter.expired'), available: nil, expired: 1
 
+%hr.spacer/
+
 - if policy(:invite).create?
   %p= t('invites.prompt')
 
   = render 'invites/form'
 
-  %hr/
+  %hr.spacer/
 
-%table.table
-  %thead
-    %tr
-      %th
-      %th= t('invites.table.uses')
-      %th= t('invites.table.expires_at')
-      %th
-      %th
-  %tbody
-    = render @invites
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th
+        %th= t('invites.table.uses')
+        %th= t('invites.table.expires_at')
+        %th
+        %th
+    %tbody
+      = render @invites
 
 = paginate @invites
+
+- if policy(:invite).deactivate_all?
+  = link_to t('admin.invites.deactivate_all'), deactivate_all_admin_invites_path, method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button'
diff --git a/app/views/authorize_follows/show.html.haml b/app/views/authorize_follows/show.html.haml
deleted file mode 100644
index 90e65b34f..000000000
--- a/app/views/authorize_follows/show.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-- content_for :page_title do
-  = t('authorize_follow.title', acct: @account.acct)
-
-.form-container
-  .follow-prompt
-    = render 'application/card', account: @account
-
-  - if current_account.following?(@account)
-    .flash-message
-      %strong
-        = t('authorize_follow.already_following')
-    = render 'post_follow_actions'
-
-  - else
-    = form_tag authorize_follow_path, method: :post, class: 'simple_form' do
-      = hidden_field_tag :acct, @account.acct
-      = button_tag t('authorize_follow.follow'), type: :submit
diff --git a/app/views/authorize_follows/_post_follow_actions.html.haml b/app/views/authorize_interactions/_post_follow_actions.html.haml
index 2a9c062e9..561c60137 100644
--- a/app/views/authorize_follows/_post_follow_actions.html.haml
+++ b/app/views/authorize_interactions/_post_follow_actions.html.haml
@@ -1,4 +1,4 @@
 .post-follow-actions
-  %div= link_to t('authorize_follow.post_follow.web'), web_url("accounts/#{@account.id}"), class: 'button button--block'
-  %div= link_to t('authorize_follow.post_follow.return'), TagManager.instance.url_for(@account), class: 'button button--block'
+  %div= link_to t('authorize_follow.post_follow.web'), web_url("accounts/#{@resource.id}"), class: 'button button--block'
+  %div= link_to t('authorize_follow.post_follow.return'), TagManager.instance.url_for(@resource), class: 'button button--block'
   %div= t('authorize_follow.post_follow.close')
diff --git a/app/views/authorize_follows/error.html.haml b/app/views/authorize_interactions/error.html.haml
index 88d33b68d..88d33b68d 100644
--- a/app/views/authorize_follows/error.html.haml
+++ b/app/views/authorize_interactions/error.html.haml
diff --git a/app/views/authorize_interactions/show.html.haml b/app/views/authorize_interactions/show.html.haml
new file mode 100644
index 000000000..7ca9b98c1
--- /dev/null
+++ b/app/views/authorize_interactions/show.html.haml
@@ -0,0 +1,18 @@
+- content_for :page_title do
+  = t('authorize_follow.title', acct: @resource.acct)
+
+.form-container
+  .follow-prompt
+    = render 'application/card', account: @resource
+
+  - if current_account.following?(@resource)
+    .flash-message
+      %strong
+        = t('authorize_follow.already_following')
+
+    = render 'post_follow_actions'
+  - else
+    = form_tag authorize_interaction_path, method: :post, class: 'simple_form' do
+      = hidden_field_tag :action, :follow
+      = hidden_field_tag :acct, @resource.acct
+      = button_tag t('authorize_follow.follow'), type: :submit
diff --git a/app/views/authorize_follows/success.html.haml b/app/views/authorize_interactions/success.html.haml
index cf9cb50ea..47fd09767 100644
--- a/app/views/authorize_follows/success.html.haml
+++ b/app/views/authorize_interactions/success.html.haml
@@ -1,13 +1,13 @@
 - content_for :page_title do
-  = t('authorize_follow.title', acct: @account.acct)
+  = t('authorize_follow.title', acct: @resource.acct)
 
 .form-container
   .follow-prompt
-    - if @account.locked?
+    - if @resource.locked?
       %h2= t('authorize_follow.follow_request')
     - else
       %h2= t('authorize_follow.following')
 
-    = render 'application/card', account: @account
+    = render 'application/card', account: @resource
 
   = render 'post_follow_actions'
diff --git a/app/views/layouts/modal.html.haml b/app/views/layouts/modal.html.haml
index e808593cd..a86b4fd3f 100644
--- a/app/views/layouts/modal.html.haml
+++ b/app/views/layouts/modal.html.haml
@@ -1,5 +1,5 @@
 - content_for :content do
-  - if user_signed_in?
+  - if user_signed_in? && !@hide_header
     .account-header
       .avatar= image_tag current_account.avatar.url(:original)
       .name
diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml
index 098262b2e..24911bb1e 100644
--- a/app/views/layouts/public.html.haml
+++ b/app/views/layouts/public.html.haml
@@ -42,6 +42,6 @@
             %h4= t 'footer.more'
             %ul
               %li= link_to t('about.source_code'), Mastodon::Version.source_url
-              %li= link_to 'joinmastodon.org', 'https://joinmastodon.org'
+              %li= link_to t('about.apps'), 'https://joinmastodon.org/apps'
 
 = render template: 'layouts/application'
diff --git a/app/views/remote_interaction/new.html.haml b/app/views/remote_interaction/new.html.haml
new file mode 100644
index 000000000..7357546b6
--- /dev/null
+++ b/app/views/remote_interaction/new.html.haml
@@ -0,0 +1,17 @@
+.form-container
+  .follow-prompt
+    %h2= t('remote_interaction.prompt')
+
+    .public-layout
+      .activity-stream.activity-stream--highlighted
+        = render 'stream_entries/status', status: @status
+
+  = simple_form_for @remote_follow, as: :remote_follow, url: remote_interaction_path(@status) do |f|
+    = render 'shared/error_messages', object: @remote_follow
+
+    = f.input :acct, placeholder: t('remote_follow.acct'), input_html: { autocapitalize: 'none', autocorrect: 'off' }
+
+    .actions
+      = f.button :button, t('remote_interaction.proceed'), type: :submit
+
+    %p.hint.subtle-hint= t('remote_follow.no_account_html', sign_up_path: open_registrations? ? new_user_registration_path : 'https://joinmastodon.org/#getting-started')
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index aa160b979..a7c767816 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -39,6 +39,11 @@
       - else
         = link_to status.application.name, status.application.website, class: 'detailed-status__application', target: '_blank', rel: 'noopener'
       ·
+    = link_to remote_interaction_path(status), class: 'modal-button detailed-status__link' do
+      = fa_icon('reply')
+      %span.detailed-status__reblogs>= number_to_human status.replies_count, strip_insignificant_zeros: true
+      = " "
+    ·
     - if status.direct_visibility?
       %span.detailed-status__link<
         = fa_icon('envelope')
@@ -46,13 +51,15 @@
       %span.detailed-status__link<
         = fa_icon('lock')
     - else
-      %span.detailed-status__link<
+      = link_to remote_interaction_path(status), class: 'modal-button detailed-status__link' do
         = fa_icon('retweet')
-        %span.detailed-status__reblogs= number_to_human status.reblogs_count, strip_insignificant_zeros: true
+        %span.detailed-status__reblogs>= number_to_human status.reblogs_count, strip_insignificant_zeros: true
+        = " "
     ·
-    %span.detailed-status__link<
+    = link_to remote_interaction_path(status), class: 'modal-button detailed-status__link' do
       = fa_icon('star')
-      %span.detailed-status__favorites= number_to_human status.favourites_count, strip_insignificant_zeros: true
+      %span.detailed-status__favorites>= number_to_human status.favourites_count, strip_insignificant_zeros: true
+      = " "
 
     - if user_signed_in?
       ·
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index bdbb6f387..1d61684ab 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -30,14 +30,16 @@
       = react_component :media_gallery, height: 343, sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
 
   .status__action-bar
-    .status__action-bar-button.static-icon-button<
+    .status__action-bar__counter
+      = link_to remote_interaction_path(status), class: 'status__action-bar-button icon-button modal-button', style: 'font-size: 18px; width: 23.1429px; height: 23.1429px; line-height: 23.15px;' do
+        = fa_icon 'reply fw'
+      .status__action-bar__counter__label= obscured_counter status.replies_count
+    = link_to remote_interaction_path(status), class: 'status__action-bar-button icon-button modal-button', style: 'font-size: 18px; width: 23.1429px; height: 23.1429px; line-height: 23.15px;' do
       - if status.public_visibility? || status.unlisted_visibility?
         = fa_icon 'retweet fw'
-        %span.detailed-status__reblogs= number_to_human status.reblogs_count, strip_insignificant_zeros: true
       - elsif status.private_visibility?
         = fa_icon 'lock fw'
       - else
         = fa_icon 'envelope fw'
-    .status__action-bar-button.static-icon-button<
+    = link_to remote_interaction_path(status), class: 'status__action-bar-button icon-button modal-button', style: 'font-size: 18px; width: 23.1429px; height: 23.1429px; line-height: 23.15px;' do
       = fa_icon 'star fw'
-      %span.detailed-status__favorites= number_to_human status.favourites_count, strip_insignificant_zeros: true
diff --git a/app/views/stream_entries/_status.html.haml b/app/views/stream_entries/_status.html.haml
index 320c9bc4f..92003a48f 100644
--- a/app/views/stream_entries/_status.html.haml
+++ b/app/views/stream_entries/_status.html.haml
@@ -53,3 +53,9 @@
   - if @next_descendant_thread
     .entry{ class: entry_classes }
       = link_to_more short_account_status_url(status.account.username, status, since_descendant_thread_id: @max_descendant_thread_id - 1)
+
+- if include_threads && !embedded_view? && !user_signed_in?
+  .entry{ class: entry_classes }
+    = link_to new_user_session_path, class: 'load-more load-gap' do
+      = fa_icon 'comments'
+      = t('statuses.sign_in_to_participate')
diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml
index 9da6245dc..2edc155bf 100644
--- a/app/views/stream_entries/show.html.haml
+++ b/app/views/stream_entries/show.html.haml
@@ -19,7 +19,7 @@
 
 .grid
   .column-0
-    .activity-stream.activity-stream-headless.h-entry
+    .activity-stream.h-entry
       = render partial: "stream_entries/#{@type}", locals: { @type.to_sym => @stream_entry.activity, include_threads: true }
   .column-1
     = render 'application/sidebar'
diff --git a/app/views/well_known/webfinger/show.xml.ruby b/app/views/well_known/webfinger/show.xml.ruby
index 4352a24e9..968c8c138 100644
--- a/app/views/well_known/webfinger/show.xml.ruby
+++ b/app/views/well_known/webfinger/show.xml.ruby
@@ -37,7 +37,7 @@ doc << Ox::Element.new('XRD').tap do |xrd|
 
   xrd << Ox::Element.new('Link').tap do |link|
     link['rel']      = 'http://ostatus.org/schema/1.0/subscribe'
-    link['template'] = "#{authorize_follow_url}?acct={uri}"
+    link['template'] = "#{authorize_interaction_url}?acct={uri}"
   end
 end