about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/accounts_controller.rb2
-rw-r--r--app/controllers/api/base_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/notes_controller.rb30
-rw-r--r--app/controllers/api/v1/media_controller.rb2
-rw-r--r--app/controllers/concerns/localized.rb4
-rw-r--r--app/controllers/concerns/sign_in_token_authentication_concern.rb10
-rw-r--r--app/controllers/concerns/two_factor_authentication_concern.rb10
-rw-r--r--app/controllers/directories_controller.rb2
-rw-r--r--app/controllers/follower_accounts_controller.rb2
-rw-r--r--app/controllers/following_accounts_controller.rb2
-rw-r--r--app/controllers/media_controller.rb2
-rw-r--r--app/controllers/media_proxy_controller.rb4
-rw-r--r--app/controllers/remote_interaction_controller.rb2
-rw-r--r--app/controllers/settings/pictures_controller.rb13
-rw-r--r--app/controllers/statuses_controller.rb2
-rw-r--r--app/controllers/tags_controller.rb2
-rw-r--r--app/helpers/application_helper.rb12
-rw-r--r--app/helpers/statuses_helper.rb4
-rw-r--r--app/javascript/flavours/glitch/actions/account_notes.js69
-rw-r--r--app/javascript/flavours/glitch/features/account/components/account_note.js103
-rw-r--r--app/javascript/flavours/glitch/features/account/components/header.js11
-rw-r--r--app/javascript/flavours/glitch/features/account/containers/account_note_container.js34
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/header.js6
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js5
-rw-r--r--app/javascript/flavours/glitch/features/emoji_picker/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/status/components/card.js15
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/status_list_container.js4
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js33
-rw-r--r--app/javascript/flavours/glitch/reducers/account_notes.js44
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/markers.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/relationships.js4
-rw-r--r--app/javascript/flavours/glitch/styles/admin.scss18
-rw-r--r--app/javascript/flavours/glitch/styles/basics.scss29
-rw-r--r--app/javascript/flavours/glitch/styles/components/accounts.scss66
-rw-r--r--app/javascript/flavours/glitch/styles/components/media.scss3
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss6
-rw-r--r--app/javascript/flavours/glitch/styles/statuses.scss16
-rw-r--r--app/javascript/mastodon/actions/account_notes.js69
-rw-r--r--app/javascript/mastodon/components/__tests__/button-test.js10
-rw-r--r--app/javascript/mastodon/components/media_gallery.js44
-rw-r--r--app/javascript/mastodon/components/status.js26
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js30
-rw-r--r--app/javascript/mastodon/features/account/components/account_note.js103
-rw-r--r--app/javascript/mastodon/features/account/components/header.js11
-rw-r--r--app/javascript/mastodon/features/account/containers/account_note_container.js34
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js6
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js5
-rw-r--r--app/javascript/mastodon/features/audio/index.js690
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/upload_button.js10
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js21
-rw-r--r--app/javascript/mastodon/features/status/components/card.js48
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js71
-rw-r--r--app/javascript/mastodon/features/ui/components/__tests__/column-test.js15
-rw-r--r--app/javascript/mastodon/features/ui/containers/status_list_container.js2
-rw-r--r--app/javascript/mastodon/features/video/index.js113
-rw-r--r--app/javascript/mastodon/locales/ast.json2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json2
-rw-r--r--app/javascript/mastodon/locales/en.json2
-rw-r--r--app/javascript/mastodon/locales/ga.json2
-rw-r--r--app/javascript/mastodon/locales/hi.json2
-rw-r--r--app/javascript/mastodon/locales/kn.json2
-rw-r--r--app/javascript/mastodon/locales/lt.json2
-rw-r--r--app/javascript/mastodon/locales/lv.json2
-rw-r--r--app/javascript/mastodon/locales/mk.json2
-rw-r--r--app/javascript/mastodon/locales/ml.json2
-rw-r--r--app/javascript/mastodon/locales/mr.json2
-rw-r--r--app/javascript/mastodon/locales/ms.json2
-rw-r--r--app/javascript/mastodon/locales/ur.json2
-rw-r--r--app/javascript/mastodon/reducers/account_notes.js44
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/reducers/markers.js2
-rw-r--r--app/javascript/mastodon/reducers/relationships.js4
-rw-r--r--app/javascript/mastodon/test_setup.js6
-rw-r--r--app/javascript/styles/mastodon/admin.scss18
-rw-r--r--app/javascript/styles/mastodon/basics.scss27
-rw-r--r--app/javascript/styles/mastodon/components.scss203
-rw-r--r--app/javascript/styles/mastodon/rtl.scss1
-rw-r--r--app/javascript/styles/mastodon/statuses.scss5
-rw-r--r--app/lib/activitypub/activity/create.rb12
-rw-r--r--app/lib/feed_manager.rb2
-rw-r--r--app/lib/language_detector.rb2
-rw-r--r--app/lib/sanitize_config.rb2
-rw-r--r--app/models/account_note.rb20
-rw-r--r--app/models/concerns/account_interactions.rb8
-rw-r--r--app/models/concerns/remotable.rb29
-rw-r--r--app/models/media_attachment.rb123
-rw-r--r--app/presenters/account_relationships_presenter.rb6
-rw-r--r--app/serializers/activitypub/note_serializer.rb10
-rw-r--r--app/serializers/rest/media_attachment_serializer.rb4
-rw-r--r--app/serializers/rest/relationship_serializer.rb6
-rw-r--r--app/services/activitypub/process_account_service.rb4
-rw-r--r--app/views/accounts/_og.html.haml4
-rw-r--r--app/views/admin/accounts/index.html.haml2
-rw-r--r--app/views/admin/custom_emojis/index.html.haml2
-rw-r--r--app/views/admin/instances/index.html.haml2
-rw-r--r--app/views/admin/reports/index.html.haml2
-rw-r--r--app/views/admin/tags/index.html.haml2
-rw-r--r--app/views/media/player.html.haml18
-rw-r--r--app/views/statuses/_detailed_status.html.haml17
-rw-r--r--app/views/statuses/_og_image.html.haml17
-rw-r--r--app/views/statuses/_simple_status.html.haml6
-rw-r--r--app/workers/move_worker.rb17
-rw-r--r--app/workers/post_process_media_worker.rb6
-rw-r--r--app/workers/redownload_media_worker.rb3
106 files changed, 2079 insertions, 474 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index b8bca580f..5c8cdd174 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -11,7 +11,7 @@ class AccountsController < ApplicationController
   before_action :set_body_classes
 
   skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format&.to_sym) }
-  skip_before_action :require_functional!
+  skip_before_action :require_functional!, unless: :whitelist_mode?
 
   def show
     respond_to do |format|
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index 153ade253..045e7dd26 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -7,7 +7,7 @@ class Api::BaseController < ApplicationController
   include RateLimitHeaders
 
   skip_before_action :store_current_location
-  skip_before_action :require_functional!
+  skip_before_action :require_functional!, unless: :whitelist_mode?
 
   before_action :require_authenticated_user!, if: :disallow_unauthenticated_api_access?
   before_action :set_cache_headers
diff --git a/app/controllers/api/v1/accounts/notes_controller.rb b/app/controllers/api/v1/accounts/notes_controller.rb
new file mode 100644
index 000000000..032e807d1
--- /dev/null
+++ b/app/controllers/api/v1/accounts/notes_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class Api::V1::Accounts::NotesController < Api::BaseController
+  include Authorization
+
+  before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
+  before_action :require_user!
+  before_action :set_account
+
+  def create
+    if params[:comment].blank?
+      AccountNote.find_by(account: current_account, target_account: @account)&.destroy
+    else
+      @note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account)
+      @note.comment = params[:comment]
+      @note.save! if @note.changed?
+    end
+    render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
+  end
+
+  private
+
+  def set_account
+    @account = Account.find(params[:account_id])
+  end
+
+  def relationships_presenter
+    AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
+  end
+end
diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb
index 0bb3d0d27..a2a919a3e 100644
--- a/app/controllers/api/v1/media_controller.rb
+++ b/app/controllers/api/v1/media_controller.rb
@@ -39,7 +39,7 @@ class Api::V1::MediaController < Api::BaseController
   end
 
   def media_attachment_params
-    params.permit(:file, :description, :focus)
+    params.permit(:file, :thumbnail, :description, :focus)
   end
 
   def file_type_error
diff --git a/app/controllers/concerns/localized.rb b/app/controllers/concerns/localized.rb
index d1384ed56..fe1142f34 100644
--- a/app/controllers/concerns/localized.rb
+++ b/app/controllers/concerns/localized.rb
@@ -7,8 +7,6 @@ module Localized
     around_action :set_locale
   end
 
-  private
-
   def set_locale
     locale   = current_user.locale if respond_to?(:user_signed_in?) && user_signed_in?
     locale ||= session[:locale] ||= default_locale
@@ -19,6 +17,8 @@ module Localized
     end
   end
 
+  private
+
   def default_locale
     if ENV['DEFAULT_LOCALE'].present?
       I18n.default_locale
diff --git a/app/controllers/concerns/sign_in_token_authentication_concern.rb b/app/controllers/concerns/sign_in_token_authentication_concern.rb
index 88c009b19..f5178930b 100644
--- a/app/controllers/concerns/sign_in_token_authentication_concern.rb
+++ b/app/controllers/concerns/sign_in_token_authentication_concern.rb
@@ -42,9 +42,11 @@ module SignInTokenAuthenticationConcern
       UserMailer.sign_in_token(user, request.remote_ip, request.user_agent, Time.now.utc.to_s).deliver_later!
     end
 
-    session[:attempt_user_id] = user.id
-    use_pack 'auth'
-    @body_classes = 'lighter'
-    render :sign_in_token
+    set_locale do
+      session[:attempt_user_id] = user.id
+      use_pack 'auth'
+      @body_classes = 'lighter'
+      render :sign_in_token
+    end
   end
 end
diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb
index 0d9f87455..35c0c27cf 100644
--- a/app/controllers/concerns/two_factor_authentication_concern.rb
+++ b/app/controllers/concerns/two_factor_authentication_concern.rb
@@ -40,9 +40,11 @@ module TwoFactorAuthenticationConcern
   end
 
   def prompt_for_two_factor(user)
-    session[:attempt_user_id] = user.id
-    use_pack 'auth'
-    @body_classes = 'lighter'
-    render :two_factor
+    set_locale do
+      session[:attempt_user_id] = user.id
+      use_pack 'auth'
+      @body_classes = 'lighter'
+      render :two_factor
+    end
   end
 end
diff --git a/app/controllers/directories_controller.rb b/app/controllers/directories_controller.rb
index adf2bd014..549c6a39e 100644
--- a/app/controllers/directories_controller.rb
+++ b/app/controllers/directories_controller.rb
@@ -10,7 +10,7 @@ class DirectoriesController < ApplicationController
   before_action :set_accounts
   before_action :set_pack
 
-  skip_before_action :require_functional!
+  skip_before_action :require_functional!, unless: :whitelist_mode?
 
   def index
     render :index
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index eb223c3f7..5ffbdae79 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -8,7 +8,7 @@ class FollowerAccountsController < ApplicationController
   before_action :set_cache_headers
 
   skip_around_action :set_locale, if: -> { request.format == :json }
-  skip_before_action :require_functional!
+  skip_before_action :require_functional!, unless: :whitelist_mode?
 
   def index
     respond_to do |format|
diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb
index 4ddccf607..69820ebb7 100644
--- a/app/controllers/following_accounts_controller.rb
+++ b/app/controllers/following_accounts_controller.rb
@@ -8,7 +8,7 @@ class FollowingAccountsController < ApplicationController
   before_action :set_cache_headers
 
   skip_around_action :set_locale, if: -> { request.format == :json }
-  skip_before_action :require_functional!
+  skip_before_action :require_functional!, unless: :whitelist_mode?
 
   def index
     respond_to do |format|
diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb
index 1d166d6e7..ce015dd1b 100644
--- a/app/controllers/media_controller.rb
+++ b/app/controllers/media_controller.rb
@@ -4,7 +4,7 @@ class MediaController < ApplicationController
   include Authorization
 
   skip_before_action :store_current_location
-  skip_before_action :require_functional!
+  skip_before_action :require_functional!, unless: :whitelist_mode?
 
   before_action :authenticate_user!, if: :whitelist_mode?
   before_action :set_media_attachment
diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb
index 014b89de1..a8261ec2b 100644
--- a/app/controllers/media_proxy_controller.rb
+++ b/app/controllers/media_proxy_controller.rb
@@ -28,8 +28,8 @@ class MediaProxyController < ApplicationController
   private
 
   def redownload!
-    @media_attachment.file_remote_url = @media_attachment.remote_url
-    @media_attachment.created_at      = Time.now.utc
+    @media_attachment.download_file!
+    @media_attachment.created_at = Time.now.utc
     @media_attachment.save!
   end
 
diff --git a/app/controllers/remote_interaction_controller.rb b/app/controllers/remote_interaction_controller.rb
index 51bb9bdea..a277bfa10 100644
--- a/app/controllers/remote_interaction_controller.rb
+++ b/app/controllers/remote_interaction_controller.rb
@@ -11,7 +11,7 @@ class RemoteInteractionController < ApplicationController
   before_action :set_body_classes
   before_action :set_pack
 
-  skip_before_action :require_functional!
+  skip_before_action :require_functional!, unless: :whitelist_mode?
 
   def new
     @remote_follow = RemoteFollow.new(session_params)
diff --git a/app/controllers/settings/pictures_controller.rb b/app/controllers/settings/pictures_controller.rb
index 73926707b..df2a6eed3 100644
--- a/app/controllers/settings/pictures_controller.rb
+++ b/app/controllers/settings/pictures_controller.rb
@@ -7,13 +7,8 @@ module Settings
     before_action :set_picture
 
     def destroy
-      if valid_picture
-        account_params = {
-          @picture => nil,
-          (@picture + '_remote_url') => nil,
-        }
-
-        msg = UpdateAccountService.new.call(@account, account_params) ? I18n.t('generic.changes_saved_msg') : nil
+      if valid_picture?
+        msg = I18n.t('generic.changes_saved_msg') if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' })
         redirect_to settings_profile_path, notice: msg, status: 303
       else
         bad_request
@@ -30,8 +25,8 @@ module Settings
       @picture = params[:id]
     end
 
-    def valid_picture
-      @picture == 'avatar' || @picture == 'header'
+    def valid_picture?
+      %w(avatar header).include?(@picture)
     end
   end
 end
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index b0abad984..a6ab8828f 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -19,7 +19,7 @@ class StatusesController < ApplicationController
   before_action :set_autoplay, only: :embed
 
   skip_around_action :set_locale, if: -> { request.format == :json }
-  skip_before_action :require_functional!, only: [:show, :embed]
+  skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode?
 
   content_security_policy only: :embed do |p|
     p.frame_ancestors(false)
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 2363cb31b..e46c0532c 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -15,7 +15,7 @@ class TagsController < ApplicationController
   before_action :set_body_classes
   before_action :set_instance_presenter
 
-  skip_before_action :require_functional!
+  skip_before_action :require_functional!, unless: :whitelist_mode?
 
   def show
     respond_to do |format|
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 2f11ccb6f..9ca11d573 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -77,6 +77,18 @@ module ApplicationHelper
     content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
   end
 
+  def visibility_icon(status)
+    if status.public_visibility?
+      fa_icon('globe', title: I18n.t('statuses.visibilities.public'))
+    elsif status.unlisted_visibility?
+      fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted'))
+    elsif status.private_visibility? || status.limited_visibility?
+      fa_icon('lock', title: I18n.t('statuses.visibilities.private'))
+    elsif status.direct_visibility?
+      fa_icon('envelope', title: I18n.t('statuses.visibilities.direct'))
+    end
+  end
+
   def custom_emoji_tag(custom_emoji, animate = true)
     if animate
       image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:")
diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb
index 866a9902c..a51597cf3 100644
--- a/app/helpers/statuses_helper.rb
+++ b/app/helpers/statuses_helper.rb
@@ -15,11 +15,13 @@ module StatusesHelper
   end
 
   def media_summary(status)
-    attachments = { image: 0, video: 0 }
+    attachments = { image: 0, video: 0, audio: 0 }
 
     status.media_attachments.each do |media|
       if media.video?
         attachments[:video] += 1
+      elsif media.audio?
+        attachments[:audio] += 1
       else
         attachments[:image] += 1
       end
diff --git a/app/javascript/flavours/glitch/actions/account_notes.js b/app/javascript/flavours/glitch/actions/account_notes.js
new file mode 100644
index 000000000..c1cce3193
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/account_notes.js
@@ -0,0 +1,69 @@
+import api from 'flavours/glitch/util/api';
+
+export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
+export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
+export const ACCOUNT_NOTE_SUBMIT_FAIL    = 'ACCOUNT_NOTE_SUBMIT_FAIL';
+
+export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT';
+export const ACCOUNT_NOTE_CANCEL    = 'ACCOUNT_NOTE_CANCEL';
+
+export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT';
+
+export function submitAccountNote() {
+  return (dispatch, getState) => {
+    dispatch(submitAccountNoteRequest());
+
+    const id = getState().getIn(['account_notes', 'edit', 'account_id']);
+
+    api(getState).post(`/api/v1/accounts/${id}/note`, {
+      comment: getState().getIn(['account_notes', 'edit', 'comment']),
+    }).then(response => {
+      dispatch(submitAccountNoteSuccess(response.data));
+    }).catch(error => dispatch(submitAccountNoteFail(error)));
+  };
+};
+
+export function submitAccountNoteRequest() {
+  return {
+    type: ACCOUNT_NOTE_SUBMIT_REQUEST,
+  };
+};
+
+export function submitAccountNoteSuccess(relationship) {
+  return {
+    type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
+    relationship,
+  };
+};
+
+export function submitAccountNoteFail(error) {
+  return {
+    type: ACCOUNT_NOTE_SUBMIT_FAIL,
+    error,
+  };
+};
+
+export function initEditAccountNote(account) {
+  return (dispatch, getState) => {
+    const comment = getState().getIn(['relationships', account.get('id'), 'note']);
+
+    dispatch({
+      type: ACCOUNT_NOTE_INIT_EDIT,
+      account,
+      comment,
+    });
+  };
+};
+
+export function cancelAccountNote() {
+  return {
+    type: ACCOUNT_NOTE_CANCEL,
+  };
+};
+
+export function changeAccountNoteComment(comment) {
+  return {
+    type: ACCOUNT_NOTE_CHANGE_COMMENT,
+    comment,
+  };
+};
diff --git a/app/javascript/flavours/glitch/features/account/components/account_note.js b/app/javascript/flavours/glitch/features/account/components/account_note.js
new file mode 100644
index 000000000..e7fd4c5ff
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/components/account_note.js
@@ -0,0 +1,103 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Icon from 'flavours/glitch/components/icon';
+import Textarea from 'react-textarea-autosize';
+
+const messages = defineMessages({
+  placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' },
+});
+
+export default @injectIntl
+class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    isEditing: PropTypes.bool,
+    isSubmitting: PropTypes.bool,
+    accountNote: PropTypes.string,
+    onEditAccountNote: PropTypes.func.isRequired,
+    onCancelAccountNote: PropTypes.func.isRequired,
+    onSaveAccountNote: PropTypes.func.isRequired,
+    onChangeAccountNote: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleChangeAccountNote = (e) => {
+    this.props.onChangeAccountNote(e.target.value);
+  };
+
+  componentWillUnmount () {
+    if (this.props.isEditing) {
+      this.props.onCancelAccountNote();
+    }
+  }
+
+  handleKeyDown = e => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      this.props.onSaveAccountNote();
+    } else if (e.keyCode === 27) {
+      this.props.onCancelAccountNote();
+    }
+  }
+
+  render () {
+    const { account, accountNote, isEditing, isSubmitting, intl } = this.props;
+
+    if (!account || (!accountNote && !isEditing)) {
+      return null;
+    }
+
+    let action_buttons = null;
+    if (isEditing) {
+      action_buttons = (
+        <div className='account__header__account-note__buttons'>
+          <button className='text-btn' tabIndex='0' onClick={this.props.onCancelAccountNote} disabled={isSubmitting}>
+            <Icon id='times' size={15} /> <FormattedMessage id='account_note.cancel' defaultMessage='Cancel' />
+          </button>
+          <div className='flex-spacer' />
+          <button className='text-btn' tabIndex='0' onClick={this.props.onSaveAccountNote} disabled={isSubmitting}>
+            <Icon id='check' size={15} /> <FormattedMessage id='account_note.save' defaultMessage='Save' />
+          </button>
+        </div>
+      );
+    }
+
+    let note_container = null;
+    if (isEditing) {
+      note_container = (
+        <Textarea
+          className='account__header__account-note__content'
+          disabled={isSubmitting}
+          placeholder={intl.formatMessage(messages.placeholder)}
+          value={accountNote}
+          onChange={this.handleChangeAccountNote}
+          onKeyDown={this.handleKeyDown}
+          autoFocus
+        />
+      );
+    } else {
+      note_container = (<div className='account__header__account-note__content'>{accountNote}</div>);
+    }
+
+    return (
+      <div className='account__header__account-note'>
+        <div className='account__header__account-note__header'>
+          <strong><FormattedMessage id='account.account_note_header' defaultMessage='Your note for @{name}' values={{ name: account.get('username') }} /></strong>
+          {!isEditing && (
+            <div>
+              <button className='text-btn' tabIndex='0' onClick={this.props.onEditAccountNote} disabled={isSubmitting}>
+                <Icon id='pencil' size={15} /> <FormattedMessage id='account_note.edit' defaultMessage='Edit' />
+              </button>
+            </div>
+          )}
+        </div>
+        {note_container}
+        {action_buttons}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js
index c7b54649c..a5abf38ae 100644
--- a/app/javascript/flavours/glitch/features/account/components/header.js
+++ b/app/javascript/flavours/glitch/features/account/components/header.js
@@ -12,6 +12,7 @@ import Button from 'flavours/glitch/components/button';
 import { shortNumberFormat } from 'flavours/glitch/util/numbers';
 import { NavLink } from 'react-router-dom';
 import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
+import AccountNoteContainer from '../containers/account_note_container';
 
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@@ -46,6 +47,7 @@ const messages = defineMessages({
   unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
   add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
   admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
+  add_account_note: { id: 'account.add_account_note', defaultMessage: 'Add note for @{name}' },
 });
 
 const dateFormatOptions = {
@@ -65,6 +67,7 @@ class Header extends ImmutablePureComponent {
     identity_props: ImmutablePropTypes.list,
     onFollow: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
+    onEditAccountNote: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
     domain: PropTypes.string.isRequired,
   };
@@ -121,6 +124,8 @@ class Header extends ImmutablePureComponent {
       return null;
     }
 
+    const accountNote = account.getIn(['relationship', 'note']);
+
     let info        = [];
     let actionBtn   = '';
     let lockedIcon  = '';
@@ -172,6 +177,10 @@ class Header extends ImmutablePureComponent {
       menu.push(null);
     }
 
+    if (accountNote === null) {
+      menu.push({ text: intl.formatMessage(messages.add_account_note, { name: account.get('username') }), action: this.props.onEditAccountNote });
+    }
+
     if (account.get('id') === me) {
       if (profileLink) menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
       if (preferencesLink) menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
@@ -278,6 +287,8 @@ class Header extends ImmutablePureComponent {
             </h1>
           </div>
 
+          <AccountNoteContainer account={account} />
+
           <div className='account__header__extra'>
             <div className='account__header__bio'>
               { (fields.size > 0 || identity_proofs.size > 0) && (
diff --git a/app/javascript/flavours/glitch/features/account/containers/account_note_container.js b/app/javascript/flavours/glitch/features/account/containers/account_note_container.js
new file mode 100644
index 000000000..f1d007ecb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/containers/account_note_container.js
@@ -0,0 +1,34 @@
+import { connect } from 'react-redux';
+import { changeAccountNoteComment, submitAccountNote, initEditAccountNote, cancelAccountNote } from 'flavours/glitch/actions/account_notes';
+import AccountNote from '../components/account_note';
+
+const mapStateToProps = (state, { account }) => {
+  const isEditing = state.getIn(['account_notes', 'edit', 'account_id']) === account.get('id');
+
+  return {
+    isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']),
+    accountNote: isEditing ? state.getIn(['account_notes', 'edit', 'comment']) : account.getIn(['relationship', 'note']),
+    isEditing,
+  };
+};
+
+const mapDispatchToProps = (dispatch, { account }) => ({
+
+  onEditAccountNote() {
+    dispatch(initEditAccountNote(account));
+  },
+
+  onSaveAccountNote() {
+    dispatch(submitAccountNote());
+  },
+
+  onCancelAccountNote() {
+    dispatch(cancelAccountNote());
+  },
+
+  onChangeAccountNote(comment) {
+    dispatch(changeAccountNoteComment(comment));
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.js b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
index 0faa8a424..1bab05c72 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/components/header.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
@@ -24,6 +24,7 @@ export default class Header extends ImmutablePureComponent {
     onUnblockDomain: PropTypes.func.isRequired,
     onEndorseToggle: PropTypes.func.isRequired,
     onAddToList: PropTypes.func.isRequired,
+    onEditAccountNote: PropTypes.func.isRequired,
     hideTabs: PropTypes.bool,
     domain: PropTypes.string.isRequired,
   };
@@ -84,6 +85,10 @@ export default class Header extends ImmutablePureComponent {
     this.props.onAddToList(this.props.account);
   }
 
+  handleEditAccountNote = () => {
+    this.props.onEditAccountNote(this.props.account);
+  }
+
   render () {
     const { account, hideTabs, identity_proofs } = this.props;
 
@@ -109,6 +114,7 @@ export default class Header extends ImmutablePureComponent {
           onUnblockDomain={this.handleUnblockDomain}
           onEndorseToggle={this.handleEndorseToggle}
           onAddToList={this.handleAddToList}
+          onEditAccountNote={this.handleEditAccountNote}
           domain={this.props.domain}
         />
 
diff --git a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
index fff5e097f..225910292 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
@@ -19,6 +19,7 @@ import { initBlockModal } from 'flavours/glitch/actions/blocks';
 import { initReport } from 'flavours/glitch/actions/reports';
 import { openModal } from 'flavours/glitch/actions/modal';
 import { blockDomain, unblockDomain } from 'flavours/glitch/actions/domain_blocks';
+import { initEditAccountNote } from 'flavours/glitch/actions/account_notes';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { unfollowModal } from 'flavours/glitch/util/initial_state';
 import { List as ImmutableList } from 'immutable';
@@ -106,6 +107,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onEditAccountNote (account) {
+    dispatch(initEditAccountNote(account));
+  },
+
   onBlockDomain (domain) {
     dispatch(openModal('CONFIRM', {
       message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />,
diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js
index 14e5cb94a..d0d9714a8 100644
--- a/app/javascript/flavours/glitch/features/emoji_picker/index.js
+++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js
@@ -283,7 +283,7 @@ class EmojiPickerMenu extends React.PureComponent {
     if (!emoji.native) {
       emoji.native = emoji.colons;
     }
-    if (!event.ctrlKey) {
+    if (!(event.ctrlKey || event.metaKey)) {
       this.props.onClose();
     }
     this.props.onPick(emoji);
diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js
index 03867e03a..d2de225c0 100644
--- a/app/javascript/flavours/glitch/features/status/components/card.js
+++ b/app/javascript/flavours/glitch/features/status/components/card.js
@@ -7,7 +7,6 @@ import punycode from 'punycode';
 import classnames from 'classnames';
 import { decode as decodeIDNA } from 'flavours/glitch/util/idna';
 import Icon from 'flavours/glitch/components/icon';
-import classNames from 'classnames';
 import { useBlurhash } from 'flavours/glitch/util/initial_state';
 import { decode } from 'blurhash';
 
@@ -156,7 +155,9 @@ export default class Card extends React.PureComponent {
     this.setState({ previewLoaded: true });
   }
 
-  handleReveal = () => {
+  handleReveal = e => {
+    e.preventDefault();
+    e.stopPropagation();
     this.setState({ revealed: true });
   }
 
@@ -194,7 +195,7 @@ export default class Card extends React.PureComponent {
     const height      = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
 
     const description = (
-      <div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}>
+      <div className='status-card__content'>
         {title}
         {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
         <span className='status-card__host'>{provider}</span>
@@ -202,7 +203,7 @@ export default class Card extends React.PureComponent {
     );
 
     let embed     = '';
-    let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
+    let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classnames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
     let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
     let spoilerButton = (
       <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
@@ -210,7 +211,7 @@ export default class Card extends React.PureComponent {
       </button>
     );
     spoilerButton = (
-      <div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}>
+      <div className={classnames('spoiler-button', { 'spoiler-button--minified': revealed })}>
         {spoilerButton}
       </div>
     );
@@ -244,7 +245,7 @@ export default class Card extends React.PureComponent {
       }
 
       return (
-        <div className={className} ref={this.setRef}>
+        <div className={className} ref={this.setRef} onClick={revealed ? null : this.handleReveal} role={revealed ? 'button' : null}>
           {embed}
           {!compact && description}
         </div>
@@ -254,14 +255,12 @@ export default class Card extends React.PureComponent {
         <div className='status-card__image'>
           {canvas}
           {thumbnail}
-          {!revealed && spoilerButton}
         </div>
       );
     } else {
       embed = (
         <div className='status-card__image'>
           <Icon id='file-text' />
-          {!revealed && spoilerButton}
         </div>
       );
     }
diff --git a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
index c01d0e5bc..bd2d2eb4e 100644
--- a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
+++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
@@ -33,6 +33,8 @@ const makeGetStatusIds = (pending = false) => createSelector([
     const statusForId = statuses.get(id);
     let showStatus    = true;
 
+    if (statusForId.get('account') === me) return true;
+
     if (columnSettings.getIn(['shows', 'reblog']) === false) {
       showStatus = showStatus && statusForId.get('reblog') === null;
     }
@@ -45,7 +47,7 @@ const makeGetStatusIds = (pending = false) => createSelector([
       showStatus = showStatus && statusForId.get('visibility') !== 'direct';
     }
 
-    if (showStatus && regex && statusForId.get('account') !== me) {
+    if (showStatus && regex) {
       const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index');
       showStatus = !regex.test(searchIndex);
     }
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
index a89d9c8b0..e5b681064 100644
--- a/app/javascript/flavours/glitch/features/video/index.js
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -185,15 +185,26 @@ class Video extends React.PureComponent {
 
   handlePlay = () => {
     this.setState({ paused: false });
+    this._updateTime();
   }
 
   handlePause = () => {
     this.setState({ paused: true });
   }
 
+  _updateTime () {
+    requestAnimationFrame(() => {
+      this.handleTimeUpdate();
+
+      if (!this.state.paused) {
+        this._updateTime();
+      }
+    });
+  }
+
   handleTimeUpdate = () => {
     this.setState({
-      currentTime: Math.floor(this.video.currentTime),
+      currentTime: this.video.currentTime,
       duration: Math.floor(this.video.duration),
     });
   }
@@ -231,7 +242,7 @@ class Video extends React.PureComponent {
       this.video.volume = slideamt;
       this.setState({ volume: slideamt });
     }
-  }, 60);
+  }, 15);
 
   handleMouseDown = e => {
     document.addEventListener('mousemove', this.handleMouseMove, true);
@@ -259,13 +270,14 @@ class Video extends React.PureComponent {
 
   handleMouseMove = throttle(e => {
     const { x } = getPointerPosition(this.seek, e);
-    const currentTime = Math.floor(this.video.duration * x);
+    const currentTime = this.video.duration * x;
 
     if (!isNaN(currentTime)) {
-      this.video.currentTime = currentTime;
-      this.setState({ currentTime });
+      this.setState({ currentTime }, () => {
+        this.video.currentTime = currentTime;
+      });
     }
-  }, 60);
+  }, 15);
 
   togglePlay = () => {
     if (this.state.paused) {
@@ -374,8 +386,10 @@ class Video extends React.PureComponent {
   }
 
   handleProgress = () => {
-    if (this.video.buffered.length > 0) {
-      this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
+    const lastTimeRange = this.video.buffered.length - 1;
+
+    if (lastTimeRange > -1) {
+      this.setState({ buffer: Math.ceil(this.video.buffered.end(lastTimeRange) / this.video.duration * 100) });
     }
   }
 
@@ -477,7 +491,6 @@ class Video extends React.PureComponent {
           onClick={this.togglePlay}
           onPlay={this.handlePlay}
           onPause={this.handlePause}
-          onTimeUpdate={this.handleTimeUpdate}
           onLoadedData={this.handleLoadedData}
           onProgress={this.handleProgress}
           onVolumeChange={this.handleVolumeChange}
@@ -518,7 +531,7 @@ class Video extends React.PureComponent {
 
               {(detailed || fullscreen) && (
                 <span>
-                  <span className='video-player__time-current'>{formatTime(currentTime)}</span>
+                  <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
                   <span className='video-player__time-sep'>/</span>
                   <span className='video-player__time-total'>{formatTime(duration)}</span>
                 </span>
diff --git a/app/javascript/flavours/glitch/reducers/account_notes.js b/app/javascript/flavours/glitch/reducers/account_notes.js
new file mode 100644
index 000000000..b1cf2e0aa
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/account_notes.js
@@ -0,0 +1,44 @@
+import { Map as ImmutableMap } from 'immutable';
+
+import {
+  ACCOUNT_NOTE_INIT_EDIT,
+  ACCOUNT_NOTE_CANCEL,
+  ACCOUNT_NOTE_CHANGE_COMMENT,
+  ACCOUNT_NOTE_SUBMIT_REQUEST,
+  ACCOUNT_NOTE_SUBMIT_FAIL,
+  ACCOUNT_NOTE_SUBMIT_SUCCESS,
+} from '../actions/account_notes';
+
+const initialState = ImmutableMap({
+  edit: ImmutableMap({
+    isSubmitting: false,
+    account_id: null,
+    comment: null,
+  }),
+});
+
+export default function account_notes(state = initialState, action) {
+  switch (action.type) {
+  case ACCOUNT_NOTE_INIT_EDIT:
+    return state.withMutations((state) => {
+      state.setIn(['edit', 'isSubmitting'], false);
+      state.setIn(['edit', 'account_id'], action.account.get('id'));
+      state.setIn(['edit', 'comment'], action.comment);
+    });
+  case ACCOUNT_NOTE_CHANGE_COMMENT:
+    return state.setIn(['edit', 'comment'], action.comment);
+  case ACCOUNT_NOTE_SUBMIT_REQUEST:
+    return state.setIn(['edit', 'isSubmitting'], true);
+  case ACCOUNT_NOTE_SUBMIT_FAIL:
+    return state.setIn(['edit', 'isSubmitting'], false);
+  case ACCOUNT_NOTE_SUBMIT_SUCCESS:
+  case ACCOUNT_NOTE_CANCEL:
+    return state.withMutations((state) => {
+      state.setIn(['edit', 'isSubmitting'], false);
+      state.setIn(['edit', 'account_id'], null);
+      state.setIn(['edit', 'comment'], null);
+    });
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
index 852abe9dd..cadbd01a3 100644
--- a/app/javascript/flavours/glitch/reducers/index.js
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -37,6 +37,7 @@ import identity_proofs from './identity_proofs';
 import trends from './trends';
 import announcements from './announcements';
 import markers from './markers';
+import account_notes from './account_notes';
 
 const reducers = {
   announcements,
@@ -77,6 +78,7 @@ const reducers = {
   polls,
   trends,
   markers,
+  account_notes,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/flavours/glitch/reducers/markers.js b/app/javascript/flavours/glitch/reducers/markers.js
index 2e67be82e..fb1572ff5 100644
--- a/app/javascript/flavours/glitch/reducers/markers.js
+++ b/app/javascript/flavours/glitch/reducers/markers.js
@@ -1,6 +1,6 @@
 import {
   MARKERS_SUBMIT_SUCCESS,
-} from '../actions/notifications';
+} from '../actions/markers';
 
 const initialState = ImmutableMap({
   home: '0',
diff --git a/app/javascript/flavours/glitch/reducers/relationships.js b/app/javascript/flavours/glitch/reducers/relationships.js
index 4652bbc14..dcaeefcae 100644
--- a/app/javascript/flavours/glitch/reducers/relationships.js
+++ b/app/javascript/flavours/glitch/reducers/relationships.js
@@ -13,6 +13,9 @@ import {
   DOMAIN_BLOCK_SUCCESS,
   DOMAIN_UNBLOCK_SUCCESS,
 } from 'flavours/glitch/actions/domain_blocks';
+import {
+  ACCOUNT_NOTE_SUBMIT_SUCCESS,
+} from 'flavours/glitch/actions/account_notes';
 import { Map as ImmutableMap, fromJS } from 'immutable';
 
 const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship));
@@ -45,6 +48,7 @@ export default function relationships(state = initialState, action) {
   case ACCOUNT_UNMUTE_SUCCESS:
   case ACCOUNT_PIN_SUCCESS:
   case ACCOUNT_UNPIN_SUCCESS:
+  case ACCOUNT_NOTE_SUBMIT_SUCCESS:
     return normalizeRelationship(state, action.relationship);
   case RELATIONSHIPS_FETCH_SUCCESS:
     return normalizeRelationships(state, action.relationships);
diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
index 1c8f2271f..3cf5ee970 100644
--- a/app/javascript/flavours/glitch/styles/admin.scss
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -171,9 +171,7 @@ $content-width: 840px;
   }
 
   .content {
-    padding: 20px 15px;
-    padding-top: 60px;
-    padding-left: 25px;
+    padding: 55px 15px 20px 25px;
 
     @media screen and (max-width: $no-columns-breakpoint) {
       max-width: none;
@@ -184,7 +182,7 @@ $content-width: 840px;
     &-heading {
       display: flex;
 
-      padding-bottom: 40px;
+      padding-bottom: 36px;
       border-bottom: 1px solid lighten($ui-base-color, 8%);
 
       margin: -15px -15px 40px 0;
@@ -215,7 +213,7 @@ $content-width: 840px;
     h2 {
       color: $secondary-text-color;
       font-size: 24px;
-      line-height: 28px;
+      line-height: 36px;
       font-weight: 400;
 
       @media screen and (max-width: $no-columns-breakpoint) {
@@ -528,6 +526,16 @@ body,
   max-width: 100%;
 }
 
+.simple_form {
+  .actions {
+    margin-top: 15px;
+  }
+
+  .button {
+    font-size: 15px;
+  }
+}
+
 .batch-form-box {
   display: flex;
   flex-wrap: wrap;
diff --git a/app/javascript/flavours/glitch/styles/basics.scss b/app/javascript/flavours/glitch/styles/basics.scss
index 9ff3f3bac..be0e1b860 100644
--- a/app/javascript/flavours/glitch/styles/basics.scss
+++ b/app/javascript/flavours/glitch/styles/basics.scss
@@ -66,6 +66,35 @@ body {
     }
   }
 
+  &.player {
+    padding: 0;
+    margin: 0;
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+
+    & > div {
+      height: 100%;
+    }
+
+    .video-player video {
+      width: 100%;
+      height: 100%;
+      max-height: 100vh;
+    }
+
+    .media-gallery {
+      margin-top: 0;
+      height: 100% !important;
+      border-radius: 0;
+    }
+
+    .media-gallery__item {
+      border-radius: 0;
+    }
+  }
+
   &.embed {
     background: lighten($ui-base-color, 4%);
     margin: 0;
diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss
index 610e48f92..774254a4c 100644
--- a/app/javascript/flavours/glitch/styles/components/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -379,7 +379,6 @@
   color: $primary-text-color;
   margin-bottom: 4px;
   display: block;
-  vertical-align: top;
   background-color: $base-overlay-background;
   text-transform: uppercase;
   font-size: 11px;
@@ -605,7 +604,7 @@
   &__tabs {
     display: flex;
     align-items: flex-start;
-    padding: 7px 5px;
+    padding: 7px 10px;
     margin-top: -55px;
 
     &__buttons {
@@ -627,7 +626,7 @@
     }
 
     &__name {
-      padding: 5px;
+      padding: 5px 10px;
 
       .account-role {
         vertical-align: top;
@@ -713,4 +712,65 @@
       }
     }
   }
+
+  &__account-note {
+    margin: 5px;
+    padding: 10px;
+    background: $ui-highlight-color;
+    color: $primary-text-color;
+    display: flex;
+    flex-direction: column;
+    border-radius: 4px;
+    font-size: 14px;
+    font-weight: 400;
+
+    &__header {
+      display: flex;
+      flex-direction: row;
+      justify-content: space-between;
+    }
+
+    &__content {
+      white-space: pre-wrap;
+      margin-top: 5px;
+    }
+
+    &__buttons {
+      display: flex;
+      flex-direction: row;
+      justify-content: flex-end;
+      margin-top: 5px;
+
+      .flex-spacer {
+        flex: 0 0 20px;
+        background: transparent;
+      }
+    }
+
+    strong {
+      font-size: 15px;
+      font-weight: 500;
+    }
+
+    button:hover span {
+      text-decoration: underline;
+    }
+
+    textarea {
+      display: block;
+      box-sizing: border-box;
+      width: 100%;
+      margin: 0;
+      margin-top: 5px;
+      color: $inverted-text-color;
+      background: $simple-background-color;
+      padding: 10px;
+      font-family: inherit;
+      font-size: 14px;
+      resize: none;
+      border: 0;
+      outline: 0;
+      border-radius: 4px;
+    }
+  }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss
index dbf0c908d..772b40dc4 100644
--- a/app/javascript/flavours/glitch/styles/components/media.scss
+++ b/app/javascript/flavours/glitch/styles/components/media.scss
@@ -76,7 +76,7 @@
   border-radius: 4px;
   position: relative;
   width: 100%;
-  height: 110px;
+  min-height: 64px;
 
   @include fullwidth-gallery;
 }
@@ -404,6 +404,7 @@
   @include fullwidth-gallery;
 
   video {
+    display: block;
     max-width: 100vw;
     max-height: 80vh;
     z-index: 1;
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index 28a4ce0ce..fe4f16353 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -776,6 +776,7 @@ a.status__display-name,
 }
 
 .status-card {
+  position: relative;
   display: flex;
   font-size: 14px;
   border: 1px solid lighten($ui-base-color, 8%);
@@ -874,11 +875,6 @@ a.status-card {
   flex: 1 1 auto;
   overflow: hidden;
   padding: 14px 14px 14px 8px;
-
-  &--blurred {
-    filter: blur(2px);
-    pointer-events: none;
-  }
 }
 
 .status-card__description {
diff --git a/app/javascript/flavours/glitch/styles/statuses.scss b/app/javascript/flavours/glitch/styles/statuses.scss
index 6fcc11e29..a71bb2552 100644
--- a/app/javascript/flavours/glitch/styles/statuses.scss
+++ b/app/javascript/flavours/glitch/styles/statuses.scss
@@ -136,6 +136,11 @@
 
   .detailed-status {
     padding: 15px;
+
+    .detailed-status__display-avatar .account__avatar {
+      width: 48px;
+      height: 48px;
+    }
   }
 
   .status {
@@ -196,7 +201,8 @@
       display: initial;
     }
 
-    .status__relative-time {
+    .status__relative-time,
+    .status__visibility-icon {
       color: $dark-text-color;
       float: right;
       font-size: 14px;
@@ -205,6 +211,11 @@
       padding: initial;
     }
 
+    .status__visibility-icon {
+      margin-left: 4px;
+      margin-right: 4px;
+    }
+
     .status__info .status__display-name {
       display: block;
       max-width: 100%;
@@ -238,7 +249,8 @@
         padding-right: 0;
       }
 
-      .status__relative-time {
+      .status__relative-time,
+      .status__visibility-icon {
         float: left;
       }
     }
diff --git a/app/javascript/mastodon/actions/account_notes.js b/app/javascript/mastodon/actions/account_notes.js
new file mode 100644
index 000000000..059ed9e80
--- /dev/null
+++ b/app/javascript/mastodon/actions/account_notes.js
@@ -0,0 +1,69 @@
+import api from '../api';
+
+export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
+export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
+export const ACCOUNT_NOTE_SUBMIT_FAIL    = 'ACCOUNT_NOTE_SUBMIT_FAIL';
+
+export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT';
+export const ACCOUNT_NOTE_CANCEL    = 'ACCOUNT_NOTE_CANCEL';
+
+export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT';
+
+export function submitAccountNote() {
+  return (dispatch, getState) => {
+    dispatch(submitAccountNoteRequest());
+
+    const id = getState().getIn(['account_notes', 'edit', 'account_id']);
+
+    api(getState).post(`/api/v1/accounts/${id}/note`, {
+      comment: getState().getIn(['account_notes', 'edit', 'comment']),
+    }).then(response => {
+      dispatch(submitAccountNoteSuccess(response.data));
+    }).catch(error => dispatch(submitAccountNoteFail(error)));
+  };
+};
+
+export function submitAccountNoteRequest() {
+  return {
+    type: ACCOUNT_NOTE_SUBMIT_REQUEST,
+  };
+};
+
+export function submitAccountNoteSuccess(relationship) {
+  return {
+    type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
+    relationship,
+  };
+};
+
+export function submitAccountNoteFail(error) {
+  return {
+    type: ACCOUNT_NOTE_SUBMIT_FAIL,
+    error,
+  };
+};
+
+export function initEditAccountNote(account) {
+  return (dispatch, getState) => {
+    const comment = getState().getIn(['relationships', account.get('id'), 'note']);
+
+    dispatch({
+      type: ACCOUNT_NOTE_INIT_EDIT,
+      account,
+      comment,
+    });
+  };
+};
+
+export function cancelAccountNote() {
+  return {
+    type: ACCOUNT_NOTE_CANCEL,
+  };
+};
+
+export function changeAccountNoteComment(comment) {
+  return {
+    type: ACCOUNT_NOTE_CHANGE_COMMENT,
+    comment,
+  };
+};
diff --git a/app/javascript/mastodon/components/__tests__/button-test.js b/app/javascript/mastodon/components/__tests__/button-test.js
index 160cd3cbc..f5a649f70 100644
--- a/app/javascript/mastodon/components/__tests__/button-test.js
+++ b/app/javascript/mastodon/components/__tests__/button-test.js
@@ -1,4 +1,4 @@
-import { shallow } from 'enzyme';
+import { render, fireEvent, screen } from '@testing-library/react';
 import React from 'react';
 import renderer from 'react-test-renderer';
 import Button from '../button';
@@ -21,16 +21,16 @@ describe('<Button />', () => {
 
   it('handles click events using the given handler', () => {
     const handler = jest.fn();
-    const button  = shallow(<Button onClick={handler} />);
-    button.find('button').simulate('click');
+    render(<Button onClick={handler}>button</Button>);
+    fireEvent.click(screen.getByText('button'));
 
     expect(handler.mock.calls.length).toEqual(1);
   });
 
   it('does not handle click events if props.disabled given', () => {
     const handler = jest.fn();
-    const button  = shallow(<Button onClick={handler} disabled />);
-    button.find('button').simulate('click');
+    render(<Button onClick={handler} disabled>button</Button>);
+    fireEvent.click(screen.getByText('button'));
 
     expect(handler.mock.calls.length).toEqual(0);
   });
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index a31de206b..0ec866138 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -8,10 +8,10 @@ import { isIOS } from '../is_mobile';
 import classNames from 'classnames';
 import { autoPlayGif, cropImages, displayMedia, useBlurhash } from '../initial_state';
 import { decode } from 'blurhash';
+import { debounce } from 'lodash';
 
 const messages = defineMessages({
-  toggle_visible: { id: 'media_gallery.toggle_visible',
-    defaultMessage: 'Hide {number, plural, one {image} other {images}}' },
+  toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide {number, plural, one {image} other {images}}' },
 });
 
 class Item extends React.PureComponent {
@@ -267,6 +267,14 @@ class MediaGallery extends React.PureComponent {
     width: this.props.defaultWidth,
   };
 
+  componentDidMount () {
+    window.addEventListener('resize', this.handleResize, { passive: true });
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('resize', this.handleResize);
+  }
+
   componentWillReceiveProps (nextProps) {
     if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
       this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
@@ -275,6 +283,14 @@ class MediaGallery extends React.PureComponent {
     }
   }
 
+  handleResize = debounce(() => {
+    if (this.node) {
+      this._setDimensions();
+    }
+  }, 250, {
+    trailing: true,
+  });
+
   handleOpen = () => {
     if (this.props.onToggleVisibility) {
       this.props.onToggleVisibility();
@@ -287,15 +303,25 @@ class MediaGallery extends React.PureComponent {
     this.props.onOpenMedia(this.props.media, index);
   }
 
-  handleRef = (node) => {
-    if (node) {
-      // offsetWidth triggers a layout, so only calculate when we need to
-      if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
+  handleRef = c => {
+    this.node = c;
+
+    if (this.node) {
+      this._setDimensions();
+    }
+  }
+
+  _setDimensions () {
+    const width = this.node.offsetWidth;
 
-      this.setState({
-        width: node.offsetWidth,
-      });
+    // offsetWidth triggers a layout, so only calculate when we need to
+    if (this.props.cacheWidth) {
+      this.props.cacheWidth(width);
     }
+
+    this.setState({
+      width: width,
+    });
   }
 
   isFullSizeEligible() {
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index f99ccd39a..827b69500 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -10,7 +10,7 @@ import StatusContent from './status_content';
 import StatusActionBar from './status_action_bar';
 import AttachmentList from './attachment_list';
 import Card from '../features/status/components/card';
-import { injectIntl, FormattedMessage } from 'react-intl';
+import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
 import { HotKeys } from 'react-hotkeys';
@@ -51,6 +51,13 @@ export const defaultMediaVisibility = (status) => {
   return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
 };
 
+const messages = defineMessages({
+  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+});
+
 export default @injectIntl
 class Status extends ImmutablePureComponent {
 
@@ -345,9 +352,12 @@ class Status extends ImmutablePureComponent {
               <Component
                 src={attachment.get('url')}
                 alt={attachment.get('description')}
+                poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
+                blurhash={attachment.get('blurhash')}
                 duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
-                peaks={[0]}
-                height={70}
+                width={this.props.cachedMediaWidth}
+                height={110}
+                cacheWidth={this.props.cacheMediaWidth}
               />
             )}
           </Bundle>
@@ -414,6 +424,15 @@ class Status extends ImmutablePureComponent {
       statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
     }
 
+    const visibilityIconInfo = {
+      'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
+      'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
+      'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
+      'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) },
+    };
+
+    const visibilityIcon = visibilityIconInfo[status.get('visibility')];
+
     return (
       <HotKeys handlers={handlers}>
         <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
@@ -423,6 +442,7 @@ class Status extends ImmutablePureComponent {
             <div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
             <div className='status__info'>
               <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+              <span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
 
               <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
                 <div className='status__avatar'>
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index bebbbcb5a..a4aa27088 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -237,9 +237,6 @@ class StatusActionBar extends ImmutablePureComponent {
     const account            = status.get('account');
 
     let menu = [];
-    let reblogIcon = 'retweet';
-    let replyIcon;
-    let replyTitle;
 
     menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
 
@@ -259,10 +256,6 @@ class StatusActionBar extends ImmutablePureComponent {
     if (status.getIn(['account', 'id']) === me) {
       if (publicStatus) {
         menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
-      } else {
-        if (status.get('visibility') === 'private') {
-          menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
-        }
       }
 
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
@@ -305,12 +298,8 @@ class StatusActionBar extends ImmutablePureComponent {
       }
     }
 
-    if (status.get('visibility') === 'direct') {
-      reblogIcon = 'envelope';
-    } else if (status.get('visibility') === 'private') {
-      reblogIcon = 'lock';
-    }
-
+    let replyIcon;
+    let replyTitle;
     if (status.get('in_reply_to_id', null) === null) {
       replyIcon = 'reply';
       replyTitle = intl.formatMessage(messages.reply);
@@ -319,6 +308,19 @@ class StatusActionBar extends ImmutablePureComponent {
       replyTitle = intl.formatMessage(messages.replyAll);
     }
 
+    const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
+
+    let reblogTitle = '';
+    if (status.get('reblogged')) {
+      reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
+    } else if (publicStatus) {
+      reblogTitle = intl.formatMessage(messages.reblog);
+    } else if (reblogPrivate) {
+      reblogTitle = intl.formatMessage(messages.reblog_private);
+    } else {
+      reblogTitle = intl.formatMessage(messages.cannot_reblog);
+    }
+
     const shareButton = ('share' in navigator) && publicStatus && (
       <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
     );
@@ -326,7 +328,7 @@ class StatusActionBar extends ImmutablePureComponent {
     return (
       <div className='status__action-bar'>
         <div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
-        <IconButton className='status__action-bar-button' disabled={!publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
+        <IconButton className='status__action-bar-button' disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
         <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
         {shareButton}
 
diff --git a/app/javascript/mastodon/features/account/components/account_note.js b/app/javascript/mastodon/features/account/components/account_note.js
new file mode 100644
index 000000000..832a96a6a
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/account_note.js
@@ -0,0 +1,103 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Icon from 'mastodon/components/icon';
+import Textarea from 'react-textarea-autosize';
+
+const messages = defineMessages({
+  placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' },
+});
+
+export default @injectIntl
+class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    isEditing: PropTypes.bool,
+    isSubmitting: PropTypes.bool,
+    accountNote: PropTypes.string,
+    onEditAccountNote: PropTypes.func.isRequired,
+    onCancelAccountNote: PropTypes.func.isRequired,
+    onSaveAccountNote: PropTypes.func.isRequired,
+    onChangeAccountNote: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleChangeAccountNote = (e) => {
+    this.props.onChangeAccountNote(e.target.value);
+  };
+
+  componentWillUnmount () {
+    if (this.props.isEditing) {
+      this.props.onCancelAccountNote();
+    }
+  }
+
+  handleKeyDown = e => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      this.props.onSaveAccountNote();
+    } else if (e.keyCode === 27) {
+      this.props.onCancelAccountNote();
+    }
+  }
+
+  render () {
+    const { account, accountNote, isEditing, isSubmitting, intl } = this.props;
+
+    if (!account || (!accountNote && !isEditing)) {
+      return null;
+    }
+
+    let action_buttons = null;
+    if (isEditing) {
+      action_buttons = (
+        <div className='account__header__account-note__buttons'>
+          <button className='text-btn' tabIndex='0' onClick={this.props.onCancelAccountNote} disabled={isSubmitting}>
+            <Icon id='times' size={15} /> <FormattedMessage id='account_note.cancel' defaultMessage='Cancel' />
+          </button>
+          <div className='flex-spacer' />
+          <button className='text-btn' tabIndex='0' onClick={this.props.onSaveAccountNote} disabled={isSubmitting}>
+            <Icon id='check' size={15} /> <FormattedMessage id='account_note.save' defaultMessage='Save' />
+          </button>
+        </div>
+      );
+    }
+
+    let note_container = null;
+    if (isEditing) {
+      note_container = (
+        <Textarea
+          className='account__header__account-note__content'
+          disabled={isSubmitting}
+          placeholder={intl.formatMessage(messages.placeholder)}
+          value={accountNote}
+          onChange={this.handleChangeAccountNote}
+          onKeyDown={this.handleKeyDown}
+          autoFocus
+        />
+      );
+    } else {
+      note_container = (<div className='account__header__account-note__content'>{accountNote}</div>);
+    }
+
+    return (
+      <div className='account__header__account-note'>
+        <div className='account__header__account-note__header'>
+          <strong><FormattedMessage id='account.account_note_header' defaultMessage='Your note for @{name}' values={{ name: account.get('username') }} /></strong>
+          {!isEditing && (
+            <div>
+              <button className='text-btn' tabIndex='0' onClick={this.props.onEditAccountNote} disabled={isSubmitting}>
+                <Icon id='pencil' size={15} /> <FormattedMessage id='account_note.edit' defaultMessage='Edit' />
+              </button>
+            </div>
+          )}
+        </div>
+        {note_container}
+        {action_buttons}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 8c85bbc39..eca0b7901 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -11,6 +11,7 @@ import Avatar from 'mastodon/components/avatar';
 import { shortNumberFormat } from 'mastodon/utils/numbers';
 import { NavLink } from 'react-router-dom';
 import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
+import AccountNoteContainer from '../containers/account_note_container';
 
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@@ -45,6 +46,7 @@ const messages = defineMessages({
   unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
   add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
   admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
+  add_account_note: { id: 'account.add_account_note', defaultMessage: 'Add note for @{name}' },
 });
 
 const dateFormatOptions = {
@@ -64,6 +66,7 @@ class Header extends ImmutablePureComponent {
     identity_props: ImmutablePropTypes.list,
     onFollow: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
+    onEditAccountNote: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
     domain: PropTypes.string.isRequired,
   };
@@ -128,6 +131,8 @@ class Header extends ImmutablePureComponent {
       return null;
     }
 
+    const accountNote = account.getIn(['relationship', 'note']);
+
     let info        = [];
     let actionBtn   = '';
     let lockedIcon  = '';
@@ -178,6 +183,10 @@ class Header extends ImmutablePureComponent {
       menu.push(null);
     }
 
+    if (accountNote === null) {
+      menu.push({ text: intl.formatMessage(messages.add_account_note, { name: account.get('username') }), action: this.props.onEditAccountNote });
+    }
+
     if (account.get('id') === me) {
       menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
       menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
@@ -284,6 +293,8 @@ class Header extends ImmutablePureComponent {
             </h1>
           </div>
 
+          <AccountNoteContainer account={account} />
+
           <div className='account__header__extra'>
             <div className='account__header__bio'>
               { (fields.size > 0 || identity_proofs.size > 0) && (
diff --git a/app/javascript/mastodon/features/account/containers/account_note_container.js b/app/javascript/mastodon/features/account/containers/account_note_container.js
new file mode 100644
index 000000000..92d470982
--- /dev/null
+++ b/app/javascript/mastodon/features/account/containers/account_note_container.js
@@ -0,0 +1,34 @@
+import { connect } from 'react-redux';
+import { changeAccountNoteComment, submitAccountNote, initEditAccountNote, cancelAccountNote } from 'mastodon/actions/account_notes';
+import AccountNote from '../components/account_note';
+
+const mapStateToProps = (state, { account }) => {
+  const isEditing = state.getIn(['account_notes', 'edit', 'account_id']) === account.get('id');
+
+  return {
+    isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']),
+    accountNote: isEditing ? state.getIn(['account_notes', 'edit', 'comment']) : account.getIn(['relationship', 'note']),
+    isEditing,
+  };
+};
+
+const mapDispatchToProps = (dispatch, { account }) => ({
+
+  onEditAccountNote() {
+    dispatch(initEditAccountNote(account));
+  },
+
+  onSaveAccountNote() {
+    dispatch(submitAccountNote());
+  },
+
+  onCancelAccountNote() {
+    dispatch(cancelAccountNote());
+  },
+
+  onChangeAccountNote(comment) {
+    dispatch(changeAccountNoteComment(comment));
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index 844b8a236..4e1b27466 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -23,6 +23,7 @@ export default class Header extends ImmutablePureComponent {
     onUnblockDomain: PropTypes.func.isRequired,
     onEndorseToggle: PropTypes.func.isRequired,
     onAddToList: PropTypes.func.isRequired,
+    onEditAccountNote: PropTypes.func.isRequired,
     hideTabs: PropTypes.bool,
     domain: PropTypes.string.isRequired,
   };
@@ -83,6 +84,10 @@ export default class Header extends ImmutablePureComponent {
     this.props.onAddToList(this.props.account);
   }
 
+  handleEditAccountNote = () => {
+    this.props.onEditAccountNote(this.props.account);
+  }
+
   render () {
     const { account, hideTabs, identity_proofs } = this.props;
 
@@ -108,6 +113,7 @@ export default class Header extends ImmutablePureComponent {
           onUnblockDomain={this.handleUnblockDomain}
           onEndorseToggle={this.handleEndorseToggle}
           onAddToList={this.handleAddToList}
+          onEditAccountNote={this.handleEditAccountNote}
           domain={this.props.domain}
         />
 
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
index 8728b4806..e480fb2aa 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -19,6 +19,7 @@ import { initBlockModal } from '../../../actions/blocks';
 import { initReport } from '../../../actions/reports';
 import { openModal } from '../../../actions/modal';
 import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
+import { initEditAccountNote } from 'mastodon/actions/account_notes';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { unfollowModal } from '../../../initial_state';
 import { List as ImmutableList } from 'immutable';
@@ -102,6 +103,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onEditAccountNote (account) {
+    dispatch(initEditAccountNote(account));
+  },
+
   onBlockDomain (domain) {
     dispatch(openModal('CONFIRM', {
       message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js
index baad1c0e5..99926e52a 100644
--- a/app/javascript/mastodon/features/audio/index.js
+++ b/app/javascript/mastodon/features/audio/index.js
@@ -1,11 +1,136 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import WaveSurfer from 'wavesurfer.js';
 import { defineMessages, injectIntl } from 'react-intl';
 import { formatTime } from 'mastodon/features/video';
 import Icon from 'mastodon/components/icon';
 import classNames from 'classnames';
 import { throttle } from 'lodash';
+import { encode, decode } from 'blurhash';
+import { getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
+import { debounce } from 'lodash';
+
+const digitCharacters = [
+  '0',
+  '1',
+  '2',
+  '3',
+  '4',
+  '5',
+  '6',
+  '7',
+  '8',
+  '9',
+  'A',
+  'B',
+  'C',
+  'D',
+  'E',
+  'F',
+  'G',
+  'H',
+  'I',
+  'J',
+  'K',
+  'L',
+  'M',
+  'N',
+  'O',
+  'P',
+  'Q',
+  'R',
+  'S',
+  'T',
+  'U',
+  'V',
+  'W',
+  'X',
+  'Y',
+  'Z',
+  'a',
+  'b',
+  'c',
+  'd',
+  'e',
+  'f',
+  'g',
+  'h',
+  'i',
+  'j',
+  'k',
+  'l',
+  'm',
+  'n',
+  'o',
+  'p',
+  'q',
+  'r',
+  's',
+  't',
+  'u',
+  'v',
+  'w',
+  'x',
+  'y',
+  'z',
+  '#',
+  '$',
+  '%',
+  '*',
+  '+',
+  ',',
+  '-',
+  '.',
+  ':',
+  ';',
+  '=',
+  '?',
+  '@',
+  '[',
+  ']',
+  '^',
+  '_',
+  '{',
+  '|',
+  '}',
+  '~',
+];
+
+const decode83 = (str) => {
+  let value = 0;
+  let c, digit;
+
+  for (let i = 0; i < str.length; i++) {
+    c = str[i];
+    digit = digitCharacters.indexOf(c);
+    value = value * 83 + digit;
+  }
+
+  return value;
+};
+
+const decodeRGB = int => ({
+  r: Math.max(0, (int >> 16)),
+  g: Math.max(0, (int >> 8) & 255),
+  b: Math.max(0, (int & 255)),
+});
+
+const luma = ({ r, g, b }) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
+
+const adjustColor = ({ r, g, b }, lumaThreshold = 100) => {
+  let delta;
+
+  if (luma({ r, g, b }) >= lumaThreshold) {
+    delta = -80;
+  } else {
+    delta = 80;
+  }
+
+  return {
+    r: r + delta,
+    g: g + delta,
+    b: b + delta,
+  };
+};
 
 const messages = defineMessages({
   play: { id: 'video.play', defaultMessage: 'Play' },
@@ -15,132 +140,182 @@ const messages = defineMessages({
   download: { id: 'video.download', defaultMessage: 'Download file' },
 });
 
+const TICK_SIZE = 10;
+const PADDING   = 180;
+
 export default @injectIntl
 class Audio extends React.PureComponent {
 
   static propTypes = {
     src: PropTypes.string.isRequired,
     alt: PropTypes.string,
+    poster: PropTypes.string,
     duration: PropTypes.number,
-    peaks: PropTypes.arrayOf(PropTypes.number),
+    width: PropTypes.number,
     height: PropTypes.number,
-    preload: PropTypes.bool,
     editable: PropTypes.bool,
+    fullscreen: PropTypes.bool,
     intl: PropTypes.object.isRequired,
+    cacheWidth: PropTypes.func,
+    blurhash: PropTypes.string,
   };
 
   state = {
+    width: this.props.width,
     currentTime: 0,
+    buffer: 0,
     duration: null,
     paused: true,
     muted: false,
     volume: 0.5,
+    dragging: false,
+    color: { r: 255, g: 255, b: 255 },
   };
 
-  // Hard coded in components.scss
-  // Any way to get ::before values programatically?
-  volWidth  = 50;
-  volOffset = 70;
+  setPlayerRef = c => {
+    this.player = c;
 
-  volHandleOffset = v => {
-    const offset = v * this.volWidth + this.volOffset;
+    if (this.player) {
+      this._setDimensions();
+    }
+  }
+
+  _setDimensions () {
+    const width  = this.player.offsetWidth;
+    const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
+
+    if (this.props.cacheWidth) {
+      this.props.cacheWidth(width);
+    }
 
-    return (offset > 110) ? 110 : offset;
+    this.setState({ width, height });
+  }
+
+  setSeekRef = c => {
+    this.seek = c;
   }
 
   setVolumeRef = c => {
     this.volume = c;
   }
 
-  setWaveformRef = c => {
-    this.waveform = c;
+  setAudioRef = c => {
+    this.audio = c;
+
+    if (this.audio) {
+      this.setState({ volume: this.audio.volume, muted: this.audio.muted });
+    }
   }
 
-  componentDidMount () {
-    if (this.waveform) {
-      this._updateWaveform();
+  setBlurhashCanvasRef = c => {
+    this.blurhashCanvas = c;
+  }
+
+  setCanvasRef = c => {
+    this.canvas = c;
+
+    if (c) {
+      this.canvasContext = c.getContext('2d');
     }
+  }
 
+  componentDidMount () {
     window.addEventListener('scroll', this.handleScroll);
+    window.addEventListener('resize', this.handleResize, { passive: true });
+
+    if (!this.props.blurhash) {
+      const img = new Image();
+      img.crossOrigin = 'anonymous';
+      img.onload = () => this.handlePosterLoad(img);
+      img.src = this.props.poster;
+    } else {
+      this._setColorScheme();
+      this._decodeBlurhash();
+    }
   }
 
-  componentDidUpdate (prevProps) {
-    if (this.waveform && prevProps.src !== this.props.src) {
-      this._updateWaveform();
+  componentDidUpdate (prevProps, prevState) {
+    if (prevProps.poster !== this.props.poster && !this.props.blurhash) {
+      const img = new Image();
+      img.crossOrigin = 'anonymous';
+      img.onload = () => this.handlePosterLoad(img);
+      img.src = this.props.poster;
     }
+
+    if (prevState.blurhash !== this.state.blurhash || prevProps.blurhash !== this.props.blurhash) {
+      this._setColorScheme();
+      this._decodeBlurhash();
+    }
+
+    this._clear();
+    this._draw();
+  }
+
+  _decodeBlurhash () {
+    const context = this.blurhashCanvas.getContext('2d');
+    const pixels = decode(this.props.blurhash || this.state.blurhash, 32, 32);
+    const outputImageData = new ImageData(pixels, 32, 32);
+
+    context.putImageData(outputImageData, 0, 0);
   }
 
   componentWillUnmount () {
     window.removeEventListener('scroll', this.handleScroll);
+    window.removeEventListener('resize', this.handleResize);
+  }
 
-    if (this.wavesurfer) {
-      this.wavesurfer.destroy();
-      this.wavesurfer = null;
+  togglePlay = () => {
+    if (this.state.paused) {
+      this.setState({ paused: false }, () => this.audio.play());
+    } else {
+      this.setState({ paused: true }, () => this.audio.pause());
     }
   }
 
-  _updateWaveform () {
-    const { src, height, duration, peaks, preload } = this.props;
-
-    const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color');
-    const waveColor     = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color');
-
-    if (this.wavesurfer) {
-      this.wavesurfer.destroy();
-      this.loaded = false;
+  handleResize = debounce(() => {
+    if (this.player) {
+      this._setDimensions();
     }
+  }, 250, {
+    trailing: true,
+  });
 
-    const wavesurfer = WaveSurfer.create({
-      container: this.waveform,
-      height,
-      barWidth: 3,
-      cursorWidth: 0,
-      progressColor,
-      waveColor,
-      backend: 'MediaElement',
-      interact: preload,
-    });
+  handlePlay = () => {
+    this.setState({ paused: false });
 
-    wavesurfer.setVolume(this.state.volume);
+    if (this.canvas && !this.audioContext) {
+      this._initAudioContext();
+    }
 
-    if (preload) {
-      wavesurfer.load(src);
-      this.loaded = true;
-    } else {
-      wavesurfer.load(src, peaks, 'none', duration);
-      this.loaded = false;
+    if (this.audioContext && this.audioContext.state === 'suspended') {
+      this.audioContext.resume();
     }
 
-    wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) }));
-    wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) }));
-    wavesurfer.on('pause', () => this.setState({ paused: true }));
-    wavesurfer.on('play', () => this.setState({ paused: false }));
-    wavesurfer.on('volume', volume => this.setState({ volume }));
-    wavesurfer.on('mute', muted => this.setState({ muted }));
+    this._renderCanvas();
+  }
+
+  handlePause = () => {
+    this.setState({ paused: true });
 
-    this.wavesurfer = wavesurfer;
+    if (this.audioContext) {
+      this.audioContext.suspend();
+    }
   }
 
-  togglePlay = () => {
-    if (this.state.paused) {
-      if (!this.props.preload && !this.loaded) {
-        this.wavesurfer.createBackend();
-        this.wavesurfer.createPeakCache();
-        this.wavesurfer.load(this.props.src);
-        this.wavesurfer.toggleInteraction();
-        this.wavesurfer.setVolume(this.state.volume);
-        this.loaded = true;
-      }
+  handleProgress = () => {
+    const lastTimeRange = this.audio.buffered.length - 1;
 
-      this.setState({ paused: false }, () => this.wavesurfer.play());
-    } else {
-      this.setState({ paused: true }, () => this.wavesurfer.pause());
+    if (lastTimeRange > -1) {
+      this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
     }
   }
 
   toggleMute = () => {
     const muted = !this.state.muted;
-    this.setState({ muted }, () => this.wavesurfer.setMute(muted));
+
+    this.setState({ muted }, () => {
+      this.audio.muted = muted;
+    });
   }
 
   handleVolumeMouseDown = e => {
@@ -162,86 +337,387 @@ class Audio extends React.PureComponent {
     document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
   }
 
-  handleMouseVolSlide = throttle(e => {
-    const rect = this.volume.getBoundingClientRect();
-    const x    = (e.clientX - rect.left) / this.volWidth; // x position within the element.
+  handleMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseMove, true);
+    document.addEventListener('mouseup', this.handleMouseUp, true);
+    document.addEventListener('touchmove', this.handleMouseMove, true);
+    document.addEventListener('touchend', this.handleMouseUp, true);
 
-    if(!isNaN(x)) {
-      let slideamt = x;
+    this.setState({ dragging: true });
+    this.audio.pause();
+    this.handleMouseMove(e);
 
-      if (x > 1) {
-        slideamt = 1;
-      } else if(x < 0) {
-        slideamt = 0;
-      }
+    e.preventDefault();
+    e.stopPropagation();
+  }
+
+  handleMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseMove, true);
+    document.removeEventListener('mouseup', this.handleMouseUp, true);
+    document.removeEventListener('touchmove', this.handleMouseMove, true);
+    document.removeEventListener('touchend', this.handleMouseUp, true);
+
+    this.setState({ dragging: false });
+    this.audio.play();
+  }
+
+  handleMouseMove = throttle(e => {
+    const { x } = getPointerPosition(this.seek, e);
+    const currentTime = this.audio.duration * x;
 
-      this.wavesurfer.setVolume(slideamt);
+    if (!isNaN(currentTime)) {
+      this.setState({ currentTime }, () => {
+        this.audio.currentTime = currentTime;
+      });
     }
-  }, 60);
+  }, 15);
+
+  handleTimeUpdate = () => {
+    this.setState({
+      currentTime: this.audio.currentTime,
+      duration: Math.floor(this.audio.duration),
+    });
+  }
+
+  handleMouseVolSlide = throttle(e => {
+    const { x } = getPointerPosition(this.volume, e);
+
+    if(!isNaN(x)) {
+      this.setState({ volume: x }, () => {
+        this.audio.volume = x;
+      });
+    }
+  }, 15);
 
   handleScroll = throttle(() => {
-    if (!this.waveform || !this.wavesurfer) {
+    if (!this.canvas || !this.audio) {
       return;
     }
 
-    const { top, height } = this.waveform.getBoundingClientRect();
+    const { top, height } = this.canvas.getBoundingClientRect();
     const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
 
     if (!this.state.paused && !inView) {
-      this.setState({ paused: true }, () => this.wavesurfer.pause());
+      this.setState({ paused: true }, () => this.audio.pause());
     }
-  }, 150, { trailing: true })
+  }, 150, { trailing: true });
 
-  render () {
-    const { height, intl, alt, editable } = this.props;
-    const { paused, muted, volume, currentTime } = this.state;
+  handleMouseEnter = () => {
+    this.setState({ hovered: true });
+  }
+
+  handleMouseLeave = () => {
+    this.setState({ hovered: false });
+  }
+
+  _initAudioContext () {
+    const context  = new AudioContext();
+    const analyser = context.createAnalyser();
+    const source   = context.createMediaElementSource(this.audio);
+
+    analyser.smoothingTimeConstant = 0.6;
+    analyser.fftSize = 2048;
+
+    source.connect(analyser);
+    source.connect(context.destination);
+
+    this.audioContext = context;
+    this.analyser = analyser;
+  }
+
+  handlePosterLoad = image => {
+    const canvas  = document.createElement('canvas');
+    const context = canvas.getContext('2d');
+
+    canvas.width  = image.width;
+    canvas.height = image.height;
+
+    context.drawImage(image, 0, 0);
+
+    const inputImageData = context.getImageData(0, 0, image.width, image.height);
+    const blurhash = encode(inputImageData.data, image.width, image.height, 4, 4);
 
-    const volumeWidth     = muted ? 0 : volume * this.volWidth;
-    const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume);
+    this.setState({ blurhash });
+  }
+
+  _setColorScheme () {
+    const blurhash     = this.props.blurhash || this.state.blurhash;
+    const averageColor = decodeRGB(decode83(blurhash.slice(2, 6)));
+
+    this.setState({
+      color: adjustColor(averageColor),
+      darkText: luma(averageColor) >= 165,
+    });
+  }
+
+  handleDownload = () => {
+    fetch(this.props.src).then(res => res.blob()).then(blob => {
+      const element   = document.createElement('a');
+      const objectURL = URL.createObjectURL(blob);
+
+      element.setAttribute('href', objectURL);
+      element.setAttribute('download', fileNameFromURL(this.props.src));
+
+      document.body.appendChild(element);
+      element.click();
+      document.body.removeChild(element);
+
+      URL.revokeObjectURL(objectURL);
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  _renderCanvas () {
+    requestAnimationFrame(() => {
+      this.handleTimeUpdate();
+      this._clear();
+      this._draw();
+
+      if (!this.state.paused) {
+        this._renderCanvas();
+      }
+    });
+  }
+
+  _clear () {
+    this.canvasContext.clearRect(0, 0, this.state.width, this.state.height);
+  }
+
+  _draw () {
+    this.canvasContext.save();
+
+    const ticks = this._getTicks(360 * this._getScaleCoefficient(), TICK_SIZE);
+
+    ticks.forEach(tick => {
+      this._drawTick(tick.x1, tick.y1, tick.x2, tick.y2);
+    });
+
+    this.canvasContext.restore();
+  }
+
+  _getRadius () {
+    return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
+  }
+
+  _getScaleCoefficient () {
+    return (this.state.height || this.props.height) / 982;
+  }
+
+  _getTicks (count, size, animationParams = [0, 90]) {
+    const radius = this._getRadius();
+    const ticks = this._getTickPoints(count);
+    const lesser = 200;
+    const m = [];
+    const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0;
+    const frequencyData = new Uint8Array(bufferLength);
+    const allScales = [];
+    const scaleCoefficient = this._getScaleCoefficient();
+
+    if (this.analyser) {
+      this.analyser.getByteFrequencyData(frequencyData);
+    }
+
+    ticks.forEach((tick, i) => {
+      const coef = 1 - i / (ticks.length * 2.5);
+
+      let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient;
+
+      if (delta < 0) {
+        delta = 0;
+      }
+
+      let k;
+
+      if (animationParams[0] <= tick.angle && tick.angle <= animationParams[1]) {
+        k = radius / (radius - this._getSize(tick.angle, animationParams[0], animationParams[1]) - delta);
+      } else {
+        k = radius / (radius - (size + delta));
+      }
+
+      const x1 = tick.x * (radius - size);
+      const y1 = tick.y * (radius - size);
+      const x2 = x1 * k;
+      const y2 = y1 * k;
+
+      m.push({ x1, y1, x2, y2 });
+
+      if (i < 20) {
+        let scale = delta / (200 * scaleCoefficient);
+        scale = scale < 1 ? 1 : scale;
+        allScales.push(scale);
+      }
+    });
+
+    const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length;
+
+    return m.map(({ x1, y1, x2, y2 }) => ({
+      x1: x1,
+      y1: y1,
+      x2: x2 * scale,
+      y2: y2 * scale,
+    }));
+  }
+
+  _getSize (angle, l, r) {
+    const scaleCoefficient = this._getScaleCoefficient();
+    const maxTickSize = TICK_SIZE * 9 * scaleCoefficient;
+    const m = (r - l) / 2;
+    const x = (angle - l);
+
+    let h;
+
+    if (x === m) {
+      return maxTickSize;
+    }
+
+    const d = Math.abs(m - x);
+    const v = 40 * Math.sqrt(1 / d);
+
+    if (v > maxTickSize) {
+      h = maxTickSize;
+    } else {
+      h = Math.max(TICK_SIZE, v);
+    }
+
+    return h;
+  }
+
+  _getTickPoints (count) {
+    const PI = 360;
+    const coords = [];
+    const step = PI / count;
+
+    let rad;
+
+    for(let deg = 0; deg < PI; deg += step) {
+      rad = deg * Math.PI / (PI / 2);
+      coords.push({ x: Math.cos(rad), y: -Math.sin(rad), angle: deg });
+    }
+
+    return coords;
+  }
+
+  _drawTick (x1, y1, x2, y2) {
+    const cx = this._getCX();
+    const cy = this._getCY();
+
+    const dx1 = Math.ceil(cx + x1);
+    const dy1 = Math.ceil(cy + y1);
+    const dx2 = Math.ceil(cx + x2);
+    const dy2 = Math.ceil(cy + y2);
+
+    const gradient = this.canvasContext.createLinearGradient(dx1, dy1, dx2, dy2);
+
+    const mainColor = `rgb(${this.state.color.r}, ${this.state.color.g}, ${this.state.color.b})`;
+    const lastColor = `rgba(${this.state.color.r}, ${this.state.color.g}, ${this.state.color.b}, 0)`;
+
+    gradient.addColorStop(0, mainColor);
+    gradient.addColorStop(0.6, mainColor);
+    gradient.addColorStop(1, lastColor);
+
+    this.canvasContext.beginPath();
+    this.canvasContext.strokeStyle = gradient;
+    this.canvasContext.lineWidth = 2;
+    this.canvasContext.moveTo(dx1, dy1);
+    this.canvasContext.lineTo(dx2, dy2);
+    this.canvasContext.stroke();
+  }
+
+  _getCX() {
+    return Math.floor(this.state.width / 2);
+  }
+
+  _getCY() {
+    return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
+  }
+
+  _getColor () {
+    return `rgb(${this.state.color.r}, ${this.state.color.g}, ${this.state.color.b})`;
+  }
+
+  render () {
+    const { src, intl, alt, editable } = this.props;
+    const { paused, muted, volume, currentTime, duration, buffer, darkText, dragging } = this.state;
+    const progress = (currentTime / duration) * 100;
 
     return (
-      <div className={classNames('audio-player', { editable })}>
-        <div className='audio-player__progress-placeholder' style={{ display: 'none' }} />
-        <div className='audio-player__wave-placeholder' style={{ display: 'none' }} />
+      <div className={classNames('audio-player', { editable, 'with-light-background': darkText })} ref={this.setPlayerRef} style={{ width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        <audio
+          src={src}
+          ref={this.setAudioRef}
+          preload='none'
+          onPlay={this.handlePlay}
+          onPause={this.handlePause}
+          onProgress={this.handleProgress}
+          crossOrigin='anonymous'
+        />
 
-        <div
-          className='audio-player__waveform'
+        <canvas
+          className='audio-player__background'
+          onClick={this.togglePlay}
+          width='32'
+          height='32'
+          style={{ width: this.state.width, height: this.state.height, position: 'absolute', top: 0, left: 0 }}
+          ref={this.setBlurhashCanvasRef}
           aria-label={alt}
           title={alt}
-          style={{ height }}
-          ref={this.setWaveformRef}
+          role='button'
+          tabIndex='0'
+        />
+
+        <canvas
+          className='audio-player__canvas'
+          width={this.state.width}
+          height={this.state.height}
+          style={{ width: '100%', position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}
+          ref={this.setCanvasRef}
         />
 
+        <img
+          src={this.props.poster}
+          alt=''
+          width={(this._getRadius() - TICK_SIZE) * 2}
+          height={(this._getRadius() - TICK_SIZE) * 2}
+          style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
+        />
+
+        <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
+          <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
+          <div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getColor() }} />
+
+          <span
+            className={classNames('video-player__seek__handle', { active: dragging })}
+            tabIndex='0'
+            style={{ left: `${progress}%`, backgroundColor: this._getColor() }}
+          />
+        </div>
+
         <div className='video-player__controls active'>
           <div className='video-player__buttons-bar'>
             <div className='video-player__buttons left'>
               <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
               <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
 
-              <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
-                &nbsp;
-                <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
+              <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
+                <div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getColor() }} />
 
                 <span
                   className={classNames('video-player__volume__handle')}
                   tabIndex='0'
-                  style={{ left: `${volumeHandleLoc}px` }}
+                  style={{ left: `${volume * 100}%`, backgroundColor: this._getColor() }}
                 />
               </div>
 
-              <span>
-                <span className='video-player__time-current'>{formatTime(currentTime)}</span>
+              <span className='video-player__time'>
+                <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
                 <span className='video-player__time-sep'>/</span>
                 <span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
               </span>
             </div>
 
             <div className='video-player__buttons right'>
-              <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)}>
-                <a className='video-player__download__icon' href={this.props.src} download>
-                  <Icon id={'download'} fixedWidth />
-                </a>
-              </button>
+              <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} onClick={this.handleDownload}><Icon id='download' fixedWidth /></button>
             </div>
           </div>
         </div>
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
index a6186010b..360a7af6a 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -203,7 +203,7 @@ class EmojiPickerMenu extends React.PureComponent {
     if (!emoji.native) {
       emoji.native = emoji.colons;
     }
-    if (!event.ctrlKey) {
+    if (!(event.ctrlKey || event.metaKey)) {
       this.props.onClose();
     }
     this.props.onPick(emoji);
diff --git a/app/javascript/mastodon/features/compose/components/upload_button.js b/app/javascript/mastodon/features/compose/components/upload_button.js
index d550019f4..9cb36167a 100644
--- a/app/javascript/mastodon/features/compose/components/upload_button.js
+++ b/app/javascript/mastodon/features/compose/components/upload_button.js
@@ -7,11 +7,9 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 
 const messages = defineMessages({
-  upload: { id: 'upload_button.label', defaultMessage: 'Add media ({formats})' },
+  upload: { id: 'upload_button.label', defaultMessage: 'Add images, a video or an audio file' },
 });
 
-const SUPPORTED_FORMATS = 'JPEG, PNG, GIF, WebM, MP4, MOV, OGG, WAV, MP3, FLAC';
-
 const makeMapStateToProps = () => {
   const mapStateToProps = state => ({
     acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
@@ -60,11 +58,13 @@ class UploadButton extends ImmutablePureComponent {
       return null;
     }
 
+    const message = intl.formatMessage(messages.upload);
+
     return (
       <div className='compose-form__upload-button'>
-        <IconButton icon='paperclip' title={intl.formatMessage(messages.upload, { formats: SUPPORTED_FORMATS })} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
+        <IconButton icon='paperclip' title={message} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
         <label>
-          <span style={{ display: 'none' }}>{intl.formatMessage(messages.upload, { formats: SUPPORTED_FORMATS })}</span>
+          <span style={{ display: 'none' }}>{message}</span>
           <input
             key={resetFileKey}
             ref={this.setRef}
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index ba62d7b10..1c5d5ca0c 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -201,10 +201,6 @@ class ActionBar extends React.PureComponent {
     if (me === status.getIn(['account', 'id'])) {
       if (publicStatus) {
         menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
-      } else {
-        if (status.get('visibility') === 'private') {
-          menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
-        }
       }
 
       menu.push(null);
@@ -261,14 +257,23 @@ class ActionBar extends React.PureComponent {
       replyIcon = 'reply-all';
     }
 
-    let reblogIcon = 'retweet';
-    if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
-    else if (status.get('visibility') === 'private') reblogIcon = 'lock';
+    const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
+
+    let reblogTitle;
+    if (status.get('reblogged')) {
+      reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
+    } else if (publicStatus) {
+      reblogTitle = intl.formatMessage(messages.reblog);
+    } else if (reblogPrivate) {
+      reblogTitle = intl.formatMessage(messages.reblog_private);
+    } else {
+      reblogTitle = intl.formatMessage(messages.cannot_reblog);
+    }
 
     return (
       <div className='detailed-status__action-bar'>
         <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
-        <div className='detailed-status__button'><IconButton disabled={!publicStatus} active={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
+        <div className='detailed-status__button'><IconButton disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
         <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
         {shareButton}
         <div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index 630e99f2c..0af7c54e4 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -6,9 +6,9 @@ import { FormattedMessage } from 'react-intl';
 import punycode from 'punycode';
 import classnames from 'classnames';
 import Icon from 'mastodon/components/icon';
-import classNames from 'classnames';
 import { useBlurhash } from 'mastodon/initial_state';
 import { decode } from 'blurhash';
+import { debounce } from 'lodash';
 
 const IDNA_PREFIX = 'xn--';
 
@@ -92,13 +92,20 @@ export default class Card extends React.PureComponent {
   }
 
   componentDidMount () {
+    window.addEventListener('resize', this.handleResize, { passive: true });
+
     if (this.props.card && this.props.card.get('blurhash')) {
       this._decode();
     }
   }
 
+  componentWillUnmount () {
+    window.removeEventListener('resize', this.handleResize);
+  }
+
   componentDidUpdate (prevProps) {
     const { card } = this.props;
+
     if (card.get('blurhash') && (!prevProps.card || prevProps.card.get('blurhash') !== card.get('blurhash'))) {
       this._decode();
     }
@@ -118,6 +125,24 @@ export default class Card extends React.PureComponent {
     }
   }
 
+  _setDimensions () {
+    const width = this.node.offsetWidth;
+
+    if (this.props.cacheWidth) {
+      this.props.cacheWidth(width);
+    }
+
+    this.setState({ width });
+  }
+
+  handleResize = debounce(() => {
+    if (this.node) {
+      this._setDimensions();
+    }
+  }, 250, {
+    trailing: true,
+  });
+
   handlePhotoClick = () => {
     const { card, onOpenMedia } = this.props;
 
@@ -150,9 +175,10 @@ export default class Card extends React.PureComponent {
   }
 
   setRef = c => {
-    if (c) {
-      if (this.props.cacheWidth) this.props.cacheWidth(c.offsetWidth);
-      this.setState({ width: c.offsetWidth });
+    this.node = c;
+
+    if (this.node) {
+      this._setDimensions();
     }
   }
 
@@ -164,7 +190,9 @@ export default class Card extends React.PureComponent {
     this.setState({ previewLoaded: true });
   }
 
-  handleReveal = () => {
+  handleReveal = e => {
+    e.preventDefault();
+    e.stopPropagation();
     this.setState({ revealed: true });
   }
 
@@ -202,7 +230,7 @@ export default class Card extends React.PureComponent {
     const height      = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
 
     const description = (
-      <div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}>
+      <div className='status-card__content'>
         {title}
         {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
         <span className='status-card__host'>{provider}</span>
@@ -210,7 +238,7 @@ export default class Card extends React.PureComponent {
     );
 
     let embed     = '';
-    let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
+    let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classnames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
     let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
     let spoilerButton = (
       <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
@@ -218,7 +246,7 @@ export default class Card extends React.PureComponent {
       </button>
     );
     spoilerButton = (
-      <div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}>
+      <div className={classnames('spoiler-button', { 'spoiler-button--minified': revealed })}>
         {spoilerButton}
       </div>
     );
@@ -252,7 +280,7 @@ export default class Card extends React.PureComponent {
       }
 
       return (
-        <div className={className} ref={this.setRef}>
+        <div className={className} ref={this.setRef} onClick={revealed ? null : this.handleReveal} role={revealed ? 'button' : null}>
           {embed}
           {!compact && description}
         </div>
@@ -262,14 +290,12 @@ export default class Card extends React.PureComponent {
         <div className='status-card__image'>
           {canvas}
           {thumbnail}
-          {!revealed && spoilerButton}
         </div>
       );
     } else {
       embed = (
         <div className='status-card__image'>
           <Icon id='file-text' />
-          {!revealed && spoilerButton}
         </div>
       );
     }
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index 2ac47677e..f7d0c9bd4 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -6,7 +6,7 @@ import DisplayName from '../../../components/display_name';
 import StatusContent from '../../../components/status_content';
 import MediaGallery from '../../../components/media_gallery';
 import { Link } from 'react-router-dom';
-import { FormattedDate } from 'react-intl';
+import { injectIntl, defineMessages, FormattedDate } from 'react-intl';
 import Card from './card';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Video from '../../video';
@@ -16,7 +16,15 @@ import classNames from 'classnames';
 import Icon from 'mastodon/components/icon';
 import AnimatedNumber from 'mastodon/components/animated_number';
 
-export default class DetailedStatus extends ImmutablePureComponent {
+const messages = defineMessages({
+  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+});
+
+export default  @injectIntl
+class DetailedStatus extends ImmutablePureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
@@ -92,7 +100,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
   render () {
     const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
     const outerStyle = { boxSizing: 'border-box' };
-    const { compact } = this.props;
+    const { intl, compact } = this.props;
 
     if (!status) {
       return null;
@@ -117,8 +125,9 @@ export default class DetailedStatus extends ImmutablePureComponent {
             src={attachment.get('url')}
             alt={attachment.get('description')}
             duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
-            height={110}
-            preload
+            poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
+            blurhash={attachment.get('blurhash')}
+            height={150}
           />
         );
       } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
@@ -157,34 +166,44 @@ export default class DetailedStatus extends ImmutablePureComponent {
     }
 
     if (status.get('application')) {
-      applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></span>;
+      applicationLink = <React.Fragment> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></React.Fragment>;
     }
 
-    if (status.get('visibility') === 'direct') {
-      reblogIcon = 'envelope';
-    } else if (status.get('visibility') === 'private') {
-      reblogIcon = 'lock';
-    }
+    const visibilityIconInfo = {
+      'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
+      'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
+      'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
+      'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) },
+    };
+
+    const visibilityIcon = visibilityIconInfo[status.get('visibility')];
+    const visibilityLink = <React.Fragment> · <Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></React.Fragment>;
 
     if (['private', 'direct'].includes(status.get('visibility'))) {
-      reblogLink = <Icon id={reblogIcon} />;
+      reblogLink = '';
     } else if (this.context.router) {
       reblogLink = (
-        <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
-          <Icon id={reblogIcon} />
-          <span className='detailed-status__reblogs'>
-            <AnimatedNumber value={status.get('reblogs_count')} />
-          </span>
-        </Link>
+        <React.Fragment>
+          <React.Fragment> · </React.Fragment>
+          <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
+            <Icon id={reblogIcon} />
+            <span className='detailed-status__reblogs'>
+              <AnimatedNumber value={status.get('reblogs_count')} />
+            </span>
+          </Link>
+        </React.Fragment>
       );
     } else {
       reblogLink = (
-        <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
-          <Icon id={reblogIcon} />
-          <span className='detailed-status__reblogs'>
-            <AnimatedNumber value={status.get('reblogs_count')} />
-          </span>
-        </a>
+        <React.Fragment>
+          <React.Fragment> · </React.Fragment>
+          <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
+            <Icon id={reblogIcon} />
+            <span className='detailed-status__reblogs'>
+              <AnimatedNumber value={status.get('reblogs_count')} />
+            </span>
+          </a>
+        </React.Fragment>
       );
     }
 
@@ -210,7 +229,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
 
     return (
       <div style={outerStyle}>
-        <div ref={this.setRef} className={classNames('detailed-status', { compact })}>
+        <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })}>
           <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
             <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
             <DisplayName account={status.get('account')} localDomain={this.props.domain} />
@@ -223,7 +242,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
           <div className='detailed-status__meta'>
             <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
               <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
-            </a>{applicationLink} · {reblogLink} · {favouriteLink}
+            </a>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
           </div>
         </div>
       </div>
diff --git a/app/javascript/mastodon/features/ui/components/__tests__/column-test.js b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
index d2791ce08..a56859be0 100644
--- a/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
+++ b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
@@ -1,25 +1,24 @@
+import { render, fireEvent, screen } from '@testing-library/react';
 import React from 'react';
-import { mount } from 'enzyme';
 import Column from '../column';
-import ColumnHeader from '../column_header';
 
 describe('<Column />', () => {
   describe('<ColumnHeader /> click handler', () => {
     it('runs the scroll animation if the column contains scrollable content', () => {
-      const wrapper = mount(
+      const scrollToMock = jest.fn();
+      const { container } = render(
         <Column heading='notifications'>
           <div className='scrollable' />
         </Column>,
       );
-      const scrollToMock = jest.fn();
-      wrapper.find(Column).find('.scrollable').getDOMNode().scrollTo = scrollToMock;
-      wrapper.find(ColumnHeader).find('button').simulate('click');
+      container.querySelector('.scrollable').scrollTo = scrollToMock;
+      fireEvent.click(screen.getByText('notifications'));
       expect(scrollToMock).toHaveBeenCalledWith({ behavior: 'smooth', top: 0 });
     });
 
     it('does not try to scroll if there is no scrollable content', () => {
-      const wrapper = mount(<Column heading='notifications' />);
-      wrapper.find(ColumnHeader).find('button').simulate('click');
+      render(<Column heading='notifications' />);
+      fireEvent.click(screen.getByText('notifications'));
     });
   });
 });
diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js
index 9f6cbf988..4ce4ac6c8 100644
--- a/app/javascript/mastodon/features/ui/containers/status_list_container.js
+++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js
@@ -17,6 +17,8 @@ const makeGetStatusIds = (pending = false) => createSelector([
     const statusForId = statuses.get(id);
     let showStatus    = true;
 
+    if (statusForId.get('account') === me) return true;
+
     if (columnSettings.getIn(['shows', 'reblog']) === false) {
       showStatus = showStatus && statusForId.get('reblog') === null;
     }
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
index 95e107618..135200a3d 100644
--- a/app/javascript/mastodon/features/video/index.js
+++ b/app/javascript/mastodon/features/video/index.js
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { fromJS, is } from 'immutable';
-import { throttle } from 'lodash';
+import { throttle, debounce } from 'lodash';
 import classNames from 'classnames';
 import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
 import { displayMedia, useBlurhash } from '../../initial_state';
@@ -19,7 +19,6 @@ const messages = defineMessages({
   close: { id: 'video.close', defaultMessage: 'Close video' },
   fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
   exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
-  download: { id: 'video.download', defaultMessage: 'Download file' },
 });
 
 export const formatTime = secondsNum => {
@@ -87,6 +86,14 @@ export const getPointerPosition = (el, event) => {
   return position;
 };
 
+export const fileNameFromURL = str => {
+  const url      = new URL(str);
+  const pathname = url.pathname;
+  const index    = pathname.lastIndexOf('/');
+
+  return pathname.substring(index + 1);
+};
+
 export default @injectIntl
 class Video extends React.PureComponent {
 
@@ -126,27 +133,24 @@ class Video extends React.PureComponent {
     revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
   };
 
-  // Hard-coded in components.scss
-  // Any way to get ::before values programatically?
-  volWidth  = 50;
-  volOffset = 70;
-
-  volHandleOffset = v => {
-    const offset = v * this.volWidth + this.volOffset;
-
-    return (offset > 110) ? 110 : offset;
-  }
-
   setPlayerRef = c => {
     this.player = c;
 
-    if (c) {
-      if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth);
+    if (this.player) {
+      this._setDimensions();
+    }
+  }
 
-      this.setState({
-        containerWidth: c.offsetWidth,
-      });
+  _setDimensions () {
+    const width = this.player.offsetWidth;
+
+    if (this.props.cacheWidth) {
+      this.props.cacheWidth(width);
     }
+
+    this.setState({
+      containerWidth: width,
+    });
   }
 
   setVideoRef = c => {
@@ -173,15 +177,26 @@ class Video extends React.PureComponent {
 
   handlePlay = () => {
     this.setState({ paused: false });
+    this._updateTime();
   }
 
   handlePause = () => {
     this.setState({ paused: true });
   }
 
+  _updateTime () {
+    requestAnimationFrame(() => {
+      this.handleTimeUpdate();
+
+      if (!this.state.paused) {
+        this._updateTime();
+      }
+    });
+  }
+
   handleTimeUpdate = () => {
     this.setState({
-      currentTime: Math.floor(this.video.currentTime),
+      currentTime: this.video.currentTime,
       duration: Math.floor(this.video.duration),
     });
   }
@@ -206,22 +221,14 @@ class Video extends React.PureComponent {
   }
 
   handleMouseVolSlide = throttle(e => {
-    const rect = this.volume.getBoundingClientRect();
-    const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
+    const { x } = getPointerPosition(this.volume, e);
 
     if(!isNaN(x)) {
-      let slideamt = x;
-
-      if(x > 1) {
-        slideamt = 1;
-      } else if(x < 0) {
-        slideamt = 0;
-      }
-
-      this.video.volume = slideamt;
-      this.setState({ volume: slideamt });
+      this.setState({ volume: x }, () => {
+        this.video.volume = x;
+      });
     }
-  }, 60);
+  }, 15);
 
   handleMouseDown = e => {
     document.addEventListener('mousemove', this.handleMouseMove, true);
@@ -249,13 +256,14 @@ class Video extends React.PureComponent {
 
   handleMouseMove = throttle(e => {
     const { x } = getPointerPosition(this.seek, e);
-    const currentTime = Math.floor(this.video.duration * x);
+    const currentTime = this.video.duration * x;
 
     if (!isNaN(currentTime)) {
-      this.video.currentTime = currentTime;
-      this.setState({ currentTime });
+      this.setState({ currentTime }, () => {
+        this.video.currentTime = currentTime;
+      });
     }
-  }, 60);
+  }, 15);
 
   togglePlay = () => {
     if (this.state.paused) {
@@ -280,6 +288,7 @@ class Video extends React.PureComponent {
     document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
 
     window.addEventListener('scroll', this.handleScroll);
+    window.addEventListener('resize', this.handleResize, { passive: true });
 
     if (this.props.blurhash) {
       this._decode();
@@ -288,6 +297,7 @@ class Video extends React.PureComponent {
 
   componentWillUnmount () {
     window.removeEventListener('scroll', this.handleScroll);
+    window.removeEventListener('resize', this.handleResize);
 
     document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
     document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
@@ -325,6 +335,14 @@ class Video extends React.PureComponent {
     }
   }
 
+  handleResize = debounce(() => {
+    if (this.player) {
+      this._setDimensions();
+    }
+  }, 250, {
+    trailing: true,
+  });
+
   handleScroll = throttle(() => {
     if (!this.video) {
       return;
@@ -381,8 +399,10 @@ class Video extends React.PureComponent {
   }
 
   handleProgress = () => {
-    if (this.video.buffered.length > 0) {
-      this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
+    const lastTimeRange = this.video.buffered.length - 1;
+
+    if (lastTimeRange > -1) {
+      this.setState({ buffer: Math.ceil(this.video.buffered.end(lastTimeRange) / this.video.duration * 100) });
     }
   }
 
@@ -421,9 +441,6 @@ class Video extends React.PureComponent {
     const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable } = this.props;
     const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
     const progress = (currentTime / duration) * 100;
-
-    const volumeWidth = (muted) ? 0 : volume * this.volWidth;
-    const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume);
     const playerStyle = {};
 
     let { width, height } = this.props;
@@ -481,7 +498,6 @@ class Video extends React.PureComponent {
           onClick={this.togglePlay}
           onPlay={this.handlePlay}
           onPause={this.handlePause}
-          onTimeUpdate={this.handleTimeUpdate}
           onLoadedData={this.handleLoadedData}
           onProgress={this.handleProgress}
           onVolumeChange={this.handleVolumeChange}
@@ -510,19 +526,19 @@ class Video extends React.PureComponent {
               <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay} autoFocus={detailed}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
               <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
 
-              <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
-                &nbsp;
-                <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
+              <div className={classNames('video-player__volume', { active: this.state.hovered })} onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
+                <div className='video-player__volume__current' style={{ width: `${volume * 100}%` }} />
+
                 <span
                   className={classNames('video-player__volume__handle')}
                   tabIndex='0'
-                  style={{ left: `${volumeHandleLoc}px` }}
+                  style={{ left: `${volume * 100}%` }}
                 />
               </div>
 
               {(detailed || fullscreen) && (
-                <span>
-                  <span className='video-player__time-current'>{formatTime(currentTime)}</span>
+                <span className='video-player__time'>
+                  <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
                   <span className='video-player__time-sep'>/</span>
                   <span className='video-player__time-total'>{formatTime(duration)}</span>
                 </span>
@@ -535,7 +551,6 @@ class Video extends React.PureComponent {
               {(!onCloseVideo && !editable && !fullscreen) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
               {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
               {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
-              <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)}><a className='video-player__download__icon' href={this.props.src} download><Icon id={'download'} fixedWidth /></a></button>
               <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
             </div>
           </div>
diff --git a/app/javascript/mastodon/locales/ast.json b/app/javascript/mastodon/locales/ast.json
index 3989978a0..2d4f73975 100644
--- a/app/javascript/mastodon/locales/ast.json
+++ b/app/javascript/mastodon/locales/ast.json
@@ -422,7 +422,7 @@
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "El borrador va perdese si coles de Mastodon.",
   "upload_area.title": "Arrastra y suelta pa xubir",
-  "upload_button.label": "Add media ({formats})",
+  "upload_button.label": "Add images, a video or an audio file",
   "upload_error.limit": "File upload limit exceeded.",
   "upload_error.poll": "La xuba de ficheros nun ta permitida con encuestes.",
   "upload_form.audio_description": "Descripción pa persones con perda auditiva",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 1d280d710..c7ca77376 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -1206,7 +1206,7 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "Add media ({formats})",
+        "defaultMessage": "Add images, a video or an audio file",
         "id": "upload_button.label"
       }
     ],
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 1779f4713..b12409a8c 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -427,7 +427,7 @@
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
-  "upload_button.label": "Add media ({formats})",
+  "upload_button.label": "Add images, a video or an audio file",
   "upload_error.limit": "File upload limit exceeded.",
   "upload_error.poll": "File upload not allowed with polls.",
   "upload_form.audio_description": "Describe for people with hearing loss",
diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json
index 19054f716..cc82ee481 100644
--- a/app/javascript/mastodon/locales/ga.json
+++ b/app/javascript/mastodon/locales/ga.json
@@ -422,7 +422,7 @@
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
-  "upload_button.label": "Add media ({formats})",
+  "upload_button.label": "Add images, a video or an audio file",
   "upload_error.limit": "File upload limit exceeded.",
   "upload_error.poll": "File upload not allowed with polls.",
   "upload_form.audio_description": "Describe for people with hearing loss",
diff --git a/app/javascript/mastodon/locales/hi.json b/app/javascript/mastodon/locales/hi.json
index e26b607bb..3c7fe6df4 100644
--- a/app/javascript/mastodon/locales/hi.json
+++ b/app/javascript/mastodon/locales/hi.json
@@ -422,7 +422,7 @@
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
-  "upload_button.label": "Add media ({formats})",
+  "upload_button.label": "Add images, a video or an audio file",
   "upload_error.limit": "File upload limit exceeded.",
   "upload_error.poll": "File upload not allowed with polls.",
   "upload_form.audio_description": "Describe for people with hearing loss",
diff --git a/app/javascript/mastodon/locales/kn.json b/app/javascript/mastodon/locales/kn.json
index 33fec4a4c..6c68862e0 100644
--- a/app/javascript/mastodon/locales/kn.json
+++ b/app/javascript/mastodon/locales/kn.json
@@ -422,7 +422,7 @@
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
-  "upload_button.label": "Add media ({formats})",
+  "upload_button.label": "Add images, a video or an audio file",
   "upload_error.limit": "File upload limit exceeded.",
   "upload_error.poll": "File upload not allowed with polls.",
   "upload_form.audio_description": "Describe for people with hearing loss",
diff --git a/app/javascript/mastodon/locales/lt.json b/app/javascript/mastodon/locales/lt.json
index 33fec4a4c..6c68862e0 100644
--- a/app/javascript/mastodon/locales/lt.json
+++ b/app/javascript/mastodon/locales/lt.json
@@ -422,7 +422,7 @@
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
-  "upload_button.label": "Add media ({formats})",
+  "upload_button.label": "Add images, a video or an audio file",
   "upload_error.limit": "File upload limit exceeded.",
   "upload_error.poll": "File upload not allowed with polls.",
   "upload_form.audio_description": "Describe for people with hearing loss",
diff --git a/app/javascript/mastodon/locales/lv.json b/app/javascript/mastodon/locales/lv.json
index d4288f96b..aa8bc183c 100644
--- a/app/javascript/mastodon/locales/lv.json
+++ b/app/javascript/mastodon/locales/lv.json
@@ -422,7 +422,7 @@
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
-  "upload_button.label": "Add media ({formats})",
+  "upload_button.label": "Add images, a video or an audio file",
   "upload_error.limit": "File upload limit exceeded.",
   "upload_error.poll": "File upload not allowed with polls.",
   "upload_form.audio_description": "Describe for people with hearing loss",
diff --git a/app/javascript/mastodon/locales/mk.json b/app/javascript/mastodon/locales/mk.json
index 61202ec19..78cc18f53 100644
--- a/app/javascript/mastodon/locales/mk.json
+++ b/app/javascript/mastodon/locales/mk.json
@@ -422,7 +422,7 @@
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
-  "upload_button.label": "Add media ({formats})",
+  "upload_button.label": "Add images, a video or an audio file",
   "upload_error.limit": "File upload limit exceeded.",
   "upload_error.poll": "File upload not allowed with polls.",
   "upload_form.audio_description": "Describe for people with hearing loss",
diff --git a/app/javascript/mastodon/locales/ml.json b/app/javascript/mastodon/locales/ml.json
index 7b74c10ee..68b89a585 100644
--- a/app/javascript/mastodon/locales/ml.json
+++ b/app/javascript/mastodon/locales/ml.json
@@ -422,7 +422,7 @@
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
-  "upload_button.label": "Add media ({formats})",
+  "upload_button.label": "Add images, a video or an audio file",
   "upload_error.limit": "File upload limit exceeded.",
   "upload_error.poll": "File upload not allowed with polls.",
   "upload_form.audio_description": "Describe for people with hearing loss",
diff --git a/app/javascript/mastodon/locales/mr.json b/app/javascript/mastodon/locales/mr.json
index 46fd5acc5..2188d02b0 100644
--- a/app/javascript/mastodon/locales/mr.json
+++ b/app/javascript/mastodon/locales/mr.json
@@ -422,7 +422,7 @@
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
-  "upload_button.label": "Add media ({formats})",
+  "upload_button.label": "Add images, a video or an audio file",
   "upload_error.limit": "File upload limit exceeded.",
   "upload_error.poll": "File upload not allowed with polls.",
   "upload_form.audio_description": "Describe for people with hearing loss",
diff --git a/app/javascript/mastodon/locales/ms.json b/app/javascript/mastodon/locales/ms.json
index 9a9fc975a..b55fd4d43 100644
--- a/app/javascript/mastodon/locales/ms.json
+++ b/app/javascript/mastodon/locales/ms.json
@@ -422,7 +422,7 @@
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
-  "upload_button.label": "Add media ({formats})",
+  "upload_button.label": "Add images, a video or an audio file",
   "upload_error.limit": "File upload limit exceeded.",
   "upload_error.poll": "File upload not allowed with polls.",
   "upload_form.audio_description": "Describe for people with hearing loss",
diff --git a/app/javascript/mastodon/locales/ur.json b/app/javascript/mastodon/locales/ur.json
index e3639d477..bff992983 100644
--- a/app/javascript/mastodon/locales/ur.json
+++ b/app/javascript/mastodon/locales/ur.json
@@ -422,7 +422,7 @@
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
-  "upload_button.label": "Add media ({formats})",
+  "upload_button.label": "Add images, a video or an audio file",
   "upload_error.limit": "File upload limit exceeded.",
   "upload_error.poll": "File upload not allowed with polls.",
   "upload_form.audio_description": "Describe for people with hearing loss",
diff --git a/app/javascript/mastodon/reducers/account_notes.js b/app/javascript/mastodon/reducers/account_notes.js
new file mode 100644
index 000000000..b1cf2e0aa
--- /dev/null
+++ b/app/javascript/mastodon/reducers/account_notes.js
@@ -0,0 +1,44 @@
+import { Map as ImmutableMap } from 'immutable';
+
+import {
+  ACCOUNT_NOTE_INIT_EDIT,
+  ACCOUNT_NOTE_CANCEL,
+  ACCOUNT_NOTE_CHANGE_COMMENT,
+  ACCOUNT_NOTE_SUBMIT_REQUEST,
+  ACCOUNT_NOTE_SUBMIT_FAIL,
+  ACCOUNT_NOTE_SUBMIT_SUCCESS,
+} from '../actions/account_notes';
+
+const initialState = ImmutableMap({
+  edit: ImmutableMap({
+    isSubmitting: false,
+    account_id: null,
+    comment: null,
+  }),
+});
+
+export default function account_notes(state = initialState, action) {
+  switch (action.type) {
+  case ACCOUNT_NOTE_INIT_EDIT:
+    return state.withMutations((state) => {
+      state.setIn(['edit', 'isSubmitting'], false);
+      state.setIn(['edit', 'account_id'], action.account.get('id'));
+      state.setIn(['edit', 'comment'], action.comment);
+    });
+  case ACCOUNT_NOTE_CHANGE_COMMENT:
+    return state.setIn(['edit', 'comment'], action.comment);
+  case ACCOUNT_NOTE_SUBMIT_REQUEST:
+    return state.setIn(['edit', 'isSubmitting'], true);
+  case ACCOUNT_NOTE_SUBMIT_FAIL:
+    return state.setIn(['edit', 'isSubmitting'], false);
+  case ACCOUNT_NOTE_SUBMIT_SUCCESS:
+  case ACCOUNT_NOTE_CANCEL:
+    return state.withMutations((state) => {
+      state.setIn(['edit', 'isSubmitting'], false);
+      state.setIn(['edit', 'account_id'], null);
+      state.setIn(['edit', 'comment'], null);
+    });
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 3823bb05e..690349b85 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -36,6 +36,7 @@ import trends from './trends';
 import missed_updates from './missed_updates';
 import announcements from './announcements';
 import markers from './markers';
+import account_notes from './account_notes';
 
 const reducers = {
   announcements,
@@ -75,6 +76,7 @@ const reducers = {
   trends,
   missed_updates,
   markers,
+  account_notes,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/mastodon/reducers/markers.js b/app/javascript/mastodon/reducers/markers.js
index 2e67be82e..fb1572ff5 100644
--- a/app/javascript/mastodon/reducers/markers.js
+++ b/app/javascript/mastodon/reducers/markers.js
@@ -1,6 +1,6 @@
 import {
   MARKERS_SUBMIT_SUCCESS,
-} from '../actions/notifications';
+} from '../actions/markers';
 
 const initialState = ImmutableMap({
   home: '0',
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
index 8322780de..1d050cc63 100644
--- a/app/javascript/mastodon/reducers/relationships.js
+++ b/app/javascript/mastodon/reducers/relationships.js
@@ -17,6 +17,9 @@ import {
   DOMAIN_BLOCK_SUCCESS,
   DOMAIN_UNBLOCK_SUCCESS,
 } from '../actions/domain_blocks';
+import {
+  ACCOUNT_NOTE_SUBMIT_SUCCESS,
+} from '../actions/account_notes';
 import { Map as ImmutableMap, fromJS } from 'immutable';
 
 const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship));
@@ -57,6 +60,7 @@ export default function relationships(state = initialState, action) {
   case ACCOUNT_UNMUTE_SUCCESS:
   case ACCOUNT_PIN_SUCCESS:
   case ACCOUNT_UNPIN_SUCCESS:
+  case ACCOUNT_NOTE_SUBMIT_SUCCESS:
     return normalizeRelationship(state, action.relationship);
   case RELATIONSHIPS_FETCH_SUCCESS:
     return normalizeRelationships(state, action.relationships);
diff --git a/app/javascript/mastodon/test_setup.js b/app/javascript/mastodon/test_setup.js
index 80148379b..666127af3 100644
--- a/app/javascript/mastodon/test_setup.js
+++ b/app/javascript/mastodon/test_setup.js
@@ -1,5 +1 @@
-import { configure } from 'enzyme';
-import Adapter from 'enzyme-adapter-react-16';
-
-const adapter = new Adapter();
-configure({ adapter });
+import '@testing-library/jest-dom/extend-expect';
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 78dea92b9..fea64f45c 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -171,9 +171,7 @@ $content-width: 840px;
   }
 
   .content {
-    padding: 20px 15px;
-    padding-top: 60px;
-    padding-left: 25px;
+    padding: 55px 15px 20px 25px;
 
     @media screen and (max-width: $no-columns-breakpoint) {
       max-width: none;
@@ -184,7 +182,7 @@ $content-width: 840px;
     &-heading {
       display: flex;
 
-      padding-bottom: 40px;
+      padding-bottom: 36px;
       border-bottom: 1px solid lighten($ui-base-color, 8%);
 
       margin: -15px -15px 40px 0;
@@ -215,7 +213,7 @@ $content-width: 840px;
     h2 {
       color: $secondary-text-color;
       font-size: 24px;
-      line-height: 28px;
+      line-height: 36px;
       font-weight: 400;
 
       @media screen and (max-width: $no-columns-breakpoint) {
@@ -544,6 +542,16 @@ body,
   max-width: 100%;
 }
 
+.simple_form {
+  .actions {
+    margin-top: 15px;
+  }
+
+  .button {
+    font-size: 15px;
+  }
+}
+
 .batch-form-box {
   display: flex;
   flex-wrap: wrap;
diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss
index a5dbe75fb..9e63b1d31 100644
--- a/app/javascript/styles/mastodon/basics.scss
+++ b/app/javascript/styles/mastodon/basics.scss
@@ -68,7 +68,32 @@ body {
   }
 
   &.player {
-    text-align: center;
+    padding: 0;
+    margin: 0;
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+
+    & > div {
+      height: 100%;
+    }
+
+    .video-player video {
+      width: 100%;
+      height: 100%;
+      max-height: 100vh;
+    }
+
+    .media-gallery {
+      margin-top: 0;
+      height: 100% !important;
+      border-radius: 0;
+    }
+
+    .media-gallery__item {
+      border-radius: 0;
+    }
   }
 
   &.embed {
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index acbd21e8b..0c594ef56 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1019,7 +1019,8 @@
   }
 
   &.light {
-    .status__relative-time {
+    .status__relative-time,
+    .status__visibility-icon {
       color: $light-text-color;
     }
 
@@ -1065,12 +1066,18 @@
 }
 
 .status__relative-time,
+.status__visibility-icon,
 .notification__relative_time {
   color: $dark-text-color;
   float: right;
   font-size: 14px;
 }
 
+.status__visibility-icon {
+  margin-left: 4px;
+  margin-right: 4px;
+}
+
 .status__display-name {
   color: $dark-text-color;
 }
@@ -3003,6 +3010,7 @@ a.account__display-name {
 }
 
 .status-card {
+  position: relative;
   display: flex;
   font-size: 14px;
   border: 1px solid lighten($ui-base-color, 8%);
@@ -3097,11 +3105,6 @@ a.status-card {
   flex: 1 1 auto;
   overflow: hidden;
   padding: 14px 14px 14px 8px;
-
-  &--blurred {
-    filter: blur(2px);
-    pointer-events: none;
-  }
 }
 
 .status-card__description {
@@ -3838,7 +3841,6 @@ a.status-card.compact:hover {
   color: $primary-text-color;
   margin-bottom: 4px;
   display: block;
-  vertical-align: top;
   background-color: $base-overlay-background;
   text-transform: uppercase;
   font-size: 11px;
@@ -5203,6 +5205,7 @@ a.status-card.compact:hover {
   border-radius: 4px;
   position: relative;
   width: 100%;
+  min-height: 64px;
 }
 
 .media-gallery__item {
@@ -5296,6 +5299,7 @@ a.status-card.compact:hover {
 }
 
 .audio-player {
+  overflow: hidden;
   box-sizing: border-box;
   position: relative;
   background: darken($ui-base-color, 8%);
@@ -5308,37 +5312,54 @@ a.status-card.compact:hover {
     height: 100%;
   }
 
-  &__waveform {
-    padding: 15px 0;
-    position: relative;
-    overflow: hidden;
+  .video-player__volume::before,
+  .video-player__seek::before {
+    background: rgba($white, 0.15);
+  }
 
-    &::before {
-      content: "";
-      display: block;
-      position: absolute;
-      border-top: 1px solid lighten($ui-base-color, 4%);
-      width: 100%;
-      height: 0;
-      left: 0;
-      top: calc(50% + 1px);
+  &.with-light-background {
+    color: $black;
+
+    .video-player__volume::before,
+    .video-player__seek::before {
+      background: rgba($black, 0.15);
+    }
+
+    .video-player__seek__buffer {
+      background: rgba($black, 0.2);
+    }
+
+    .video-player__buttons button {
+      color: rgba($black, 0.75);
+
+      &:active,
+      &:hover,
+      &:focus {
+        color: $black;
+      }
+    }
+
+    .video-player__time-sep,
+    .video-player__time-total,
+    .video-player__time-current {
+      color: $black;
     }
   }
 
-  &__progress-placeholder {
-    background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);
+  .video-player__seek::before,
+  .video-player__seek__buffer,
+  .video-player__seek__progress {
+    top: 0;
   }
 
-  &__wave-placeholder {
-    background-color: lighten($ui-base-color, 16%);
+  .video-player__seek__handle {
+    top: -4px;
   }
 
   .video-player__controls {
     padding: 0 15px;
     padding-top: 10px;
-    background: darken($ui-base-color, 8%);
-    border-top: 1px solid lighten($ui-base-color, 4%);
-    border-radius: 0 0 4px 4px;
+    background: transparent;
   }
 }
 
@@ -5350,6 +5371,7 @@ a.status-card.compact:hover {
   border-radius: 4px;
   box-sizing: border-box;
   direction: ltr;
+  color: $white;
 
   &.editable {
     border-radius: 0;
@@ -5361,6 +5383,7 @@ a.status-card.compact:hover {
   }
 
   video {
+    display: block;
     max-width: 100vw;
     max-height: 80vh;
     z-index: 1;
@@ -5461,6 +5484,10 @@ a.status-card.compact:hover {
   }
 
   &__buttons {
+    display: flex;
+    flex: 0 1 auto;
+    min-width: 30px;
+    align-items: center;
     font-size: 16px;
     white-space: nowrap;
     overflow: hidden;
@@ -5479,6 +5506,7 @@ a.status-card.compact:hover {
     }
 
     button {
+      flex: 0 0 auto;
       background: transparent;
       padding: 2px 10px;
       font-size: 16px;
@@ -5493,6 +5521,13 @@ a.status-card.compact:hover {
     }
   }
 
+  &__time {
+    display: inline;
+    flex: 0 1 auto;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
   &__time-sep,
   &__time-total,
   &__time-current {
@@ -5502,7 +5537,6 @@ a.status-card.compact:hover {
 
   &__time-current {
     color: $white;
-    margin-left: 60px;
   }
 
   &__time-sep {
@@ -5516,9 +5550,22 @@ a.status-card.compact:hover {
   }
 
   &__volume {
+    flex: 0 0 auto;
+    display: inline-flex;
     cursor: pointer;
     height: 24px;
-    display: inline;
+    position: relative;
+    overflow: hidden;
+
+    .no-reduce-motion & {
+      transition: all 100ms linear;
+    }
+
+    &.active {
+      overflow: visible;
+      width: 50px;
+      margin-right: 16px;
+    }
 
     &::before {
       content: "";
@@ -5528,8 +5575,9 @@ a.status-card.compact:hover {
       display: block;
       position: absolute;
       height: 4px;
-      left: 70px;
-      bottom: 20px;
+      left: 0;
+      top: 50%;
+      transform: translate(0, -50%);
     }
 
     &__current {
@@ -5537,8 +5585,9 @@ a.status-card.compact:hover {
       position: absolute;
       height: 4px;
       border-radius: 4px;
-      left: 70px;
-      bottom: 20px;
+      left: 0;
+      top: 50%;
+      transform: translate(0, -50%);
       background: lighten($ui-highlight-color, 8%);
     }
 
@@ -5548,12 +5597,21 @@ a.status-card.compact:hover {
       border-radius: 50%;
       width: 12px;
       height: 12px;
-      bottom: 16px;
-      left: 70px;
-      transition: opacity .1s ease;
+      top: 50%;
+      left: 0;
+      margin-left: -6px;
+      transform: translate(0, -50%);
       background: lighten($ui-highlight-color, 8%);
       box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);
-      pointer-events: none;
+      opacity: 0;
+
+      .no-reduce-motion & {
+        transition: opacity 100ms linear;
+      }
+    }
+
+    &.active &__handle {
+      opacity: 1;
     }
   }
 
@@ -5613,10 +5671,12 @@ a.status-card.compact:hover {
       height: 12px;
       top: 6px;
       margin-left: -6px;
-      transition: opacity .1s ease;
       background: lighten($ui-highlight-color, 8%);
       box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);
-      pointer-events: none;
+
+      .no-reduce-motion & {
+        transition: opacity .1s ease;
+      }
 
       &.active {
         opacity: 1;
@@ -6436,7 +6496,7 @@ noscript {
   &__tabs {
     display: flex;
     align-items: flex-start;
-    padding: 7px 5px;
+    padding: 7px 10px;
     margin-top: -55px;
 
     &__buttons {
@@ -6458,7 +6518,7 @@ noscript {
     }
 
     &__name {
-      padding: 5px;
+      padding: 5px 10px;
 
       .account-role {
         vertical-align: top;
@@ -6544,6 +6604,67 @@ noscript {
       }
     }
   }
+
+  &__account-note {
+    margin: 5px;
+    padding: 10px;
+    background: $ui-highlight-color;
+    color: $primary-text-color;
+    display: flex;
+    flex-direction: column;
+    border-radius: 4px;
+    font-size: 14px;
+    font-weight: 400;
+
+    &__header {
+      display: flex;
+      flex-direction: row;
+      justify-content: space-between;
+    }
+
+    &__content {
+      white-space: pre-wrap;
+      margin-top: 5px;
+    }
+
+    &__buttons {
+      display: flex;
+      flex-direction: row;
+      justify-content: flex-end;
+      margin-top: 5px;
+
+      .flex-spacer {
+        flex: 0 0 20px;
+        background: transparent;
+      }
+    }
+
+    strong {
+      font-size: 15px;
+      font-weight: 500;
+    }
+
+    button:hover span {
+      text-decoration: underline;
+    }
+
+    textarea {
+      display: block;
+      box-sizing: border-box;
+      width: 100%;
+      margin: 0;
+      margin-top: 5px;
+      color: $inverted-text-color;
+      background: $simple-background-color;
+      padding: 10px;
+      font-family: inherit;
+      font-size: 14px;
+      resize: none;
+      border: 0;
+      outline: 0;
+      border-radius: 4px;
+    }
+  }
 }
 
 .trends {
diff --git a/app/javascript/styles/mastodon/rtl.scss b/app/javascript/styles/mastodon/rtl.scss
index ecd166253..fbf26e30b 100644
--- a/app/javascript/styles/mastodon/rtl.scss
+++ b/app/javascript/styles/mastodon/rtl.scss
@@ -158,6 +158,7 @@ body.rtl {
   }
 
   .status__relative-time,
+  .status__visibility-icon,
   .activity-stream .status.light .status__header .status__meta {
     float: left;
   }
diff --git a/app/javascript/styles/mastodon/statuses.scss b/app/javascript/styles/mastodon/statuses.scss
index a8fd2936c..7ae1c5a24 100644
--- a/app/javascript/styles/mastodon/statuses.scss
+++ b/app/javascript/styles/mastodon/statuses.scss
@@ -140,6 +140,11 @@
 
   .detailed-status {
     padding: 15px;
+
+    .detailed-status__display-avatar .account__avatar {
+      width: 48px;
+      height: 48px;
+    }
   }
 
   .status {
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 3509a6c40..d3d460551 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -238,12 +238,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
       begin
         href             = Addressable::URI.parse(attachment['url']).normalize.to_s
-        media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
+        media_attachment = MediaAttachment.create(account: @account, remote_url: href, thumbnail_remote_url: icon_url_from_attachment(attachment), description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
         media_attachments << media_attachment
 
         next if unsupported_media_type?(attachment['mediaType']) || skip_download?
 
-        media_attachment.file_remote_url = href
+        media_attachment.download_file!
+        media_attachment.download_thumbnail!
         media_attachment.save
       rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
         RedownloadMediaWorker.perform_in(rand(30..600).seconds, media_attachment.id)
@@ -256,6 +257,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     media_attachments
   end
 
+  def icon_url_from_attachment(attachment)
+    url = attachment['icon'].is_a?(Hash) ? attachment['icon']['url'] : attachment['icon']
+    Addressable::URI.parse(url).normalize.to_s if url.present?
+  rescue Addressable::URI::InvalidURIError
+    nil
+  end
+
   def process_poll
     return unless @object['type'] == 'Question' && (@object['anyOf'].is_a?(Array) || @object['oneOf'].is_a?(Array))
 
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 8b3198df7..af0fa2b98 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -121,7 +121,7 @@ class FeedManager
     crutches = build_crutches(into_account.id, statuses)
 
     statuses.each do |status|
-      next if filter_from_home?(status, into_account, crutches)
+      next if filter_from_home?(status, into_account.id, crutches)
 
       add_to_feed(:home, into_account.id, status, aggregate)
     end
diff --git a/app/lib/language_detector.rb b/app/lib/language_detector.rb
index 05a06726d..2cc8ac615 100644
--- a/app/lib/language_detector.rb
+++ b/app/lib/language_detector.rb
@@ -4,7 +4,7 @@ class LanguageDetector
   include Singleton
 
   WORDS_THRESHOLD        = 4
-  RELIABLE_CHARACTERS_RE = /[\p{Hebrew}\p{Arabic}\p{Syriac}\p{Thaana}\p{Nko}\p{Han}\p{Katakana}\p{Hiragana}\p{Hangul}]+/m
+  RELIABLE_CHARACTERS_RE = /[\p{Hebrew}\p{Arabic}\p{Syriac}\p{Thaana}\p{Nko}\p{Han}\p{Katakana}\p{Hiragana}\p{Hangul}\p{Thai}]+/m
 
   def initialize
     @identifier = CLD3::NNetLanguageIdentifier.new(1, 2048)
diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb
index 8d094dddb..80e8f6cc7 100644
--- a/app/lib/sanitize_config.rb
+++ b/app/lib/sanitize_config.rb
@@ -91,6 +91,8 @@ class Sanitize
         'span'       => %w(class),
         'abbr'       => %w(title),
         'blockquote' => %w(cite),
+        'ol'         => %w(start reversed),
+        'li'         => %w(value),
       },
 
       add_attributes: {
diff --git a/app/models/account_note.rb b/app/models/account_note.rb
new file mode 100644
index 000000000..bf61df923
--- /dev/null
+++ b/app/models/account_note.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_notes
+#
+#  id                :bigint(8)        not null, primary key
+#  account_id        :bigint(8)
+#  target_account_id :bigint(8)
+#  comment           :text             not null
+#  created_at        :datetime         not null
+#  updated_at        :datetime         not null
+#
+class AccountNote < ApplicationRecord
+  include RelationshipCacheable
+
+  belongs_to :account
+  belongs_to :target_account, class_name: 'Account'
+
+  validates :account_id, uniqueness: { scope: :target_account_id }
+end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 32fcb5397..be7211f2c 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -44,6 +44,14 @@ module AccountInteractions
       follow_mapping(AccountPin.where(account_id: account_id, target_account_id: target_account_ids), :target_account_id)
     end
 
+    def account_note_map(target_account_ids, account_id)
+      AccountNote.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |note, mapping|
+        mapping[note.target_account_id] = {
+          comment: note.comment,
+        }
+      end
+    end
+
     def domain_blocking_map(target_account_ids, account_id)
       accounts_map    = Account.where(id: target_account_ids).select('id, domain').each_with_object({}) { |a, h| h[a.id] = a.domain }
       blocked_domains = domain_blocking_map_by_domain(accounts_map.values.compact, account_id)
diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb
index c728a460e..53ebc0835 100644
--- a/app/models/concerns/remotable.rb
+++ b/app/models/concerns/remotable.rb
@@ -4,12 +4,12 @@ module Remotable
   extend ActiveSupport::Concern
 
   class_methods do
-    def remotable_attachment(attachment_name, limit, suppress_errors: true)
-      attribute_name  = "#{attachment_name}_remote_url".to_sym
-      method_name     = "#{attribute_name}=".to_sym
-      alt_method_name = "reset_#{attachment_name}!".to_sym
+    def remotable_attachment(attachment_name, limit, suppress_errors: true, download_on_assign: true, attribute_name: nil)
+      attribute_name ||= "#{attachment_name}_remote_url".to_sym
+
+      define_method("download_#{attachment_name}!") do |url = nil|
+        url ||= self[attribute_name]
 
-      define_method method_name do |url|
         return if url.blank?
 
         begin
@@ -18,7 +18,7 @@ module Remotable
           return
         end
 
-        return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.blank? || (self[attribute_name] == url && send("#{attachment_name}_file_name").present?)
+        return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.blank?
 
         begin
           Request.new(:get, url).perform do |response|
@@ -36,10 +36,8 @@ module Remotable
 
             basename = SecureRandom.hex(8)
 
-            send("#{attachment_name}_file_name=", basename + extname)
-            send("#{attachment_name}=", StringIO.new(response.body_with_limit(limit)))
-
-            self[attribute_name] = url if has_attribute?(attribute_name)
+            public_send("#{attachment_name}_file_name=", basename + extname)
+            public_send("#{attachment_name}=", StringIO.new(response.body_with_limit(limit)))
           end
         rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError => e
           Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
@@ -50,14 +48,15 @@ module Remotable
         end
       end
 
-      define_method alt_method_name do
-        url = self[attribute_name]
+      define_method("#{attribute_name}=") do |url|
+        return if self[attribute_name] == url && public_send("#{attachment_name}_file_name").present?
 
-        return if url.blank?
+        self[attribute_name] = url if has_attribute?(attribute_name)
 
-        self[attribute_name] = ''
-        send(method_name, url)
+        public_send("download_#{attachment_name}!", url) if download_on_assign
       end
+
+      alias_method("reset_#{attachment_name}!", "download_#{attachment_name}!")
     end
   end
 
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index f789bdc55..61581138e 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -21,6 +21,11 @@
 #  blurhash                    :string
 #  processing                  :integer
 #  file_storage_schema_version :integer
+#  thumbnail_file_name         :string
+#  thumbnail_content_type      :string
+#  thumbnail_file_size         :integer
+#  thumbnail_updated_at        :datetime
+#  thumbnail_remote_url        :string
 #
 
 class MediaAttachment < ApplicationRecord
@@ -49,13 +54,13 @@ class MediaAttachment < ApplicationRecord
     original: {
       pixels: 1_638_400, # 1280x1280px
       file_geometry_parser: FastGeometryParser,
-    },
+    }.freeze,
 
     small: {
       pixels: 160_000, # 400x400px
       file_geometry_parser: FastGeometryParser,
       blurhash: BLURHASH_OPTIONS,
-    },
+    }.freeze,
   }.freeze
 
   VIDEO_FORMAT = {
@@ -74,14 +79,14 @@ class MediaAttachment < ApplicationRecord
         'frames:v' => 60 * 60 * 3,
         'crf' => 18,
         'map_metadata' => '-1',
-      },
-    },
+      }.freeze,
+    }.freeze,
   }.freeze
 
   VIDEO_PASSTHROUGH_OPTIONS = {
-    video_codecs: ['h264'],
-    audio_codecs: ['aac', nil],
-    colorspaces: ['yuv420p'],
+    video_codecs: ['h264'].freeze,
+    audio_codecs: ['aac', nil].freeze,
+    colorspaces: ['yuv420p'].freeze,
     options: {
       format: 'mp4',
       convert_options: {
@@ -90,9 +95,9 @@ class MediaAttachment < ApplicationRecord
           'map_metadata' => '-1',
           'c:v' => 'copy',
           'c:a' => 'copy',
-        },
-      },
-    },
+        }.freeze,
+      }.freeze,
+    }.freeze,
   }.freeze
 
   VIDEO_STYLES = {
@@ -101,15 +106,15 @@ class MediaAttachment < ApplicationRecord
         output: {
           'loglevel' => 'fatal',
           vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
-        },
-      },
+        }.freeze,
+      }.freeze,
       format: 'png',
       time: 0,
       file_geometry_parser: FastGeometryParser,
       blurhash: BLURHASH_OPTIONS,
-    },
+    }.freeze,
 
-    original: VIDEO_FORMAT.merge(passthrough_options: VIDEO_PASSTHROUGH_OPTIONS),
+    original: VIDEO_FORMAT.merge(passthrough_options: VIDEO_PASSTHROUGH_OPTIONS).freeze,
   }.freeze
 
   AUDIO_STYLES = {
@@ -119,16 +124,23 @@ class MediaAttachment < ApplicationRecord
       convert_options: {
         output: {
           'loglevel' => 'fatal',
-          'map_metadata' => '-1',
           'q:a' => 2,
-        },
-      },
-    },
+        }.freeze,
+      }.freeze,
+    }.freeze,
   }.freeze
 
   VIDEO_CONVERTED_STYLES = {
-    small: VIDEO_STYLES[:small],
-    original: VIDEO_FORMAT,
+    small: VIDEO_STYLES[:small].freeze,
+    original: VIDEO_FORMAT.freeze,
+  }.freeze
+
+  THUMBNAIL_STYLES = {
+    original: IMAGE_STYLES[:small].freeze,
+  }.freeze
+
+  GLOBAL_CONVERT_OPTIONS = {
+    all: '-quality 90 -strip +set modify-date +set create-date',
   }.freeze
 
   IMAGE_LIMIT = (ENV['MAX_IMAGE_SIZE'] || 10.megabytes).to_i
@@ -144,18 +156,28 @@ class MediaAttachment < ApplicationRecord
   has_attached_file :file,
                     styles: ->(f) { file_styles f },
                     processors: ->(f) { file_processors f },
-                    convert_options: { all: '-quality 90 -strip +set modify-date +set create-date' }
+                    convert_options: GLOBAL_CONVERT_OPTIONS
 
   validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
   validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :larger_media_format?
   validates_attachment_size :file, less_than: VIDEO_LIMIT, if: :larger_media_format?
-  remotable_attachment :file, VIDEO_LIMIT, suppress_errors: false
+  remotable_attachment :file, VIDEO_LIMIT, suppress_errors: false, download_on_assign: false, attribute_name: :remote_url
+
+  has_attached_file :thumbnail,
+                    styles: THUMBNAIL_STYLES,
+                    processors: [:lazy_thumbnail, :blurhash_transcoder],
+                    convert_options: GLOBAL_CONVERT_OPTIONS
+
+  validates_attachment_content_type :thumbnail, content_type: IMAGE_MIME_TYPES
+  validates_attachment_size :thumbnail, less_than: IMAGE_LIMIT
+  remotable_attachment :thumbnail, IMAGE_LIMIT, suppress_errors: true, download_on_assign: false
 
   include Attachmentable
 
   validates :account, presence: true
   validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH }, if: :local?
   validates :file, presence: true, if: :local?
+  validates :thumbnail, absence: true, if: -> { local? && !audio_or_video? }
 
   scope :attached,   -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
   scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
@@ -194,15 +216,17 @@ class MediaAttachment < ApplicationRecord
 
     x, y = (point.is_a?(Enumerable) ? point : point.split(',')).map(&:to_f)
 
-    meta = file.instance_read(:meta) || {}
+    meta = (file.instance_read(:meta) || {}).with_indifferent_access.slice(:focus, :original, :small)
     meta['focus'] = { 'x' => x, 'y' => y }
 
     file.instance_write(:meta, meta)
   end
 
   def focus
-    x = file.meta['focus']['x']
-    y = file.meta['focus']['y']
+    x = file.meta&.dig('focus', 'x')
+    y = file.meta&.dig('focus', 'y')
+
+    return if x.nil? || y.nil?
 
     "#{x},#{y}"
   end
@@ -213,6 +237,10 @@ class MediaAttachment < ApplicationRecord
     @delay_processing
   end
 
+  def delay_processing_for_attachment?(attachment_name)
+    @delay_processing && attachment_name == :file
+  end
+
   after_commit :enqueue_processing, on: :create
   after_commit :reset_parent_cache, on: :update
 
@@ -220,10 +248,10 @@ class MediaAttachment < ApplicationRecord
   before_create :set_shortcode
   before_create :set_processing
 
-  before_post_process :set_type_and_extension
-  before_post_process :check_video_dimensions
+  after_post_process :set_meta
 
-  before_save :set_meta
+  before_file_post_process :set_type_and_extension
+  before_file_post_process :check_video_dimensions
 
   class << self
     def supported_mime_types
@@ -236,25 +264,25 @@ class MediaAttachment < ApplicationRecord
 
     private
 
-    def file_styles(f)
-      if f.instance.file_content_type == 'image/gif' || VIDEO_CONVERTIBLE_MIME_TYPES.include?(f.instance.file_content_type)
+    def file_styles(attachment)
+      if attachment.instance.file_content_type == 'image/gif' || VIDEO_CONVERTIBLE_MIME_TYPES.include?(attachment.instance.file_content_type)
         VIDEO_CONVERTED_STYLES
-      elsif IMAGE_MIME_TYPES.include?(f.instance.file_content_type)
+      elsif IMAGE_MIME_TYPES.include?(attachment.instance.file_content_type)
         IMAGE_STYLES
-      elsif VIDEO_MIME_TYPES.include?(f.instance.file_content_type)
+      elsif VIDEO_MIME_TYPES.include?(attachment.instance.file_content_type)
         VIDEO_STYLES
       else
         AUDIO_STYLES
       end
     end
 
-    def file_processors(f)
-      if f.file_content_type == 'image/gif'
+    def file_processors(instance)
+      if instance.file_content_type == 'image/gif'
         [:gif_transcoder, :blurhash_transcoder]
-      elsif VIDEO_MIME_TYPES.include?(f.file_content_type)
+      elsif VIDEO_MIME_TYPES.include?(instance.file_content_type)
         [:video_transcoder, :blurhash_transcoder, :type_corrector]
-      elsif AUDIO_MIME_TYPES.include?(f.file_content_type)
-        [:transcoder, :type_corrector]
+      elsif AUDIO_MIME_TYPES.include?(instance.file_content_type)
+        [:image_extractor, :transcoder, :type_corrector]
       else
         [:lazy_thumbnail, :blurhash_transcoder, :type_corrector]
       end
@@ -297,7 +325,7 @@ class MediaAttachment < ApplicationRecord
   def check_video_dimensions
     return unless (video? || gifv?) && file.queued_for_write[:original].present?
 
-    movie = FFMPEG::Movie.new(file.queued_for_write[:original].path)
+    movie = ffmpeg_data(file.queued_for_write[:original].path)
 
     return unless movie.valid?
 
@@ -306,20 +334,18 @@ class MediaAttachment < ApplicationRecord
   end
 
   def set_meta
-    meta = populate_meta
-
-    return if meta == {}
-
-    file.instance_write :meta, meta
+    file.instance_write :meta, populate_meta
   end
 
   def populate_meta
-    meta = file.instance_read(:meta) || {}
+    meta = (file.instance_read(:meta) || {}).with_indifferent_access.slice(:focus, :original, :small)
 
     file.queued_for_write.each do |style, file|
       meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file)
     end
 
+    meta[:small] = image_geometry(thumbnail.queued_for_write[:original]) if thumbnail.queued_for_write.key?(:original)
+
     meta
   end
 
@@ -337,7 +363,7 @@ class MediaAttachment < ApplicationRecord
   end
 
   def video_metadata(file)
-    movie = FFMPEG::Movie.new(file.path)
+    movie = ffmpeg_data(file.path)
 
     return {} unless movie.valid?
 
@@ -350,6 +376,13 @@ class MediaAttachment < ApplicationRecord
     }.compact
   end
 
+  # We call this method about 3 different times on potentially different
+  # paths but ultimately the same file, so it makes sense to memoize the
+  # result while disregarding the path
+  def ffmpeg_data(path = nil)
+    @ffmpeg_data ||= FFMPEG::Movie.new(path)
+  end
+
   def enqueue_processing
     PostProcessMediaWorker.perform_async(id) if delay_processing?
   end
diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb
index 08614b67c..d662380f6 100644
--- a/app/presenters/account_relationships_presenter.rb
+++ b/app/presenters/account_relationships_presenter.rb
@@ -3,7 +3,7 @@
 class AccountRelationshipsPresenter
   attr_reader :following, :followed_by, :blocking, :blocked_by,
               :muting, :requested, :domain_blocking,
-              :endorsed
+              :endorsed, :account_note
 
   def initialize(account_ids, current_account_id, **options)
     @account_ids        = account_ids.map { |a| a.is_a?(Account) ? a.id : a.to_i }
@@ -17,6 +17,7 @@ class AccountRelationshipsPresenter
     @requested       = cached[:requested].merge(Account.requested_map(@uncached_account_ids, @current_account_id))
     @domain_blocking = cached[:domain_blocking].merge(Account.domain_blocking_map(@uncached_account_ids, @current_account_id))
     @endorsed        = cached[:endorsed].merge(Account.endorsed_map(@uncached_account_ids, @current_account_id))
+    @account_note    = cached[:account_note].merge(Account.account_note_map(@uncached_account_ids, @current_account_id))
 
     cache_uncached!
 
@@ -28,6 +29,7 @@ class AccountRelationshipsPresenter
     @requested.merge!(options[:requested_map] || {})
     @domain_blocking.merge!(options[:domain_blocking_map] || {})
     @endorsed.merge!(options[:endorsed_map] || {})
+    @account_note.merge!(options[:account_note_map] || {})
   end
 
   private
@@ -44,6 +46,7 @@ class AccountRelationshipsPresenter
       requested: {},
       domain_blocking: {},
       endorsed: {},
+      account_note: {},
     }
 
     @uncached_account_ids = []
@@ -72,6 +75,7 @@ class AccountRelationshipsPresenter
         requested:       { account_id => requested[account_id] },
         domain_blocking: { account_id => domain_blocking[account_id] },
         endorsed:        { account_id => endorsed[account_id] },
+        account_note:    { account_id => account_note[account_id] },
       }
 
       Rails.cache.write("relationship:#{@current_account_id}:#{account_id}", maps_for_account, expires_in: 1.day)
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index e2d2b6bec..a06cd17d4 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -172,6 +172,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
     attributes :type, :media_type, :url, :name, :blurhash
     attribute :focal_point, if: :focal_point?
 
+    has_one :icon, serializer: ActivityPub::ImageSerializer, if: :thumbnail?
+
     def type
       'Document'
     end
@@ -195,6 +197,14 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
     def focal_point
       [object.file.meta['focus']['x'], object.file.meta['focus']['y']]
     end
+
+    def icon
+      object.thumbnail
+    end
+
+    def thumbnail?
+      object.thumbnail.present?
+    end
   end
 
   class MentionSerializer < ActivityPub::Serializer
diff --git a/app/serializers/rest/media_attachment_serializer.rb b/app/serializers/rest/media_attachment_serializer.rb
index cc10e3001..e65f7acf1 100644
--- a/app/serializers/rest/media_attachment_serializer.rb
+++ b/app/serializers/rest/media_attachment_serializer.rb
@@ -28,7 +28,9 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
   def preview_url
     if object.needs_redownload?
       media_proxy_url(object.id, :small)
-    else
+    elsif object.thumbnail.present?
+      full_asset_url(object.thumbnail.url(:original))
+    elsif object.file.styles.key?(:small)
       full_asset_url(object.file.url(:small))
     end
   end
diff --git a/app/serializers/rest/relationship_serializer.rb b/app/serializers/rest/relationship_serializer.rb
index 1a3fd915c..e295fb847 100644
--- a/app/serializers/rest/relationship_serializer.rb
+++ b/app/serializers/rest/relationship_serializer.rb
@@ -3,7 +3,7 @@
 class REST::RelationshipSerializer < ActiveModel::Serializer
   attributes :id, :following, :showing_reblogs, :followed_by, :blocking, :blocked_by,
              :muting, :muting_notifications, :requested, :domain_blocking,
-             :endorsed
+             :endorsed, :note
 
   def id
     object.id.to_s
@@ -50,4 +50,8 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
   def endorsed
     instance_options[:relationships].endorsed[object.id] || false
   end
+
+  def note
+    (instance_options[:relationships].account_note[object.id] || {})[:comment]
+  end
 end
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index f4276cece..85b915ec6 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -89,8 +89,8 @@ class ActivityPub::ProcessAccountService < BaseService
   end
 
   def set_fetchable_attributes!
-    @account.avatar_remote_url = image_url('icon')  unless skip_download?
-    @account.header_remote_url = image_url('image') unless skip_download?
+    @account.avatar_remote_url = image_url('icon')  || '' unless skip_download?
+    @account.header_remote_url = image_url('image') || '' unless skip_download?
     @account.public_key        = public_key || ''
     @account.statuses_count    = outbox_total_items    if outbox_total_items.present?
     @account.following_count   = following_total_items if following_total_items.present?
diff --git a/app/views/accounts/_og.html.haml b/app/views/accounts/_og.html.haml
index 839576372..6350d7ed0 100644
--- a/app/views/accounts/_og.html.haml
+++ b/app/views/accounts/_og.html.haml
@@ -7,7 +7,7 @@
 = opengraph 'og:title', yield(:page_title).strip
 = opengraph 'og:description', description
 = opengraph 'og:image', full_asset_url(account.avatar.url(:original))
-= opengraph 'og:image:width', '120'
-= opengraph 'og:image:height', '120'
+= opengraph 'og:image:width', '400'
+= opengraph 'og:image:height', '400'
 = opengraph 'twitter:card', 'summary'
 = opengraph 'profile:username', acct(account)[1..-1]
diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml
index 7592161c9..8eac226e0 100644
--- a/app/views/admin/accounts/index.html.haml
+++ b/app/views/admin/accounts/index.html.haml
@@ -38,7 +38,7 @@
           = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.accounts.#{key}")
 
     .actions
-      %button= t('admin.accounts.search')
+      %button.button= t('admin.accounts.search')
       = link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button negative'
 
 .table-wrapper
diff --git a/app/views/admin/custom_emojis/index.html.haml b/app/views/admin/custom_emojis/index.html.haml
index 45cb7bee0..b6cf7ba64 100644
--- a/app/views/admin/custom_emojis/index.html.haml
+++ b/app/views/admin/custom_emojis/index.html.haml
@@ -31,7 +31,7 @@
         = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.custom_emojis.#{key}")
 
     .actions
-      %button= t('admin.accounts.search')
+      %button.button= t('admin.accounts.search')
       = link_to t('admin.accounts.reset'), admin_custom_emojis_path, class: 'button negative'
 
 = form_for(@form, url: batch_admin_custom_emojis_path) do |f|
diff --git a/app/views/admin/instances/index.html.haml b/app/views/admin/instances/index.html.haml
index a73b8dc92..696ba3c7f 100644
--- a/app/views/admin/instances/index.html.haml
+++ b/app/views/admin/instances/index.html.haml
@@ -27,7 +27,7 @@
           = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.instances.#{key}")
 
       .actions
-        %button= t('admin.accounts.search')
+        %button.button= t('admin.accounts.search')
         = link_to t('admin.accounts.reset'), admin_instances_path, class: 'button negative'
 
 %hr.spacer/
diff --git a/app/views/admin/reports/index.html.haml b/app/views/admin/reports/index.html.haml
index 2149fcc46..bb441380e 100644
--- a/app/views/admin/reports/index.html.haml
+++ b/app/views/admin/reports/index.html.haml
@@ -18,7 +18,7 @@
         = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.reports.#{key}")
 
     .actions
-      %button= t('admin.accounts.search')
+      %button.button= t('admin.accounts.search')
       = link_to t('admin.accounts.reset'), admin_reports_path, class: 'button negative'
 
 - @reports.group_by(&:target_account_id).each do |target_account_id, reports|
diff --git a/app/views/admin/tags/index.html.haml b/app/views/admin/tags/index.html.haml
index e64802275..72eef18a9 100644
--- a/app/views/admin/tags/index.html.haml
+++ b/app/views/admin/tags/index.html.haml
@@ -33,7 +33,7 @@
         = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.tags.#{key}")
 
     .actions
-      %button= t('admin.accounts.search')
+      %button.button= t('admin.accounts.search')
       = link_to t('admin.accounts.reset'), admin_tags_path, class: 'button negative'
 
 %hr.spacer/
diff --git a/app/views/media/player.html.haml b/app/views/media/player.html.haml
index ea868b3f6..3d308ee69 100644
--- a/app/views/media/player.html.haml
+++ b/app/views/media/player.html.haml
@@ -1,2 +1,16 @@
-%video{ poster: @media_attachment.file.url(:small), preload: 'auto', autoplay: 'autoplay', muted: 'muted', loop: 'loop', controls: 'controls', style: "width: #{@media_attachment.file.meta.dig('original', 'width')}px; height: #{@media_attachment.file.meta.dig('original', 'height')}px" }
-  %source{ src: @media_attachment.file.url(:original), type: @media_attachment.file_content_type }
+- content_for :header_tags do
+  = render_initial_state
+  = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
+
+- if @media_attachment.video?
+  = react_component :video, src: @media_attachment.file.url(:original), preview: @media_attachment.file.url(:small), blurhash: @media_attachment.blurhash, width: 670, height: 380, editable: true, detailed: true, inline: true, alt: @media_attachment.description do
+    %video{ controls: 'controls' }
+      %source{ src: @media_attachment.file.url(:original) }
+- elsif @media_attachment.gifv?
+  = react_component :media_gallery, height: 380, standalone: true, autoplay: true, media: [ActiveModelSerializers::SerializableResource.new(@media_attachment, serializer: REST::MediaAttachmentSerializer).as_json] do
+    %video{ autoplay: 'autoplay', muted: 'muted', loop: 'loop' }
+      %source{ src: @media_attachment.file.url(:original) }
+- elsif @media_attachment.audio?
+  = react_component :audio, src: @media_attachment.file.url(:original), poster: full_asset_url(@media_attachment.account.avatar_static_url), width: 670, height: 380, fullscreen: true, alt: @media_attachment.description, duration: @media_attachment.file.meta.dig(:original, :duration) do
+    %audio{ controls: 'controls' }
+      %source{ src: @media_attachment.file.url(:original) }
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index 8e409846a..d10017db9 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -1,4 +1,4 @@
-.detailed-status.detailed-status--flex
+.detailed-status.detailed-status--flex{ class: "detailed-status-#{status.visibility}" }
   .p-author.h-card
     = link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'detailed-status__display-name u-url', target: stream_link_target, rel: 'noopener' do
       .detailed-status__display-avatar
@@ -33,7 +33,7 @@
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - elsif status.media_attachments.first.audio?
       - audio = status.media_attachments.first
-      = react_component :audio, src: audio.file.url(:original), height: 130, alt: audio.description, preload: true, duration: audio.file.meta.dig(:original, :duration) do
+      = react_component :audio, src: audio.file.url(:original), poster: audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url, blurhash: audio.blurhash, width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - else
       = react_component :media_gallery, height: 380, sensitive: status.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
@@ -47,6 +47,9 @@
     = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime u-url u-uid', target: stream_link_target, rel: 'noopener noreferrer' do
       %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
     ·
+    %span.detailed-status__visibility-icon
+      = visibility_icon status
+    ·
     - if status.application && @account.user&.setting_show_application
       - if status.application.website.blank?
         %strong.detailed-status__application= status.application.name
@@ -61,18 +64,12 @@
       %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')
-    - elsif status.private_visibility? || status.limited_visibility?
-      %span.detailed-status__link<
-        = fa_icon('lock')
-    - else
+    - if status.public_visibility? || status.unlisted_visibility?
       = link_to remote_interaction_path(status, type: :reblog), class: 'modal-button detailed-status__link' do
         = fa_icon('retweet')
         %span.detailed-status__reblogs>= number_to_human status.reblogs_count, strip_insignificant_zeros: true
         = " "
-    ·
+      ·
     = link_to remote_interaction_path(status, type: :favourite), class: 'modal-button detailed-status__link' do
       = fa_icon('star')
       %span.detailed-status__favorites>= number_to_human status.favourites_count, strip_insignificant_zeros: true
diff --git a/app/views/statuses/_og_image.html.haml b/app/views/statuses/_og_image.html.haml
index 67f9274b6..c8b6147ef 100644
--- a/app/views/statuses/_og_image.html.haml
+++ b/app/views/statuses/_og_image.html.haml
@@ -27,12 +27,25 @@
         = opengraph 'og:video:height', media.file.meta.dig('original', 'height')
         = opengraph 'twitter:player:width', media.file.meta.dig('original', 'width')
         = opengraph 'twitter:player:height', media.file.meta.dig('original', 'height')
+    - elsif media.audio?
+      - player_card = true
+      = opengraph 'og:image', full_asset_url(account.avatar.url(:original))
+      = opengraph 'og:image:width', '400'
+      = opengraph 'og:image:height','400'
+      = opengraph 'og:audio', full_asset_url(media.file.url(:original))
+      = opengraph 'og:audio:secure_url', full_asset_url(media.file.url(:original))
+      = opengraph 'og:audio:type', media.file_content_type
+      = opengraph 'twitter:player', medium_player_url(media)
+      = opengraph 'twitter:player:stream', full_asset_url(media.file.url(:original))
+      = opengraph 'twitter:player:stream:content_type', media.file_content_type
+      = opengraph 'twitter:player:width', '670'
+      = opengraph 'twitter:player:height', '380'
   - if player_card
     = opengraph 'twitter:card', 'player'
   - else
     = opengraph 'twitter:card', 'summary_large_image'
 - else
   = opengraph 'og:image', full_asset_url(account.avatar.url(:original))
-  = opengraph 'og:image:width', '120'
-  = opengraph 'og:image:height','120'
+  = opengraph 'og:image:width', '400'
+  = opengraph 'og:image:height','400'
   = opengraph 'twitter:card', 'summary'
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index 7a0262c9d..a84f51e3b 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -1,8 +1,10 @@
-.status
+.status{ class: "status-#{status.visibility}" }
   .status__info
     = link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__relative-time u-url u-uid', target: stream_link_target, rel: 'noopener noreferrer' do
       %time.time-ago{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
     %data.dt-published{ value: status.created_at.to_time.iso8601 }
+    %span.status__visibility-icon
+      = visibility_icon status
 
     .p-author.h-card
       = link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name u-url', target: stream_link_target, rel: 'noopener noreferrer' do
@@ -37,7 +39,7 @@
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - elsif status.media_attachments.first.audio?
       - audio = status.media_attachments.first
-      = react_component :audio, src: audio.file.url(:original), height: 110, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) do
+      = react_component :audio, src: audio.file.url(:original), poster: audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url, blurhash: audio.blurhash, width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - else
       = react_component :media_gallery, height: 343, sensitive: status.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
diff --git a/app/workers/move_worker.rb b/app/workers/move_worker.rb
index 595730226..4d76461b0 100644
--- a/app/workers/move_worker.rb
+++ b/app/workers/move_worker.rb
@@ -12,6 +12,8 @@ class MoveWorker
     else
       queue_follow_unfollows!
     end
+
+    copy_account_notes!
   rescue ActiveRecord::RecordNotFound
     true
   end
@@ -34,4 +36,19 @@ class MoveWorker
       UnfollowFollowWorker.push_bulk(accounts.map(&:id)) { |follower_id| [follower_id, @source_account.id, @target_account.id, bypass_locked] }
     end
   end
+
+  def copy_account_notes!
+    AccountNote.where(target_account: @source_account).find_each do |note|
+      text = I18n.with_locale(note.account.user.locale || I18n.default_locale) do
+        I18n.t('move_handler.copy_account_note_text', acct: @source_account.acct)
+      end
+
+      new_note = AccountNote.find_by(account: note.account, target_account: @target_account)
+      if new_note.nil?
+        AccountNote.create!(account: note.account, target_account: @target_account, comment: [text, note.comment].join('\n'))
+      else
+        new_note.update!(comment: [text, note.comment, '\n', new_note.comment].join('\n'))
+      end
+    end
+  end
 end
diff --git a/app/workers/post_process_media_worker.rb b/app/workers/post_process_media_worker.rb
index 148ae5e2b..a904f35b1 100644
--- a/app/workers/post_process_media_worker.rb
+++ b/app/workers/post_process_media_worker.rb
@@ -25,8 +25,14 @@ class PostProcessMediaWorker
     media_attachment = MediaAttachment.find(media_attachment_id)
     media_attachment.processing = :in_progress
     media_attachment.save
+
+    # Because paperclip-av-transcover overwrites this attribute
+    # we will save it here and restore it after reprocess is done
+    previous_meta = media_attachment.file_meta
+
     media_attachment.file.reprocess!(:original)
     media_attachment.processing = :complete
+    media_attachment.file_meta = previous_meta.merge(media_attachment.file_meta).with_indifferent_access.slice(:focus, :original, :small)
     media_attachment.save
   rescue ActiveRecord::RecordNotFound
     true
diff --git a/app/workers/redownload_media_worker.rb b/app/workers/redownload_media_worker.rb
index 071501a49..0638cd0f0 100644
--- a/app/workers/redownload_media_worker.rb
+++ b/app/workers/redownload_media_worker.rb
@@ -11,7 +11,8 @@ class RedownloadMediaWorker
 
     return if media_attachment.remote_url.blank?
 
-    media_attachment.file_remote_url = media_attachment.remote_url
+    media_attachment.download_file!
+    media_attachment.download_thumbnail!
     media_attachment.save
   rescue ActiveRecord::RecordNotFound
     true