about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/activitypub/followers_synchronizations_controller.rb36
-rw-r--r--app/controllers/activitypub/inboxes_controller.rb14
-rw-r--r--app/controllers/admin/ip_blocks_controller.rb56
-rw-r--r--app/controllers/api/v1/accounts_controller.rb4
-rw-r--r--app/controllers/api/v1/mutes_controller.rb33
-rw-r--r--app/controllers/auth/registrations_controller.rb6
-rw-r--r--app/controllers/settings/preferences_controller.rb1
-rw-r--r--app/helpers/admin/action_logs_helper.rb4
-rw-r--r--app/helpers/webfinger_helper.rb33
-rw-r--r--app/javascript/core/admin.js1
-rw-r--r--app/javascript/core/auth.js1
-rw-r--r--app/javascript/core/common.js1
-rw-r--r--app/javascript/core/embed.js2
-rw-r--r--app/javascript/core/public.js1
-rw-r--r--app/javascript/core/settings.js1
-rw-r--r--app/javascript/core/two_factor_authentication.js1
-rw-r--r--app/javascript/flavours/glitch/actions/accounts.js4
-rw-r--r--app/javascript/flavours/glitch/actions/markers.js8
-rw-r--r--app/javascript/flavours/glitch/actions/mutes.js10
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js44
-rw-r--r--app/javascript/flavours/glitch/actions/picture_in_picture.js38
-rw-r--r--app/javascript/flavours/glitch/components/account.js7
-rw-r--r--app/javascript/flavours/glitch/components/animated_number.js17
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_emoji.js2
-rw-r--r--app/javascript/flavours/glitch/components/column_header.js18
-rw-r--r--app/javascript/flavours/glitch/components/icon_button.js12
-rw-r--r--app/javascript/flavours/glitch/components/icon_with_badge.js4
-rw-r--r--app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js69
-rw-r--r--app/javascript/flavours/glitch/components/status.js16
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js22
-rw-r--r--app/javascript/flavours/glitch/containers/mastodon.js7
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js10
-rw-r--r--app/javascript/flavours/glitch/features/audio/index.js60
-rw-r--r--app/javascript/flavours/glitch/features/emoji_picker/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/components/announcements.js3
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/index.js33
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/column_settings.js36
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js30
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js5
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js35
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js13
-rw-r--r--app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js162
-rw-r--r--app/javascript/flavours/glitch/features/picture_in_picture/components/header.js40
-rw-r--r--app/javascript/flavours/glitch/features/picture_in_picture/index.js88
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js7
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js5
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js3
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/media_modal.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/mute_modal.js44
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/video_modal.js4
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js6
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js66
-rw-r--r--app/javascript/flavours/glitch/locales/es.js114
-rw-r--r--app/javascript/flavours/glitch/names.yml8
-rw-r--r--app/javascript/flavours/glitch/packs/about.js1
-rw-r--r--app/javascript/flavours/glitch/packs/common.js1
-rw-r--r--app/javascript/flavours/glitch/packs/error.js1
-rw-r--r--app/javascript/flavours/glitch/packs/home.js1
-rw-r--r--app/javascript/flavours/glitch/packs/public.js1
-rw-r--r--app/javascript/flavours/glitch/packs/settings.js1
-rw-r--r--app/javascript/flavours/glitch/packs/share.js1
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/local_settings.js3
-rw-r--r--app/javascript/flavours/glitch/reducers/mutes.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/notifications.js14
-rw-r--r--app/javascript/flavours/glitch/reducers/picture_in_picture.js22
-rw-r--r--app/javascript/flavours/glitch/reducers/relationships.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js13
-rw-r--r--app/javascript/flavours/glitch/reducers/user_lists.js20
-rw-r--r--app/javascript/flavours/glitch/styles/components/accounts.scss121
-rw-r--r--app/javascript/flavours/glitch/styles/components/columns.scss156
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss26
-rw-r--r--app/javascript/flavours/glitch/styles/components/modal.scss16
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss124
-rw-r--r--app/javascript/flavours/glitch/styles/mastodon-light/diff.scss5
-rw-r--r--app/javascript/flavours/glitch/util/config.js10
-rw-r--r--app/javascript/flavours/glitch/util/emoji/index.js3
-rw-r--r--app/javascript/flavours/glitch/util/main.js2
-rw-r--r--app/javascript/flavours/glitch/util/notifications.js29
-rw-r--r--app/javascript/flavours/vanilla/names.yml8
-rw-r--r--app/javascript/mastodon/actions/accounts.js4
-rw-r--r--app/javascript/mastodon/actions/markers.js8
-rw-r--r--app/javascript/mastodon/actions/mutes.js10
-rw-r--r--app/javascript/mastodon/actions/notifications.js49
-rw-r--r--app/javascript/mastodon/actions/onboarding.js13
-rw-r--r--app/javascript/mastodon/components/account.js7
-rw-r--r--app/javascript/mastodon/components/autosuggest_emoji.js3
-rw-r--r--app/javascript/mastodon/components/column_header.js18
-rw-r--r--app/javascript/mastodon/components/icon_button.js1
-rw-r--r--app/javascript/mastodon/components/icon_with_badge.js4
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js2
-rw-r--r--app/javascript/mastodon/features/emoji/emoji.js3
-rw-r--r--app/javascript/mastodon/features/getting_started/components/announcements.js6
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js4
-rw-r--r--app/javascript/mastodon/features/introduction/index.js3
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js36
-rw-r--r--app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js30
-rw-r--r--app/javascript/mastodon/features/notifications/components/setting_toggle.js5
-rw-r--r--app/javascript/mastodon/features/notifications/containers/column_settings_container.js35
-rw-r--r--app/javascript/mastodon/features/notifications/index.js9
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js4
-rw-r--r--app/javascript/mastodon/features/ui/components/focal_point_modal.js3
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/mute_modal.js44
-rw-r--r--app/javascript/mastodon/features/ui/index.js8
-rw-r--r--app/javascript/mastodon/initial_state.js1
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json164
-rw-r--r--app/javascript/mastodon/locales/en.json21
-rw-r--r--app/javascript/mastodon/locales/ja.json2
-rw-r--r--app/javascript/mastodon/main.js2
-rw-r--r--app/javascript/mastodon/reducers/mutes.js4
-rw-r--r--app/javascript/mastodon/reducers/notifications.js17
-rw-r--r--app/javascript/mastodon/reducers/relationships.js2
-rw-r--r--app/javascript/mastodon/reducers/settings.js13
-rw-r--r--app/javascript/mastodon/reducers/user_lists.js20
-rw-r--r--app/javascript/mastodon/utils/config.js10
-rw-r--r--app/javascript/mastodon/utils/notifications.js29
-rw-r--r--app/javascript/packs/about.js1
-rw-r--r--app/javascript/packs/application.js1
-rw-r--r--app/javascript/packs/common.js1
-rw-r--r--app/javascript/packs/error.js1
-rw-r--r--app/javascript/packs/public-path.js21
-rw-r--r--app/javascript/packs/public.js1
-rw-r--r--app/javascript/packs/share.js1
-rw-r--r--app/javascript/skins/glitch/contrast/names.yml4
-rw-r--r--app/javascript/skins/glitch/mastodon-light/names.yml5
-rw-r--r--app/javascript/skins/vanilla/contrast/names.yml4
-rw-r--r--app/javascript/skins/vanilla/mastodon-light/names.yml4
-rw-r--r--app/javascript/styles/mastodon-light/diff.scss5
-rw-r--r--app/javascript/styles/mastodon/components.scss76
-rw-r--r--app/lib/activitypub/activity.rb2
-rw-r--r--app/lib/activitypub/tag_manager.rb4
-rw-r--r--app/lib/fast_ip_map.rb32
-rw-r--r--app/lib/sanitize_config.rb1
-rw-r--r--app/lib/settings/scoped_settings.rb2
-rw-r--r--app/lib/user_settings_decorator.rb5
-rw-r--r--app/lib/webfinger.rb93
-rw-r--r--app/models/account.rb6
-rw-r--r--app/models/account_alias.rb2
-rw-r--r--app/models/account_migration.rb2
-rw-r--r--app/models/concerns/account_interactions.rb27
-rw-r--r--app/models/concerns/expireable.rb10
-rw-r--r--app/models/follow.rb8
-rw-r--r--app/models/form/ip_block_batch.rb31
-rw-r--r--app/models/form/redirect.rb2
-rw-r--r--app/models/ip_block.rb41
-rw-r--r--app/models/mute.rb3
-rw-r--r--app/models/remote_follow.rb10
-rw-r--r--app/models/user.rb18
-rw-r--r--app/policies/ip_block_policy.rb15
-rw-r--r--app/serializers/initial_state_serializer.rb1
-rw-r--r--app/serializers/rest/muted_account_serializer.rb10
-rw-r--r--app/services/activitypub/fetch_remote_account_service.rb7
-rw-r--r--app/services/activitypub/prepare_followers_synchronization_service.rb13
-rw-r--r--app/services/activitypub/process_account_service.rb2
-rw-r--r--app/services/activitypub/synchronize_followers_service.rb74
-rw-r--r--app/services/app_sign_up_service.rb4
-rw-r--r--app/services/mute_service.rb6
-rw-r--r--app/services/process_mentions_service.rb4
-rw-r--r--app/services/resolve_account_service.rb9
-rw-r--r--app/views/admin/ip_blocks/_ip_block.html.haml11
-rw-r--r--app/views/admin/ip_blocks/index.html.haml25
-rw-r--r--app/views/admin/ip_blocks/new.html.haml20
-rw-r--r--app/views/admin/pending_accounts/_account.html.haml2
-rwxr-xr-xapp/views/layouts/application.html.haml1
-rw-r--r--app/views/layouts/embedded.html.haml1
-rw-r--r--app/views/settings/preferences/appearance/show.html.haml1
-rw-r--r--app/views/statuses/_simple_status.html.haml13
-rw-r--r--app/views/well_known/host_meta/show.xml.ruby1
-rw-r--r--app/workers/activitypub/delivery_worker.rb10
-rw-r--r--app/workers/activitypub/distribution_worker.rb2
-rw-r--r--app/workers/activitypub/followers_synchronization_worker.rb14
-rw-r--r--app/workers/delete_mute_worker.rb10
-rw-r--r--app/workers/scheduler/ip_cleanup_scheduler.rb18
174 files changed, 2711 insertions, 419 deletions
diff --git a/app/controllers/activitypub/followers_synchronizations_controller.rb b/app/controllers/activitypub/followers_synchronizations_controller.rb
new file mode 100644
index 000000000..525031105
--- /dev/null
+++ b/app/controllers/activitypub/followers_synchronizations_controller.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseController
+  include SignatureVerification
+  include AccountOwnedConcern
+
+  before_action :require_signature!
+  before_action :set_items
+  before_action :set_cache_headers
+
+  def show
+    expires_in 0, public: false
+    render json: collection_presenter,
+           serializer: ActivityPub::CollectionSerializer,
+           adapter: ActivityPub::Adapter,
+           content_type: 'application/activity+json'
+  end
+
+  private
+
+  def uri_prefix
+    signed_request_account.uri[/http(s?):\/\/[^\/]+\//]
+  end
+
+  def set_items
+    @items = @account.followers.where(Account.arel_table[:uri].matches(uri_prefix + '%', false, true)).pluck(:uri)
+  end
+
+  def collection_presenter
+    ActivityPub::CollectionPresenter.new(
+      id: account_followers_synchronization_url(@account),
+      type: :ordered,
+      items: @items
+    )
+  end
+end
diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb
index 0a561e7f0..fdb60d590 100644
--- a/app/controllers/activitypub/inboxes_controller.rb
+++ b/app/controllers/activitypub/inboxes_controller.rb
@@ -11,6 +11,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
 
   def create
     upgrade_account
+    process_collection_synchronization
     process_payload
     head 202
   end
@@ -52,6 +53,19 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
     DeliveryFailureTracker.reset!(signed_request_account.inbox_url)
   end
 
+  def process_collection_synchronization
+    raw_params = request.headers['Collection-Synchronization']
+    return if raw_params.blank? || ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] == 'true'
+
+    # Re-using the syntax for signature parameters
+    tree   = SignatureParamsParser.new.parse(raw_params)
+    params = SignatureParamsTransformer.new.apply(tree)
+
+    ActivityPub::PrepareFollowersSynchronizationService.new.call(signed_request_account, params)
+  rescue Parslet::ParseFailed
+    Rails.logger.warn 'Error parsing Collection-Synchronization header'
+  end
+
   def process_payload
     ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body, @account&.id)
   end
diff --git a/app/controllers/admin/ip_blocks_controller.rb b/app/controllers/admin/ip_blocks_controller.rb
new file mode 100644
index 000000000..92b8b0d2b
--- /dev/null
+++ b/app/controllers/admin/ip_blocks_controller.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Admin
+  class IpBlocksController < BaseController
+    def index
+      authorize :ip_block, :index?
+
+      @ip_blocks = IpBlock.page(params[:page])
+      @form      = Form::IpBlockBatch.new
+    end
+
+    def new
+      authorize :ip_block, :create?
+
+      @ip_block = IpBlock.new(ip: '', severity: :no_access, expires_in: 1.year)
+    end
+
+    def create
+      authorize :ip_block, :create?
+
+      @ip_block = IpBlock.new(resource_params)
+
+      if @ip_block.save
+        log_action :create, @ip_block
+        redirect_to admin_ip_blocks_path, notice: I18n.t('admin.ip_blocks.created_msg')
+      else
+        render :new
+      end
+    end
+
+    def batch
+      @form = Form::IpBlockBatch.new(form_ip_block_batch_params.merge(current_account: current_account, action: action_from_button))
+      @form.save
+    rescue ActionController::ParameterMissing
+      flash[:alert] = I18n.t('admin.ip_blocks.no_ip_block_selected')
+    rescue Mastodon::NotPermittedError
+      flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
+    ensure
+      redirect_to admin_ip_blocks_path
+    end
+
+    private
+
+    def resource_params
+      params.require(:ip_block).permit(:ip, :severity, :comment, :expires_in)
+    end
+
+    def action_from_button
+      'delete' if params[:delete]
+    end
+
+    def form_ip_block_batch_params
+      params.require(:form_ip_block_batch).permit(ip_block_ids: [])
+    end
+  end
+end
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index aef51a647..3e66ff212 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -20,7 +20,7 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def create
-    token    = AppSignUpService.new.call(doorkeeper_token.application, account_params)
+    token    = AppSignUpService.new.call(doorkeeper_token.application, request.remote_ip, account_params)
     response = Doorkeeper::OAuth::TokenResponse.new(token)
 
     headers.merge!(response.headers)
@@ -42,7 +42,7 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def mute
-    MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications))
+    MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications), duration: (params[:duration] || 0))
     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
   end
 
diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb
index a89f3d700..fd52511d7 100644
--- a/app/controllers/api/v1/mutes_controller.rb
+++ b/app/controllers/api/v1/mutes_controller.rb
@@ -6,25 +6,16 @@ class Api::V1::MutesController < Api::BaseController
   after_action :insert_pagination_headers
 
   def index
-    @data = @accounts = load_accounts
-    render json: @accounts, each_serializer: REST::AccountSerializer
+    @accounts = load_accounts
+    render json: @accounts, each_serializer: REST::MutedAccountSerializer
   end
 
-  def details
-    @data = @mutes = load_mutes
-    render json: @mutes, each_serializer: REST::MuteSerializer
-  end 
-
   private
 
   def load_accounts
     paginated_mutes.map(&:target_account)
   end
 
-  def load_mutes
-    paginated_mutes.includes(:account, :target_account).to_a
-  end
-
   def paginated_mutes
     @paginated_mutes ||= Mute.eager_load(:target_account)
                              .joins(:target_account)
@@ -43,34 +34,26 @@ class Api::V1::MutesController < Api::BaseController
 
   def next_path
     if records_continue?
-      url_for pagination_params(max_id: pagination_max_id)
+      api_v1_mutes_url pagination_params(max_id: pagination_max_id)
     end
   end
 
   def prev_path
-    unless @data.empty?
-      url_for pagination_params(since_id: pagination_since_id)
+    unless paginated_mutes.empty?
+      api_v1_mutes_url pagination_params(since_id: pagination_since_id)
     end
   end
 
   def pagination_max_id
-    if params[:action] == "details"
-      @mutes.last.id
-    else
-      paginated_mutes.last.id
-    end
+    paginated_mutes.last.id
   end
 
   def pagination_since_id
-    if params[:action] == "details"
-      @mutes.first.id
-    else
-      paginated_mutes.first.id
-    end
+    paginated_mutes.first.id
   end
 
   def records_continue?
-    @data.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
+    paginated_mutes.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
   end
 
   def pagination_params(core_params)
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 96d973394..23e5a22e1 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -46,9 +46,9 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   def build_resource(hash = nil)
     super(hash)
 
-    resource.locale             = I18n.locale
-    resource.invite_code        = params[:invite_code] if resource.invite_code.blank?
-    resource.current_sign_in_ip = request.remote_ip
+    resource.locale      = I18n.locale
+    resource.invite_code = params[:invite_code] if resource.invite_code.blank?
+    resource.sign_up_ip  = request.remote_ip
 
     resource.build_account if resource.account.nil?
   end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 87431f8cf..d05ceb53f 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -44,6 +44,7 @@ class Settings::PreferencesController < Settings::BaseController
       :setting_display_media,
       :setting_expand_spoilers,
       :setting_reduce_motion,
+      :setting_disable_swiping,
       :setting_system_font_ui,
       :setting_system_emoji_font,
       :setting_noindex,
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index 8e398c3b2..0f3ca36e2 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -29,6 +29,8 @@ module Admin::ActionLogsHelper
       link_to record.target_account.acct, admin_account_path(record.target_account_id)
     when 'Announcement'
       link_to truncate(record.text), edit_admin_announcement_path(record.id)
+    when 'IpBlock'
+      "#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})"
     end
   end
 
@@ -48,6 +50,8 @@ module Admin::ActionLogsHelper
       end
     when 'Announcement'
       truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text'])
+    when 'IpBlock'
+      "#{attributes['ip']}/#{attributes['ip'].prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{attributes['severity']}")})"
     end
   end
 end
diff --git a/app/helpers/webfinger_helper.rb b/app/helpers/webfinger_helper.rb
index ab7ca4698..482f4e19e 100644
--- a/app/helpers/webfinger_helper.rb
+++ b/app/helpers/webfinger_helper.rb
@@ -1,38 +1,7 @@
 # frozen_string_literal: true
 
-# Monkey-patch on monkey-patch.
-# Because it conflicts with the request.rb patch.
-class HTTP::Timeout::PerOperationOriginal < HTTP::Timeout::PerOperation
-  def connect(socket_class, host, port, nodelay = false)
-    ::Timeout.timeout(@connect_timeout, HTTP::TimeoutError) do
-      @socket = socket_class.open(host, port)
-      @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
-    end
-  end
-end
-
 module WebfingerHelper
   def webfinger!(uri)
-    hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri)
-
-    raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if !Rails.configuration.x.access_to_hidden_service && hidden_service_uri
-
-    opts = {
-      ssl: !hidden_service_uri,
-
-      headers: {
-        'User-Agent': Mastodon::Version.user_agent,
-      },
-
-      timeout_class: HTTP::Timeout::PerOperationOriginal,
-
-      timeout_options: {
-        write_timeout: 10,
-        connect_timeout: 5,
-        read_timeout: 10,
-      },
-    }
-
-    Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger
+    Webfinger.new(uri).perform
   end
 end
diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js
index f2334c254..bbc7cfac7 100644
--- a/app/javascript/core/admin.js
+++ b/app/javascript/core/admin.js
@@ -1,5 +1,6 @@
 //  This file will be loaded on admin pages, regardless of theme.
 
+import 'packs/public-path';
 import { delegate } from '@rails/ujs';
 import ready from '../mastodon/ready';
 
diff --git a/app/javascript/core/auth.js b/app/javascript/core/auth.js
index ca04730a3..d1d14d99e 100644
--- a/app/javascript/core/auth.js
+++ b/app/javascript/core/auth.js
@@ -1,2 +1,3 @@
+import 'packs/public-path';
 import './settings';
 import './two_factor_authentication';
diff --git a/app/javascript/core/common.js b/app/javascript/core/common.js
index 3b038ca40..1cee2f603 100644
--- a/app/javascript/core/common.js
+++ b/app/javascript/core/common.js
@@ -1,5 +1,6 @@
 //  This file will be loaded on all pages, regardless of theme.
 
+import 'packs/public-path';
 import 'font-awesome/css/font-awesome.css';
 
 require.context('../images/', true);
diff --git a/app/javascript/core/embed.js b/app/javascript/core/embed.js
index 6146e6592..9083eb7a3 100644
--- a/app/javascript/core/embed.js
+++ b/app/javascript/core/embed.js
@@ -1,5 +1,7 @@
 //  This file will be loaded on embed pages, regardless of theme.
 
+import 'packs/public-path';
+
 window.addEventListener('message', e => {
   const data = e.data || {};
 
diff --git a/app/javascript/core/public.js b/app/javascript/core/public.js
index 8c26f1404..b67fb13e5 100644
--- a/app/javascript/core/public.js
+++ b/app/javascript/core/public.js
@@ -1,5 +1,6 @@
 //  This file will be loaded on public pages, regardless of theme.
 
+import 'packs/public-path';
 import ready from '../mastodon/ready';
 
 const { delegate } = require('@rails/ujs');
diff --git a/app/javascript/core/settings.js b/app/javascript/core/settings.js
index 9403e339b..d5bb9532c 100644
--- a/app/javascript/core/settings.js
+++ b/app/javascript/core/settings.js
@@ -1,5 +1,6 @@
 //  This file will be loaded on settings pages, regardless of theme.
 
+import 'packs/public-path';
 import escapeTextContentForBrowser from 'escape-html';
 const { delegate } = require('@rails/ujs');
 import emojify from '../mastodon/features/emoji/emoji';
diff --git a/app/javascript/core/two_factor_authentication.js b/app/javascript/core/two_factor_authentication.js
index dde06be8c..f076cdf30 100644
--- a/app/javascript/core/two_factor_authentication.js
+++ b/app/javascript/core/two_factor_authentication.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import axios from 'axios';
 import * as WebAuthnJSON from '@github/webauthn-json';
 import ready from '../mastodon/ready';
diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js
index 428b62f68..912a3d179 100644
--- a/app/javascript/flavours/glitch/actions/accounts.js
+++ b/app/javascript/flavours/glitch/actions/accounts.js
@@ -274,11 +274,11 @@ export function unblockAccountFail(error) {
 };
 
 
-export function muteAccount(id, notifications) {
+export function muteAccount(id, notifications, duration=0) {
   return (dispatch, getState) => {
     dispatch(muteAccountRequest(id));
 
-    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => {
+    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => {
       // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
       dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
     }).catch(error => {
diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js
index 80bcada6e..c0e7a93af 100644
--- a/app/javascript/flavours/glitch/actions/markers.js
+++ b/app/javascript/flavours/glitch/actions/markers.js
@@ -100,8 +100,12 @@ export function submitMarkersSuccess({ home, notifications }) {
   };
 };
 
-export function submitMarkers() {
-  return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
+export function submitMarkers(params = {}) {
+  const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
+  if (params.immediate === true) {
+    debouncedSubmitMarkers.flush();
+  }
+  return result;
 };
 
 export const fetchMarkers = () => (dispatch, getState) => {
diff --git a/app/javascript/flavours/glitch/actions/mutes.js b/app/javascript/flavours/glitch/actions/mutes.js
index 927fc7415..2bacfadb7 100644
--- a/app/javascript/flavours/glitch/actions/mutes.js
+++ b/app/javascript/flavours/glitch/actions/mutes.js
@@ -13,6 +13,7 @@ export const MUTES_EXPAND_FAIL    = 'MUTES_EXPAND_FAIL';
 
 export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
 export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
+export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION';
 
 export function fetchMutes() {
   return (dispatch, getState) => {
@@ -104,3 +105,12 @@ export function toggleHideNotifications() {
     dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
   };
 }
+
+export function changeMuteDuration(duration) {
+  return dispatch => {
+    dispatch({
+      type: MUTES_CHANGE_DURATION,
+      duration,
+    });
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index 7f311153b..eb7087027 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -16,6 +16,7 @@ import { getFiltersRegex } from 'flavours/glitch/selectors';
 import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state';
 import compareId from 'flavours/glitch/util/compare_id';
 import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer';
+import { requestNotificationPermission } from 'flavours/glitch/util/notifications';
 
 export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
 export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@@ -46,8 +47,12 @@ export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
 
 export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY';
 
+
 export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
 
+export const NOTIFICATIONS_SET_BROWSER_SUPPORT    = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
+export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
+
 defineMessages({
   mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
 });
@@ -327,3 +332,42 @@ export function markNotificationsAsRead() {
     type: NOTIFICATIONS_MARK_AS_READ,
   };
 };
+
+// Browser support
+export function setupBrowserNotifications() {
+  return dispatch => {
+    dispatch(setBrowserSupport('Notification' in window));
+    if ('Notification' in window) {
+      dispatch(setBrowserPermission(Notification.permission));
+    }
+
+    if ('Notification' in window && 'permissions' in navigator) {
+      navigator.permissions.query({ name: 'notifications' }).then((status) => {
+        status.onchange = () => dispatch(setBrowserPermission(Notification.permission));
+      });
+    }
+  };
+}
+
+export function requestBrowserPermission(callback = noOp) {
+  return dispatch => {
+    requestNotificationPermission((permission) => {
+      dispatch(setBrowserPermission(permission));
+      callback(permission);
+    });
+  };
+};
+
+export function setBrowserSupport (value) {
+  return {
+    type: NOTIFICATIONS_SET_BROWSER_SUPPORT,
+    value,
+  };
+}
+
+export function setBrowserPermission (value) {
+  return {
+    type: NOTIFICATIONS_SET_BROWSER_PERMISSION,
+    value,
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/picture_in_picture.js b/app/javascript/flavours/glitch/actions/picture_in_picture.js
new file mode 100644
index 000000000..4085cb59e
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/picture_in_picture.js
@@ -0,0 +1,38 @@
+// @ts-check
+
+export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY';
+export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE';
+
+/**
+ * @typedef MediaProps
+ * @property {string} src
+ * @property {boolean} muted
+ * @property {number} volume
+ * @property {number} currentTime
+ * @property {string} poster
+ * @property {string} backgroundColor
+ * @property {string} foregroundColor
+ * @property {string} accentColor
+ */
+
+/**
+ * @param {string} statusId
+ * @param {string} accountId
+ * @param {string} playerType
+ * @param {MediaProps} props
+ * @return {object}
+ */
+export const deployPictureInPicture = (statusId, accountId, playerType, props) => ({
+  type: PICTURE_IN_PICTURE_DEPLOY,
+  statusId,
+  accountId,
+  playerType,
+  props,
+});
+
+/*
+ * @return {object}
+ */
+export const removePictureInPicture = () => ({
+  type: PICTURE_IN_PICTURE_REMOVE,
+});
diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js
index f3e58dfe3..23399c630 100644
--- a/app/javascript/flavours/glitch/components/account.js
+++ b/app/javascript/flavours/glitch/components/account.js
@@ -8,6 +8,7 @@ import IconButton from './icon_button';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { me } from 'flavours/glitch/util/initial_state';
+import RelativeTimestamp from './relative_timestamp';
 
 const messages = defineMessages({
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
@@ -116,6 +117,11 @@ class Account extends ImmutablePureComponent {
       }
     }
 
+    let mute_expires_at;
+    if (account.get('mute_expires_at')) {
+      mute_expires_at =  <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>;
+    }
+
     return small ? (
       <Permalink
         className='account small'
@@ -138,6 +144,7 @@ class Account extends ImmutablePureComponent {
         <div className='account__wrapper'>
           <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
+            {mute_expires_at}
             <DisplayName account={account} />
           </Permalink>
           {buttons ?
diff --git a/app/javascript/flavours/glitch/components/animated_number.js b/app/javascript/flavours/glitch/components/animated_number.js
index e3235e368..3cc5173dd 100644
--- a/app/javascript/flavours/glitch/components/animated_number.js
+++ b/app/javascript/flavours/glitch/components/animated_number.js
@@ -5,10 +5,21 @@ import TransitionMotion from 'react-motion/lib/TransitionMotion';
 import spring from 'react-motion/lib/spring';
 import { reduceMotion } from 'flavours/glitch/util/initial_state';
 
+const obfuscatedCount = count => {
+  if (count < 0) {
+    return 0;
+  } else if (count <= 1) {
+    return count;
+  } else {
+    return '1+';
+  }
+};
+
 export default class AnimatedNumber extends React.PureComponent {
 
   static propTypes = {
     value: PropTypes.number.isRequired,
+    obfuscate: PropTypes.bool,
   };
 
   state = {
@@ -36,11 +47,11 @@ export default class AnimatedNumber extends React.PureComponent {
   }
 
   render () {
-    const { value } = this.props;
+    const { value, obfuscate } = this.props;
     const { direction } = this.state;
 
     if (reduceMotion) {
-      return <FormattedNumber value={value} />;
+      return obfuscate ? obfuscatedCount(value) : <FormattedNumber value={value} />;
     }
 
     const styles = [{
@@ -54,7 +65,7 @@ export default class AnimatedNumber extends React.PureComponent {
         {items => (
           <span className='animated-number'>
             {items.map(({ key, data, style }) => (
-              <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}><FormattedNumber value={data} /></span>
+              <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span>
             ))}
           </span>
         )}
diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js
index c8609e48f..d04c1eb68 100644
--- a/app/javascript/flavours/glitch/components/autosuggest_emoji.js
+++ b/app/javascript/flavours/glitch/components/autosuggest_emoji.js
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
 
-const assetHost = process.env.CDN_HOST || '';
+import { assetHost } from 'flavours/glitch/util/config';
 
 export default class AutosuggestEmoji extends React.PureComponent {
 
diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js
index 01bd4a246..ccd0714f1 100644
--- a/app/javascript/flavours/glitch/components/column_header.js
+++ b/app/javascript/flavours/glitch/components/column_header.js
@@ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent {
     onMove: PropTypes.func,
     onClick: PropTypes.func,
     appendContent: PropTypes.node,
+    collapseIssues: PropTypes.bool,
   };
 
   state = {
@@ -88,7 +89,7 @@ class ColumnHeader extends React.PureComponent {
   }
 
   render () {
-    const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props;
+    const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
     const { collapsed, animating } = this.state;
 
     const wrapperClassName = classNames('column-header__wrapper', {
@@ -150,7 +151,20 @@ class ColumnHeader extends React.PureComponent {
     }
 
     if (children || (multiColumn && this.props.onPin)) {
-      collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>;
+      collapseButton = (
+        <button
+          className={collapsibleButtonClassName}
+          title={formatMessage(collapsed ? messages.show : messages.hide)}
+          aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
+          aria-pressed={collapsed ? 'false' : 'true'}
+          onClick={this.handleToggleClick}
+        >
+          <i className='icon-with-badge'>
+            <Icon id='sliders' />
+            {collapseIssues && <i className='icon-with-badge__issue-badge' />}
+          </i>
+        </button>
+      );
     }
 
     const hasTitle = icon && title;
diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js
index e134d0a39..58d3568dd 100644
--- a/app/javascript/flavours/glitch/components/icon_button.js
+++ b/app/javascript/flavours/glitch/components/icon_button.js
@@ -4,6 +4,7 @@ import spring from 'react-motion/lib/spring';
 import PropTypes from 'prop-types';
 import classNames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
+import AnimatedNumber from 'flavours/glitch/components/animated_number';
 
 export default class IconButton extends React.PureComponent {
 
@@ -27,6 +28,8 @@ export default class IconButton extends React.PureComponent {
     overlay: PropTypes.bool,
     tabIndex: PropTypes.string,
     label: PropTypes.string,
+    counter: PropTypes.number,
+    obfuscateCount: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -104,6 +107,8 @@ export default class IconButton extends React.PureComponent {
       pressed,
       tabIndex,
       title,
+      counter,
+      obfuscateCount,
     } = this.props;
 
     const {
@@ -118,8 +123,13 @@ export default class IconButton extends React.PureComponent {
       activate,
       deactivate,
       overlayed: overlay,
+      'icon-button--with-counter': typeof counter !== 'undefined',
     });
 
+    if (typeof counter !== 'undefined') {
+      style.width = 'auto';
+    }
+
     return (
       <button
         aria-label={title}
@@ -135,7 +145,7 @@ export default class IconButton extends React.PureComponent {
         tabIndex={tabIndex}
         disabled={disabled}
       >
-        <Icon id={icon} fixedWidth aria-hidden='true' />
+        <Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>}
         {this.props.label}
       </button>
     );
diff --git a/app/javascript/flavours/glitch/components/icon_with_badge.js b/app/javascript/flavours/glitch/components/icon_with_badge.js
index 219efc28c..a42ba4589 100644
--- a/app/javascript/flavours/glitch/components/icon_with_badge.js
+++ b/app/javascript/flavours/glitch/components/icon_with_badge.js
@@ -4,16 +4,18 @@ import Icon from 'flavours/glitch/components/icon';
 
 const formatNumber = num => num > 40 ? '40+' : num;
 
-const IconWithBadge = ({ id, count, className }) => (
+const IconWithBadge = ({ id, count, issueBadge, className }) => (
   <i className='icon-with-badge'>
     <Icon id={id} fixedWidth className={className} />
     {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
+    {issueBadge && <i className='icon-with-badge__issue-badge' />}
   </i>
 );
 
 IconWithBadge.propTypes = {
   id: PropTypes.string.isRequired,
   count: PropTypes.number.isRequired,
+  issueBadge: PropTypes.bool,
   className: PropTypes.string,
 };
 
diff --git a/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js
new file mode 100644
index 000000000..01dce0a38
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js
@@ -0,0 +1,69 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Icon from 'flavours/glitch/components/icon';
+import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
+import { connect } from 'react-redux';
+import { debounce } from 'lodash';
+import { FormattedMessage } from 'react-intl';
+
+export default @connect()
+class PictureInPicturePlaceholder extends React.PureComponent {
+
+  static propTypes = {
+    width: PropTypes.number,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  state = {
+    width: this.props.width,
+    height: this.props.width && (this.props.width / (16/9)),
+  };
+
+  handleClick = () => {
+    const { dispatch } = this.props;
+    dispatch(removePictureInPicture());
+  }
+
+  setRef = c => {
+    this.node = c;
+
+    if (this.node) {
+      this._setDimensions();
+    }
+  }
+
+  _setDimensions () {
+    const width  = this.node.offsetWidth;
+    const height = width / (16/9);
+
+    this.setState({ width, height });
+  }
+
+  componentDidMount () {
+    window.addEventListener('resize', this.handleResize, { passive: true });
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('resize', this.handleResize);
+  }
+
+  handleResize = debounce(() => {
+    if (this.node) {
+      this._setDimensions();
+    }
+  }, 250, {
+    trailing: true,
+  });
+
+  render () {
+    const { height } = this.state;
+
+    return (
+      <div ref={this.setRef} className='picture-in-picture-placeholder' style={{ height }} role='button' tabIndex='0' onClick={this.handleClick}>
+        <Icon id='window-restore' />
+        <FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index fc7940e5a..1b7dce4c4 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -17,6 +17,7 @@ import classNames from 'classnames';
 import { autoUnfoldCW } from 'flavours/glitch/util/content_warning';
 import PollContainer from 'flavours/glitch/containers/poll_container';
 import { displayMedia } from 'flavours/glitch/util/initial_state';
+import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
 
 // We use the component (and not the container) since we do not want
 // to use the progress bar to show download progress
@@ -97,6 +98,8 @@ class Status extends ImmutablePureComponent {
     cachedMediaWidth: PropTypes.number,
     onClick: PropTypes.func,
     scrollKey: PropTypes.string,
+    deployPictureInPicture: PropTypes.func,
+    usingPiP: PropTypes.bool,
   };
 
   state = {
@@ -123,6 +126,7 @@ class Status extends ImmutablePureComponent {
     'hidden',
     'expanded',
     'unread',
+    'usingPiP',
   ]
 
   updateOnStates = [
@@ -394,6 +398,12 @@ class Status extends ImmutablePureComponent {
     }
   }
 
+  handleDeployPictureInPicture = (type, mediaProps) => {
+    const { deployPictureInPicture, status } = this.props;
+
+    deployPictureInPicture(status, type, mediaProps);
+  }
+
   handleHotkeyReply = e => {
     e.preventDefault();
     this.props.onReply(this.props.status, this.context.router.history);
@@ -496,6 +506,7 @@ class Status extends ImmutablePureComponent {
       hidden,
       unread,
       featured,
+      usingPiP,
       ...other
     } = this.props;
     const { isExpanded, isCollapsed, forceFilter } = this.state;
@@ -576,6 +587,9 @@ class Status extends ImmutablePureComponent {
     if (status.get('poll')) {
       media = <PollContainer pollId={status.get('poll')} />;
       mediaIcon = 'tasks';
+    } else if (usingPiP) {
+      media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
+      mediaIcon = 'video-camera';
     } else if (attachments.size > 0) {
       if (muted || attachments.some(item => item.get('type') === 'unknown')) {
         media = (
@@ -601,6 +615,7 @@ class Status extends ImmutablePureComponent {
                 width={this.props.cachedMediaWidth}
                 height={110}
                 cacheWidth={this.props.cacheMediaWidth}
+                deployPictureInPicture={this.handleDeployPictureInPicture}
               />
             )}
           </Bundle>
@@ -624,6 +639,7 @@ class Status extends ImmutablePureComponent {
               onOpenVideo={this.handleOpenVideo}
               width={this.props.cachedMediaWidth}
               cacheWidth={this.props.cacheMediaWidth}
+              deployPictureInPicture={this.handleDeployPictureInPicture}
               visible={this.state.showMedia}
               onToggleVisibility={this.handleToggleMediaVisibility}
             />)}
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index cfb03c21b..2ccb02c62 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -40,16 +40,6 @@ const messages = defineMessages({
   hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
 });
 
-const obfuscatedCount = count => {
-  if (count < 0) {
-    return 0;
-  } else if (count <= 1) {
-    return count;
-  } else {
-    return '1+';
-  }
-};
-
 export default @injectIntl
 class StatusActionBar extends ImmutablePureComponent {
 
@@ -284,10 +274,14 @@ class StatusActionBar extends ImmutablePureComponent {
     );
     if (showReplyCount) {
       replyButton = (
-        <div className='status__action-bar__counter'>
-          {replyButton}
-          <span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span>
-        </div>
+        <IconButton
+          className='status__action-bar-button'
+          title={replyTitle}
+          icon={replyIcon}
+          onClick={this.handleReplyClick}
+          counter={status.get('replies_count')}
+          obfuscateCount
+        />
       );
     }
 
diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js
index 8101be87e..762280bec 100644
--- a/app/javascript/flavours/glitch/containers/mastodon.js
+++ b/app/javascript/flavours/glitch/containers/mastodon.js
@@ -32,13 +32,6 @@ export default class Mastodon extends React.PureComponent {
 
   componentDidMount() {
     this.disconnect = store.dispatch(connectUserStream());
-
-    // Desktop notifications
-    // Ask after 1 minute
-    if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
-      window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
-    }
-
     store.dispatch(showOnboardingOnce());
   }
 
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
index 2cbe3d094..ac423c58d 100644
--- a/app/javascript/flavours/glitch/containers/status_container.js
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -22,6 +22,7 @@ import { initMuteModal } from 'flavours/glitch/actions/mutes';
 import { initBlockModal } from 'flavours/glitch/actions/blocks';
 import { initReport } from 'flavours/glitch/actions/reports';
 import { openModal } from 'flavours/glitch/actions/modal';
+import { deployPictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
 import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state';
@@ -69,6 +70,7 @@ const makeMapStateToProps = () => {
       account     : account || props.account,
       settings    : state.get('local_settings'),
       prepend     : prepend || props.prepend,
+      usingPiP    : state.get('picture_in_picture').statusId === props.id,
     };
   };
 
@@ -245,6 +247,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
     }
   },
 
+  deployPictureInPicture (status, type, mediaProps) {
+    dispatch((_, getState) => {
+      if (getState().getIn(['local_settings', 'media', 'pop_in_player'])) {
+        dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
+      }
+    });
+  },
+
 });
 
 export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js
index 7a2fb7fb6..6d09ac8d2 100644
--- a/app/javascript/flavours/glitch/features/audio/index.js
+++ b/app/javascript/flavours/glitch/features/audio/index.js
@@ -37,7 +37,11 @@ class Audio extends React.PureComponent {
     backgroundColor: PropTypes.string,
     foregroundColor: PropTypes.string,
     accentColor: PropTypes.string,
+    currentTime: PropTypes.number,
     autoPlay: PropTypes.bool,
+    volume: PropTypes.number,
+    muted: PropTypes.bool,
+    deployPictureInPicture: PropTypes.func,
   };
 
   state = {
@@ -64,6 +68,19 @@ class Audio extends React.PureComponent {
     }
   }
 
+  _pack() {
+    return {
+      src: this.props.src,
+      volume: this.audio.volume,
+      muted: this.audio.muted,
+      currentTime: this.audio.currentTime,
+      poster: this.props.poster,
+      backgroundColor: this.props.backgroundColor,
+      foregroundColor: this.props.foregroundColor,
+      accentColor: this.props.accentColor,
+    };
+  }
+
   _setDimensions () {
     const width  = this.player.offsetWidth;
     const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
@@ -100,6 +117,7 @@ class Audio extends React.PureComponent {
   }
  
   componentDidMount () {
+    window.addEventListener('scroll', this.handleScroll);
     window.addEventListener('resize', this.handleResize, { passive: true });
   }
 
@@ -115,7 +133,12 @@ class Audio extends React.PureComponent {
   }
 
   componentWillUnmount () {
+    window.removeEventListener('scroll', this.handleScroll);
     window.removeEventListener('resize', this.handleResize);
+
+    if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
+      this.props.deployPictureInPicture('audio', this._pack());
+    }
   }
 
   togglePlay = () => {
@@ -243,6 +266,25 @@ class Audio extends React.PureComponent {
     }
   }, 15);
 
+  handleScroll = throttle(() => {
+    if (!this.canvas || !this.audio) {
+      return;
+    }
+
+    const { top, height } = this.canvas.getBoundingClientRect();
+    const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
+
+    if (!this.state.paused && !inView) {
+      this.audio.pause();
+
+      if (this.props.deployPictureInPicture) {
+        this.props.deployPictureInPicture('audio', this._pack());
+      }
+
+      this.setState({ paused: true });
+    }
+  }, 150, { trailing: true });
+
   handleMouseEnter = () => {
     this.setState({ hovered: true });
   }
@@ -252,10 +294,22 @@ class Audio extends React.PureComponent {
   }
 
   handleLoadedData = () => {
-    const { autoPlay } = this.props;
+    const { autoPlay, currentTime, volume, muted } = this.props;
+
+    if (currentTime) {
+      this.audio.currentTime = currentTime;
+    }
+
+    if (volume !== undefined) {
+      this.audio.volume = volume;
+    }
+
+    if (muted !== undefined) {
+      this.audio.muted = muted;
+    }
 
     if (autoPlay) {
-      this.audio.play();
+      this.togglePlay();
     }
   }
 
@@ -341,7 +395,7 @@ class Audio extends React.PureComponent {
   render () {
     const { src, intl, alt, editable, autoPlay } = this.props;
     const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
-    const progress = (currentTime / duration) * 100;
+    const progress = Math.min((currentTime / duration) * 100, 100);
 
     return (
       <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js
index d0d9714a8..89219d739 100644
--- a/app/javascript/flavours/glitch/features/emoji_picker/index.js
+++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js
@@ -13,6 +13,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import detectPassiveEvents from 'detect-passive-events';
 import { buildCustomEmojis, categoriesFromEmojis } from 'flavours/glitch/util/emoji';
 import { useSystemEmojiFont } from 'flavours/glitch/util/initial_state';
+import { assetHost } from 'flavours/glitch/util/config';
 
 const messages = defineMessages({
   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@@ -105,7 +106,6 @@ const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
   },
 });
 
-const assetHost = process.env.CDN_HOST || '';
 let EmojiPicker, Emoji; // load asynchronously
 
 const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
diff --git a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
index acaa78fe3..cd81d07de 100644
--- a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
+++ b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
@@ -15,6 +15,7 @@ import EmojiPickerDropdown from 'flavours/glitch/features/emoji_picker';
 import AnimatedNumber from 'flavours/glitch/components/animated_number';
 import TransitionMotion from 'react-motion/lib/TransitionMotion';
 import spring from 'react-motion/lib/spring';
+import { assetHost } from 'flavours/glitch/util/config';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -153,8 +154,6 @@ class Content extends ImmutablePureComponent {
 
 }
 
-const assetHost = process.env.CDN_HOST || '';
-
 class Emoji extends React.PureComponent {
 
   static propTypes = {
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js
index 0b3428027..3af6cbdf6 100644
--- a/app/javascript/flavours/glitch/features/local_settings/page/index.js
+++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js
@@ -28,6 +28,8 @@ const messages = defineMessages({
   rewrite_mentions_no: { id: 'settings.rewrite_mentions_no', defaultMessage: 'Do not rewrite mentions' },
   rewrite_mentions_acct: { id: 'settings.rewrite_mentions_acct', defaultMessage: 'Rewrite with username and domain (when the account is remote)' },
   rewrite_mentions_username: { id: 'settings.rewrite_mentions_username', defaultMessage:  'Rewrite with username' },
+  pop_in_left: { id: 'settings.pop_in_left', defaultMessage: 'Left' },
+  pop_in_right: { id: 'settings.pop_in_right', defaultMessage:  'Right' },
 });
 
 export default @injectIntl
@@ -111,6 +113,14 @@ class LocalSettingsPage extends React.PureComponent {
             <FormattedMessage id='settings.notifications.favicon_badge' defaultMessage='Unread notifications favicon badge' />
             <span className='hint'><FormattedMessage id='settings.notifications.favicon_badge.hint' defaultMessage="Add a badge for unread notifications to the favicon" /></span>
           </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['notifications', 'show_unread']}
+            id='mastodon-settings--notifications-show_unread'
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.notifications.show_unread' defaultMessage='Show unread marker' />
+          </LocalSettingsPageItem>
         </section>
         <section>
           <h2><FormattedMessage id='settings.layout_opts' defaultMessage='Layout options' /></h2>
@@ -384,7 +394,7 @@ class LocalSettingsPage extends React.PureComponent {
         </section>
       </div>
     ),
-    ({ onChange, settings }) => (
+    ({ intl, onChange, settings }) => (
       <div className='glitch local-settings__page media'>
         <h1><FormattedMessage id='settings.media' defaultMessage='Media' /></h1>
         <LocalSettingsPageItem
@@ -420,6 +430,27 @@ class LocalSettingsPage extends React.PureComponent {
         >
           <FormattedMessage id='settings.media_reveal_behind_cw' defaultMessage='Reveal sensitive media behind a CW by default' />
         </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['media', 'pop_in_player']}
+          id='mastodon-settings--pop-in-player'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.pop_in_player' defaultMessage='Enable pop-in player' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['media', 'pop_in_position']}
+          id='mastodon-settings--pop-in-position'
+          options={[
+            { value: 'left', message: intl.formatMessage(messages.pop_in_left) },
+            { value: 'right', message: intl.formatMessage(messages.pop_in_right) },
+          ]}
+          onChange={onChange}
+          dependsOn={[['media', 'pop_in_player']]}
+        >
+          <FormattedMessage id='settings.pop_in_position' defaultMessage='Pop-in player position:' />
+        </LocalSettingsPageItem>
       </div>
     ),
   ];
diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
index e4d5d0eda..9748219dd 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
@@ -12,6 +12,10 @@ export default class ColumnSettings extends React.PureComponent {
     pushSettings: ImmutablePropTypes.map.isRequired,
     onChange: PropTypes.func.isRequired,
     onClear: PropTypes.func.isRequired,
+    onRequestNotificationPermission: PropTypes.func,
+    alertsEnabled: PropTypes.bool,
+    browserSupport: PropTypes.bool,
+    browserPermission: PropTypes.bool,
   };
 
   onPushChange = (path, checked) => {
@@ -19,7 +23,7 @@ export default class ColumnSettings extends React.PureComponent {
   }
 
   render () {
-    const { settings, pushSettings, onChange, onClear } = this.props;
+    const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission } = this.props;
 
     const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
     const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
@@ -33,6 +37,12 @@ export default class ColumnSettings extends React.PureComponent {
 
     return (
       <div>
+        {alertsEnabled && browserSupport && browserPermission === 'denied' && (
+          <div className='column-settings__row column-settings__row--with-margin'>
+            <span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span>
+          </div>
+        )}
+
         <div className='column-settings__row'>
           <ClearColumnButton onClick={onClear} />
         </div>
@@ -41,6 +51,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-filter-bar' className='column-settings__section'>
             <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
           </span>
+
           <div className='column-settings__row'>
             <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
             <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
@@ -51,7 +62,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
@@ -62,7 +73,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
@@ -73,7 +84,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
@@ -84,7 +95,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} />
@@ -95,7 +106,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
@@ -106,12 +117,23 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
           </div>
         </div>
+
+        <div role='group' aria-labelledby='notifications-status'>
+          <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js
new file mode 100644
index 000000000..8e77f5a03
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import Icon from 'flavours/glitch/components/icon';
+import Button from 'flavours/glitch/components/button';
+import { requestBrowserPermission } from 'flavours/glitch/actions/notifications';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+export default @connect(() => {})
+class NotificationsPermissionBanner extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.dispatch(requestBrowserPermission());
+  }
+
+  render () {
+    return (
+      <div className='notifications-permission-banner'>
+        <h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2>
+        <p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p>
+        <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js
index 0264b6815..e472f7c4f 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js
@@ -13,6 +13,7 @@ export default class SettingToggle extends React.PureComponent {
     meta: PropTypes.node,
     onChange: PropTypes.func.isRequired,
     defaultValue: PropTypes.bool,
+    disabled: PropTypes.bool,
   }
 
   onChange = ({ target }) => {
@@ -20,12 +21,12 @@ export default class SettingToggle extends React.PureComponent {
   }
 
   render () {
-    const { prefix, settings, settingPath, label, meta, defaultValue } = this.props;
+    const { prefix, settings, settingPath, label, meta, defaultValue, disabled } = this.props;
     const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-');
 
     return (
       <div className='setting-toggle'>
-        <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
+        <Toggle disabled={disabled} id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
         <label htmlFor={id} className='setting-toggle__label'>{label}</label>
         {meta && <span className='setting-meta__label'>{meta}</span>}
       </div>
diff --git a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
index 4b863712a..c2564f44e 100644
--- a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
+++ b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
@@ -3,28 +3,55 @@ import { defineMessages, injectIntl } from 'react-intl';
 import ColumnSettings from '../components/column_settings';
 import { changeSetting } from 'flavours/glitch/actions/settings';
 import { setFilter } from 'flavours/glitch/actions/notifications';
-import { clearNotifications } from 'flavours/glitch/actions/notifications';
+import { clearNotifications, requestBrowserPermission } from 'flavours/glitch/actions/notifications';
 import { changeAlerts as changePushNotifications } from 'flavours/glitch/actions/push_notifications';
 import { openModal } from 'flavours/glitch/actions/modal';
+import { showAlert } from 'flavours/glitch/actions/alerts';
 
 const messages = defineMessages({
   clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' },
   clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' },
+  permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' },
 });
 
 const mapStateToProps = state => ({
   settings: state.getIn(['settings', 'notifications']),
   pushSettings: state.get('push_notifications'),
+  alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true),
+  browserSupport: state.getIn(['notifications', 'browserSupport']),
+  browserPermission: state.getIn(['notifications', 'browserPermission']),
 });
 
 const mapDispatchToProps = (dispatch, { intl }) => ({
 
   onChange (path, checked) {
     if (path[0] === 'push') {
-      dispatch(changePushNotifications(path.slice(1), checked));
+      if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+        dispatch(requestBrowserPermission((permission) => {
+          if (permission === 'granted') {
+            dispatch(changePushNotifications(path.slice(1), checked));
+          } else {
+            dispatch(showAlert(undefined, messages.permissionDenied));
+          }
+        }));
+      } else {
+        dispatch(changePushNotifications(path.slice(1), checked));
+      }
     } else if (path[0] === 'quickFilter') {
       dispatch(changeSetting(['notifications', ...path], checked));
       dispatch(setFilter('all'));
+    } else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+      if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+        dispatch(requestBrowserPermission((permission) => {
+          if (permission === 'granted') {
+            dispatch(changeSetting(['notifications', ...path], checked));
+          } else {
+            dispatch(showAlert(undefined, messages.permissionDenied));
+          }
+        }));
+      } else {
+        dispatch(changeSetting(['notifications', ...path], checked));
+      }
     } else {
       dispatch(changeSetting(['notifications', ...path], checked));
     }
@@ -38,6 +65,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }));
   },
 
+  onRequestNotificationPermission () {
+    dispatch(requestBrowserPermission());
+  },
+
 });
 
 export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));
diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index 475968caa..97434b586 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -27,6 +27,7 @@ import ScrollableList from 'flavours/glitch/components/scrollable_list';
 import LoadGap from 'flavours/glitch/components/load_gap';
 import Icon from 'flavours/glitch/components/icon';
 import compareId from 'flavours/glitch/util/compare_id';
+import NotificationsPermissionBanner from './components/notifications_permission_banner';
 
 import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container';
 
@@ -60,8 +61,9 @@ const mapStateToProps = state => ({
   hasMore: state.getIn(['notifications', 'hasMore']),
   numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
   notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
-  lastReadId: state.getIn(['notifications', 'readMarkerId']),
-  canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
+  lastReadId: state.getIn(['local_settings', 'notifications', 'show_unread']) ? state.getIn(['notifications', 'readMarkerId']) : '0',
+  canMarkAsRead: state.getIn(['local_settings', 'notifications', 'show_unread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
+  needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default',
 });
 
 /* glitch */
@@ -71,7 +73,7 @@ const mapDispatchToProps = dispatch => ({
   },
   onMarkAsRead() {
     dispatch(markNotificationsAsRead());
-    dispatch(submitMarkers());
+    dispatch(submitMarkers({ immediate: true }));
   },
   onMount() {
     dispatch(mountNotifications());
@@ -105,6 +107,7 @@ class Notifications extends React.PureComponent {
     onUnmount: PropTypes.func,
     lastReadId: PropTypes.string,
     canMarkAsRead: PropTypes.bool,
+    needsNotificationPermission: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -211,7 +214,7 @@ class Notifications extends React.PureComponent {
   }
 
   render () {
-    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props;
+    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
     const { notifCleaning, notifCleaningActive } = this.props;
     const { animatingNCD } = this.state;
     const pinned = !!columnId;
@@ -257,6 +260,8 @@ class Notifications extends React.PureComponent {
         showLoading={isLoading && notifications.size === 0}
         hasMore={hasMore}
         numPending={numPending}
+        prepend={needsNotificationPermission && <NotificationsPermissionBanner />}
+        alwaysPrepend
         emptyMessage={emptyMessage}
         onLoadMore={this.handleLoadOlder}
         onLoadPending={this.handleLoadPending}
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js
new file mode 100644
index 000000000..2ddba140e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js
@@ -0,0 +1,162 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'flavours/glitch/components/icon_button';
+import classNames from 'classnames';
+import { me, boostModal } from 'flavours/glitch/util/initial_state';
+import { defineMessages, injectIntl } from 'react-intl';
+import { replyCompose } from 'flavours/glitch/actions/compose';
+import { reblog, favourite, unreblog, unfavourite } from 'flavours/glitch/actions/interactions';
+import { makeGetStatus } from 'flavours/glitch/selectors';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+const messages = defineMessages({
+  reply: { id: 'status.reply', defaultMessage: 'Reply' },
+  replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+  reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
+  cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+  cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, { statusId }) => ({
+    status: getStatus(state, { id: statusId }),
+    askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
+    showReplyCount: state.getIn(['local_settings', 'show_reply_count']),
+  });
+
+  return mapStateToProps;
+};
+
+export default @connect(makeMapStateToProps)
+@injectIntl
+class Footer extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    statusId: PropTypes.string.isRequired,
+    status: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    askReplyConfirmation: PropTypes.bool,
+    showReplyCount: PropTypes.bool,
+  };
+
+  _performReply = () => {
+    const { dispatch, status } = this.props;
+    dispatch(replyCompose(status, this.context.router.history));
+  };
+
+  handleReplyClick = () => {
+    const { dispatch, askReplyConfirmation, intl } = this.props;
+
+    if (askReplyConfirmation) {
+      dispatch(openModal('CONFIRM', {
+        message: intl.formatMessage(messages.replyMessage),
+        confirm: intl.formatMessage(messages.replyConfirm),
+        onConfirm: this._performReply,
+      }));
+    } else {
+      this._performReply();
+    }
+  };
+
+  handleFavouriteClick = () => {
+    const { dispatch, status } = this.props;
+
+    if (status.get('favourited')) {
+      dispatch(unfavourite(status));
+    } else {
+      dispatch(favourite(status));
+    }
+  };
+
+  _performReblog = () => {
+    const { dispatch, status } = this.props;
+    dispatch(reblog(status));
+  }
+
+  handleReblogClick = e => {
+    const { dispatch, status } = this.props;
+
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else if ((e && e.shiftKey) || !boostModal) {
+      this._performReblog();
+    } else {
+      dispatch(openModal('BOOST', { status, onReblog: this._performReblog }));
+    }
+  };
+
+  render () {
+    const { status, intl, showReplyCount } = this.props;
+
+    const publicStatus  = ['public', 'unlisted'].includes(status.get('visibility'));
+    const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
+
+    let replyIcon, replyTitle;
+
+    if (status.get('in_reply_to_id', null) === null) {
+      replyIcon = 'reply';
+      replyTitle = intl.formatMessage(messages.reply);
+    } else {
+      replyIcon = 'reply-all';
+      replyTitle = intl.formatMessage(messages.replyAll);
+    }
+
+    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);
+    }
+
+    let replyButton = null;
+    if (showReplyCount) {
+      replyButton = (
+        <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}
+          counter={status.get('replies_count')}
+          obfuscateCount
+        />
+      );
+    } else {
+      replyButton = (
+        <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}
+        />
+      );
+    }
+
+    return (
+      <div className='picture-in-picture__footer'>
+        {replyButton}
+        <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate}  active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
+        <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} counter={status.get('favourites_count')} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js
new file mode 100644
index 000000000..24adcde25
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { Link } from 'react-router-dom';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+
+const mapStateToProps = (state, { accountId }) => ({
+  account: state.getIn(['accounts', accountId]),
+});
+
+export default @connect(mapStateToProps)
+class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    accountId: PropTypes.string.isRequired,
+    statusId: PropTypes.string.isRequired,
+    account: ImmutablePropTypes.map.isRequired,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  render () {
+    const { account, statusId, onClose } = this.props;
+
+    return (
+      <div className='picture-in-picture__header'>
+        <Link to={`/statuses/${statusId}`} className='picture-in-picture__header__account'>
+          <Avatar account={account} size={36} />
+          <DisplayName account={account} />
+        </Link>
+
+        <IconButton icon='times' onClick={onClose} title='Close' />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/index.js b/app/javascript/flavours/glitch/features/picture_in_picture/index.js
new file mode 100644
index 000000000..3e6a20faa
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/index.js
@@ -0,0 +1,88 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import Video from 'flavours/glitch/features/video';
+import Audio from 'flavours/glitch/features/audio';
+import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
+import Header from './components/header';
+import Footer from './components/footer';
+import classNames from 'classnames';
+
+const mapStateToProps = state => ({
+  ...state.get('picture_in_picture'),
+  left: state.getIn(['local_settings', 'media', 'pop_in_position']) === 'left',
+});
+
+export default @connect(mapStateToProps)
+class PictureInPicture extends React.Component {
+
+  static propTypes = {
+    statusId: PropTypes.string,
+    accountId: PropTypes.string,
+    type: PropTypes.string,
+    src: PropTypes.string,
+    muted: PropTypes.bool,
+    volume: PropTypes.number,
+    currentTime: PropTypes.number,
+    poster: PropTypes.string,
+    backgroundColor: PropTypes.string,
+    foregroundColor: PropTypes.string,
+    accentColor: PropTypes.string,
+    dispatch: PropTypes.func.isRequired,
+    left: PropTypes.bool,
+  };
+
+  handleClose = () => {
+    const { dispatch } = this.props;
+    dispatch(removePictureInPicture());
+  }
+
+  render () {
+    const { type, src, currentTime, accountId, statusId, left } = this.props;
+
+    if (!currentTime) {
+      return null;
+    }
+
+    let player;
+
+    if (type === 'video') {
+      player = (
+        <Video
+          src={src}
+          currentTime={this.props.currentTime}
+          volume={this.props.volume}
+          muted={this.props.muted}
+          autoPlay
+          inline
+          alwaysVisible
+        />
+      );
+    } else if (type === 'audio') {
+      player = (
+        <Audio
+          src={src}
+          currentTime={this.props.currentTime}
+          volume={this.props.volume}
+          muted={this.props.muted}
+          poster={this.props.poster}
+          backgroundColor={this.props.backgroundColor}
+          foregroundColor={this.props.foregroundColor}
+          accentColor={this.props.accentColor}
+          autoPlay
+        />
+      );
+    }
+
+    return (
+      <div className={classNames('picture-in-picture', { left })}>
+        <Header accountId={accountId} statusId={statusId} onClose={this.handleClose} />
+
+        {player}
+
+        <Footer statusId={statusId} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index e4aecbf94..04d350bcb 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -18,6 +18,7 @@ import classNames from 'classnames';
 import PollContainer from 'flavours/glitch/containers/poll_container';
 import Icon from 'flavours/glitch/components/icon';
 import AnimatedNumber from 'flavours/glitch/components/animated_number';
+import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
 
 export default class DetailedStatus extends ImmutablePureComponent {
 
@@ -37,6 +38,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
     domain: PropTypes.string.isRequired,
     compact: PropTypes.bool,
     showMedia: PropTypes.bool,
+    usingPiP: PropTypes.bool,
     onToggleMediaVisibility: PropTypes.func,
   };
 
@@ -109,7 +111,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 { expanded, onToggleHidden, settings } = this.props;
+    const { expanded, onToggleHidden, settings, usingPiP } = this.props;
     const outerStyle = { boxSizing: 'border-box' };
     const { compact } = this.props;
 
@@ -131,6 +133,9 @@ export default class DetailedStatus extends ImmutablePureComponent {
     if (status.get('poll')) {
       media = <PollContainer pollId={status.get('poll')} />;
       mediaIcon = 'tasks';
+    } else if (usingPiP) {
+      media = <PictureInPicturePlaceholder />;
+      mediaIcon = 'video-camera';
     } else if (status.get('media_attachments').size > 0) {
       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
         media = <AttachmentList media={status.get('media_attachments')} />;
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 3e2e95f35..b330adf3f 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -132,6 +132,7 @@ const makeMapStateToProps = () => {
       settings: state.get('local_settings'),
       askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0,
       domain: state.getIn(['meta', 'domain']),
+      usingPiP: state.get('picture_in_picture').statusId === props.params.statusId,
     };
   };
 
@@ -157,6 +158,7 @@ class Status extends ImmutablePureComponent {
     askReplyConfirmation: PropTypes.bool,
     multiColumn: PropTypes.bool,
     domain: PropTypes.string.isRequired,
+    usingPiP: PropTypes.bool,
   };
 
   state = {
@@ -514,7 +516,7 @@ class Status extends ImmutablePureComponent {
   render () {
     let ancestors, descendants;
     const { setExpansion } = this;
-    const { status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props;
+    const { status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props;
     const { fullscreen, isExpanded } = this.state;
 
     if (status === null) {
@@ -578,6 +580,7 @@ class Status extends ImmutablePureComponent {
                   domain={domain}
                   showMedia={this.state.showMedia}
                   onToggleMediaVisibility={this.handleToggleMediaVisibility}
+                  usingPiP={usingPiP}
                 />
 
                 <ActionBar
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
index b790b29a0..5de3e26d5 100644
--- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -20,6 +20,7 @@ import GIFV from 'flavours/glitch/components/gifv';
 import { me } from 'flavours/glitch/util/initial_state';
 import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
 import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
+import { assetHost } from 'flavours/glitch/util/config';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -50,8 +51,6 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
   .replace(/\n/g, ' ')
   .replace(/\*\*\*\*\*\*/g, '\n\n');
 
-const assetHost = process.env.CDN_HOST || '';
-
 class ImageLoader extends React.PureComponent {
 
   static propTypes = {
diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
index 23e8dac7e..aa6554107 100644
--- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
@@ -140,7 +140,7 @@ class MediaModal extends ImmutablePureComponent {
             src={image.get('url')}
             width={image.get('width')}
             height={image.get('height')}
-            startTime={time || 0}
+            currentTime={time || 0}
             onCloseVideo={onClose}
             detailed
             alt={image.get('description')}
diff --git a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
index 2aab82751..7d25db316 100644
--- a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
@@ -1,25 +1,32 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
-import { injectIntl, FormattedMessage } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import Toggle from 'react-toggle';
 import Button from 'flavours/glitch/components/button';
 import { closeModal } from 'flavours/glitch/actions/modal';
 import { muteAccount } from 'flavours/glitch/actions/accounts';
-import { toggleHideNotifications } from 'flavours/glitch/actions/mutes';
+import { toggleHideNotifications, changeMuteDuration } from 'flavours/glitch/actions/mutes';
 
+const messages = defineMessages({
+  minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
+  hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
+  days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
+  indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' },
+});
 
 const mapStateToProps = state => {
   return {
     account: state.getIn(['mutes', 'new', 'account']),
     notifications: state.getIn(['mutes', 'new', 'notifications']),
+    muteDuration: state.getIn(['mutes', 'new', 'duration']),
   };
 };
 
 const mapDispatchToProps = dispatch => {
   return {
-    onConfirm(account, notifications) {
-      dispatch(muteAccount(account.get('id'), notifications));
+    onConfirm(account, notifications, muteDuration) {
+      dispatch(muteAccount(account.get('id'), notifications, muteDuration));
     },
 
     onClose() {
@@ -29,6 +36,10 @@ const mapDispatchToProps = dispatch => {
     onToggleNotifications() {
       dispatch(toggleHideNotifications());
     },
+
+    onChangeMuteDuration(e) {
+      dispatch(changeMuteDuration(e.target.value));
+    },
   };
 };
 
@@ -43,6 +54,8 @@ class MuteModal extends React.PureComponent {
     onConfirm: PropTypes.func.isRequired,
     onToggleNotifications: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
+    muteDuration: PropTypes.number.isRequired,
+    onChangeMuteDuration: PropTypes.func.isRequired,
   };
 
   componentDidMount() {
@@ -51,7 +64,7 @@ class MuteModal extends React.PureComponent {
 
   handleClick = () => {
     this.props.onClose();
-    this.props.onConfirm(this.props.account, this.props.notifications);
+    this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration);
   }
 
   handleCancel = () => {
@@ -66,8 +79,12 @@ class MuteModal extends React.PureComponent {
     this.props.onToggleNotifications();
   }
 
+  changeMuteDuration = (e) => {
+    this.props.onChangeMuteDuration(e);
+  }
+
   render () {
-    const { account, notifications } = this.props;
+    const { account, notifications, muteDuration, intl } = this.props;
 
     return (
       <div className='modal-root__modal mute-modal'>
@@ -91,6 +108,21 @@ class MuteModal extends React.PureComponent {
               <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
             </label>
           </div>
+          <div>
+            <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span>
+
+            {/* eslint-disable-next-line jsx-a11y/no-onchange */}
+            <select value={muteDuration} onChange={this.changeMuteDuration}>
+              <option value={0}>{intl.formatMessage(messages.indefinite)}</option>
+              <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
+              <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
+              <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
+              <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
+              <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
+              <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
+              <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
+            </select>
+          </div>
         </div>
 
         <div className='mute-modal__action-bar'>
diff --git a/app/javascript/flavours/glitch/features/ui/components/video_modal.js b/app/javascript/flavours/glitch/features/ui/components/video_modal.js
index afeff90a4..c8d2a81b0 100644
--- a/app/javascript/flavours/glitch/features/ui/components/video_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js
@@ -42,9 +42,9 @@ export default class VideoModal extends ImmutablePureComponent {
             preview={media.get('preview_url')}
             blurhash={media.get('blurhash')}
             src={media.get('url')}
-            startTime={options.startTime}
+            currentTime={options.startTime}
             autoPlay={options.autoPlay}
-            defaultVolume={options.defaultVolume}
+            volume={options.defaultVolume}
             onCloseVideo={onClose}
             detailed
             alt={media.get('description')}
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index a399fc2b3..61a34fd2b 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -19,6 +19,7 @@ import PermaLink from 'flavours/glitch/components/permalink';
 import ColumnsAreaContainer from './containers/columns_area_container';
 import classNames from 'classnames';
 import Favico from 'favico.js';
+import PictureInPicture from 'flavours/glitch/features/picture_in_picture';
 import {
   Compose,
   Status,
@@ -359,7 +360,7 @@ class UI extends React.Component {
     const visibility = !document[this.visibilityHiddenProp];
     this.props.dispatch(notificationsSetVisibility(visibility));
     if (visibility) {
-      this.props.dispatch(submitMarkers());
+      this.props.dispatch(submitMarkers({ immediate: true }));
     }
   }
 
@@ -385,7 +386,7 @@ class UI extends React.Component {
 
   componentDidMount () {
     this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
-      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey;
+      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
     };
 
     if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support
@@ -614,6 +615,7 @@ class UI extends React.Component {
             {children}
           </SwitchingColumnsArea>
 
+          <PictureInPicture />
           <NotificationsContainer />
           <LoadingBarContainer className='loading-bar' />
           <ModalContainer />
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
index cc60a0d2e..95bee1331 100644
--- a/app/javascript/flavours/glitch/features/video/index.js
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -103,7 +103,7 @@ class Video extends React.PureComponent {
     width: PropTypes.number,
     height: PropTypes.number,
     sensitive: PropTypes.bool,
-    startTime: PropTypes.number,
+    currentTime: PropTypes.number,
     onOpenVideo: PropTypes.func,
     onCloseVideo: PropTypes.func,
     letterbox: PropTypes.bool,
@@ -111,15 +111,18 @@ class Video extends React.PureComponent {
     detailed: PropTypes.bool,
     inline: PropTypes.bool,
     editable: PropTypes.bool,
+    alwaysVisible: PropTypes.bool,
     cacheWidth: PropTypes.func,
     intl: PropTypes.object.isRequired,
     visible: PropTypes.bool,
     onToggleVisibility: PropTypes.func,
+    deployPictureInPicture: PropTypes.func,
     preventPlayback: PropTypes.bool,
     blurhash: PropTypes.string,
     link: PropTypes.node,
     autoPlay: PropTypes.bool,
-    defaultVolume: PropTypes.number,
+    volume: PropTypes.number,
+    muted: PropTypes.bool,
   };
 
   state = {
@@ -298,16 +301,27 @@ class Video extends React.PureComponent {
     document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
     document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
 
+    window.addEventListener('scroll', this.handleScroll);
     window.addEventListener('resize', this.handleResize, { passive: true });
   }
 
   componentWillUnmount () {
+    window.removeEventListener('scroll', this.handleScroll);
     window.removeEventListener('resize', this.handleResize);
 
     document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
     document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
     document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
     document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+
+    if (!this.state.paused && this.video && this.props.deployPictureInPicture) {
+      this.props.deployPictureInPicture('video', {
+        src: this.props.src,
+        currentTime: this.video.currentTime,
+        muted: this.video.muted,
+        volume: this.video.volume,
+      });
+    }
   }
 
   componentDidUpdate (prevProps) {
@@ -330,6 +344,30 @@ class Video extends React.PureComponent {
     trailing: true,
   });
 
+  handleScroll = throttle(() => {
+    if (!this.video) {
+      return;
+    }
+
+    const { top, height } = this.video.getBoundingClientRect();
+    const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
+
+    if (!this.state.paused && !inView) {
+      this.video.pause();
+
+      if (this.props.deployPictureInPicture) {
+        this.props.deployPictureInPicture('video', {
+          src: this.props.src,
+          currentTime: this.video.currentTime,
+          muted: this.video.muted,
+          volume: this.video.volume,
+        });
+      }
+
+      this.setState({ paused: true });
+    }
+  }, 150, { trailing: true })
+
   handleFullscreenChange = () => {
     this.setState({ fullscreen: isFullscreen() });
   }
@@ -360,15 +398,21 @@ class Video extends React.PureComponent {
   }
 
   handleLoadedData = () => {
-    if (this.props.startTime) {
-      this.video.currentTime = this.props.startTime;
+    const { currentTime, volume, muted, autoPlay } = this.props;
+
+    if (currentTime) {
+      this.video.currentTime = currentTime;
+    }
+
+    if (volume !== undefined) {
+      this.video.volume = volume;
     }
 
-    if (this.props.defaultVolume !== undefined) {
-      this.video.volume = this.props.defaultVolume;
+    if (muted !== undefined) {
+      this.video.muted = muted;
     }
 
-    if (this.props.autoPlay) {
+    if (autoPlay) {
       this.video.play();
     }
   }
@@ -413,9 +457,9 @@ class Video extends React.PureComponent {
   }
 
   render () {
-    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable, blurhash } = this.props;
+    const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable, blurhash } = this.props;
     const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
-    const progress = (currentTime / duration) * 100;
+    const progress = Math.min((currentTime / duration) * 100, 100);
     const playerStyle = {};
 
     const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth });
@@ -440,7 +484,7 @@ class Video extends React.PureComponent {
 
     let preload;
 
-    if (startTime || fullscreen || dragging) {
+    if (this.props.currentTime || fullscreen || dragging) {
       preload = 'auto';
     } else if (detailed) {
       preload = 'metadata';
@@ -532,7 +576,7 @@ class Video extends React.PureComponent {
             </div>
 
             <div className='video-player__buttons right'>
-              {(!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>}
+              {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && <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(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>
diff --git a/app/javascript/flavours/glitch/locales/es.js b/app/javascript/flavours/glitch/locales/es.js
index 456df3c47..086873881 100644
--- a/app/javascript/flavours/glitch/locales/es.js
+++ b/app/javascript/flavours/glitch/locales/es.js
@@ -1,7 +1,119 @@
 import inherited from 'mastodon/locales/es.json';
 
 const messages = {
-  //  No translations available.
+  'advanced_options.icon_title': 'Opciones avanzadas',
+  'advanced_options.local-only.long': 'No publicar a otras instancias',
+  'advanced_options.local-only.short': 'Local',
+  'advanced_options.local-only.tooltip': 'Este toot es local',
+  'advanced_options.threaded_mode.long': 'Al publicar abre automáticamente una respuesta',
+  'advanced_options.threaded_mode.short': 'Modo hilo',
+  'advanced_options.threaded_mode.tooltip': 'Modo hilo habilitado',
+  'compose.attach.doodle': 'Dibujar algo',
+  'compose.attach.upload': 'Subir un archivo',
+  'compose.attach': 'Adjuntar...',
+  'favourite_modal.combo': 'Puedes presionar {combo} para omitir esto la próxima vez',
+  'getting_started.onboarding': 'Paseo inicial',
+  'getting_started.open_source_notice': 'Glitchsoc es software libre y gratuito bifurcado de {Mastodon}. Puedes contribuir o reportar errores en GitHub en {github}.',
+  'home.column_settings.show_direct': 'Mostrar mensajes directos',
+  'layout.auto': 'Automático',
+  'layout.current_is': 'Tu diseño actual es:',
+  'layout.desktop': 'Escritorio',
+  'layout.hint.auto': 'Seleccionar un diseño automáticamente basado en "Habilitar interface web avanzada" y tamaño de pantalla',
+  'layout.hint.desktop': 'Utiliza el diseño multi-columna sin importar "Habilitar interface web avanzada" o tamaño de pantalla',
+  'layout.hint.single': 'Utiliza el diseño de una columna sin importar "Habilitar interface web avanzada" o tamaño de pantalla',
+  'layout.mobile': 'Móvil',
+  'media_gallery.sensitive': 'Sensible',
+  'navigation_bar.app_settings': 'Ajustes de aplicación',
+  'notification_purge.btn_all': 'Seleccionar\ntodo',
+  'notification_purge.btn_apply': 'Borrar\nselección',
+  'notification_purge.btn_invert': 'Invertir\nselección',
+  'notification_purge.btn_none': 'Seleccionar\nnada',
+  'notification.markForDeletion': 'Marcar para borrar',
+  'notifications.clear': 'Limpiar notificaciones',
+  'notifications.marked_clear_confirmation': '¿Deseas borrar permanentemente todas las notificaciones seleccionadas?',
+  'notifications.marked_clear': 'Limpiar notificaciones seleccionadas',
+  'onboarding.page_one.federation': '{domain} es una "instancia" de Mastodon. Mastodon es una red de servidores independientes que se unen para crear una red social más grande. A estos servidores los llamamos instancias.',
+  'onboarding.page_one.welcome': '¡Bienvenidx a {domain}!',
+  'onboarding.page_six.github': '{domain} usa Glitchsoc. Glitchsoc es una bifurcación {fork} amigable de {Mastodon}, y es compatible con cualquier instancia o aplicación de Mastodon. Glitchsoc es completamente gratuito y de código abierto. Puedes reportar errores, solicitar funciones o contribuir al código en {github}.',
+  'settings.always_show_spoilers_field': 'Siempre mostrar el campo de advertencia de contenido',
+  'settings.auto_collapse_all': 'Todo',
+  'settings.auto_collapse_lengthy': 'Toots largos',
+  'settings.auto_collapse_media': 'Toots con medios',
+  'settings.auto_collapse_notifications': 'Notificaciones',
+  'settings.auto_collapse_reblogs': 'Retoots',
+  'settings.auto_collapse_replies': 'Respuestas',
+  'settings.auto_collapse': 'Colapsar automáticamente',
+  'settings.close': 'Cerrar',
+  'settings.collapsed_statuses': 'Toots colapsados',
+  'settings.compose_box_opts': 'Cuadro de redacción',
+  'settings.confirm_before_clearing_draft': 'Mostrar diálogo de confirmación antes de sobreescribir un mensaje estabas escribiendo',
+  'settings.confirm_boost_missing_media_description': 'Mostrar diálogo de confirmación antes de retootear publicaciones con medios sin descripción',
+  'settings.confirm_missing_media_description': 'Mostrar diálogo de confirmación antes de publicar toots con medios sin descripción',
+  'settings.content_warnings_filter': 'No descolapsar estas advertencias de contenido:',
+  'settings.content_warnings.regexp': 'Regexp (expresión regular)',
+  'settings.content_warnings': 'Advertencias de contenido',
+  'settings.enable_collapsed': 'Habilitar toots colapsados',
+  'settings.enable_content_warnings_auto_unfold': 'Descolapsar automáticamente advertencias de contenido',
+  'settings.filtering_behavior.cw': 'Mostrar el toot y agregar las palabras filtradas a la advertencia de contenido',
+  'settings.filtering_behavior.drop': 'Ocultar toots filtrados por completo',
+  'settings.filtering_behavior.hide': 'Mostrar "Filtrado" y agregar un botón para saber por qué',
+  'settings.filtering_behavior.upstream': 'Mostrar "Filtrado"',
+  'settings.filtering_behavior': 'Configuración de filtros',
+  'settings.filters': 'Filtros',
+  'settings.general': 'General',
+  'settings.hicolor_privacy_icons': 'Íconos de privacidad más visibles',
+  'settings.image_backgrounds_media': 'Vista previa de medios de toots colapsados',
+  'settings.image_backgrounds_users': 'Darle fondo de imagen a toots colapsados',
+  'settings.image_backgrounds': 'Fondos de imágenes',
+  'settings.inline_preview_cards': 'Vista previa para enlaces externos',
+  'settings.layout_opts': 'Opciones de diseño',
+  'settings.layout': 'Diseño',
+  'settings.media_fullwidth': 'Ancho completo al mostrar medios ',
+  'settings.media_letterbox_hint': 'Escalar medios para que llenen el espacio del contenedor sin cambiar sus proporciones sin recortarlos',
+  'settings.media_letterbox': 'Mantener proporciones al mostrar medios',
+  'settings.media_reveal_behind_cw': 'Siempre mostrar medios sensibles dentro de las advertencias de contenido',
+  'settings.media': 'Medios',
+  'settings.navbar_under': 'Barra de navegación en la parte inferior (solo móvil)',
+  'settings.notifications_opts': 'Opciones de notificaciones',
+  'settings.notifications.favicon_badge.hint': 'Muestra un marcador de notificaciones sin leer en el favicon',
+  'settings.notifications.favicon_badge': 'Marcador de notificaciones en el favicon',
+  'settings.notifications.tab_badge.hint': 'Muestra un marcador de notificaciones sin leer en el ícono de notificaciones cuando dicha columna no está abierta',
+  'settings.notifications.tab_badge': 'Marcador de notificaciones no leídas',
+  'settings.preferences': 'Preferencias de usuarix',
+  'settings.prepend_cw_re': 'Anteponer "re: " a las advertencias de contenido al responder',
+  'settings.preselect_on_reply_hint': 'Al responder a conversaciones con múltiples participantes, preselecciona los nombres de usuarix subsecuentes del/la primerx',
+  'settings.preselect_on_reply': 'Preseleccionar nombres de usuarix al responder',
+  'settings.rewrite_mentions_acct': 'Reescribir con nombre de usuarix y dominio (para cuentas remotas)',
+  'settings.rewrite_mentions_no': 'No reescribir menciones',
+  'settings.rewrite_mentions_username': 'Reescribir con nombre de usuarix',
+  'settings.rewrite_mentions': 'Reescribir menciones in publicaciones mostradas',
+  'settings.show_action_bar': 'Mostrar botones de acción en toots colapsados',
+  'settings.show_content_type_choice': 'Mostrar selección de tipo de contenido al crear toots',
+  'settings.show_reply_counter': 'Mostrar un conteo estimado de respuestas',
+  'settings.side_arm_reply_mode.copy': 'Copiar opción de privacidad del toot al que estás respondiendo',
+  'settings.side_arm_reply_mode.keep': 'Conservar opción de privacidad',
+  'settings.side_arm_reply_mode.restrict': 'Restringir la opción de privacidad a la misma del toot al que estás respondiendo',
+  'settings.side_arm_reply_mode': 'Al responder a un toot:',
+  'settings.side_arm.none': 'Ninguno',
+  'settings.side_arm': 'Botón secundario:',
+  'settings.swipe_to_change_columns': 'Permitir deslizar para cambiar columnas (Sólo en móvil)',
+  'settings.tag_misleading_links.hint': 'Añadir una indicación visual indicando el destino de los enlace que no los mencionen explícitamente',
+  'settings.tag_misleading_links': 'Marcar enlaces engañosos',
+  'settings.wide_view': 'Vista amplia (solo modo de escritorio)',
+  'status.collapse': 'Colapsar',
+  'status.uncollapse': 'Descolapsar',
+  'confirmations.unfilter': 'Información del toot filtrado',
+  'confirmations.unfilter.author': 'Publicado por',
+  'confirmations.unfilter.filters': 'Coincidencia con {count, plural, one {filtro} other {filtros}}',
+  'confirmations.unfilter.edit_filter': 'Editar filtro',
+  'confirmations.unfilter.confirm': 'Mostrar',
+  'confirmations.delete.confirm': 'Borrar',
+  'confirmations.delete.message': 'Por favor confirma borrado',
+  'confirmations.redraft.confirm': 'Borrar y volver a borrador',
+  'confirmations.redraft.message': '¿Borrar y volver a borrador? Perderás todas las respuestas, retoots y favoritos asociados, y las respuestas a la publicación original quedarán huérfanos.',
+  'confirmations.reply.confirm': 'Responder',
+  'confirmations.reply.message': 'Responder no sobreescribirá el mensaje que estás escibiendo actualmente. ¿Deseas continuar?',
+  'status.show_filter_reason': '(mostrar por qué)',
 };
 
 export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/names.yml b/app/javascript/flavours/glitch/names.yml
index 0801c4565..0d56fd3cc 100644
--- a/app/javascript/flavours/glitch/names.yml
+++ b/app/javascript/flavours/glitch/names.yml
@@ -13,3 +13,11 @@ pl:
   skins:
     glitch:
       default: Domyślny
+es:
+  flavours:
+    glitch:
+      description: El diseño predeterminado para las instancias con GlitchSoc.
+      name: Glitchsoc
+  skins:
+    glitch:
+      default: Predeterminado
diff --git a/app/javascript/flavours/glitch/packs/about.js b/app/javascript/flavours/glitch/packs/about.js
index bc0a4887b..2e2cce501 100644
--- a/app/javascript/flavours/glitch/packs/about.js
+++ b/app/javascript/flavours/glitch/packs/about.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 
 function loaded() {
diff --git a/app/javascript/flavours/glitch/packs/common.js b/app/javascript/flavours/glitch/packs/common.js
index 1fedc890a..7dc34eba9 100644
--- a/app/javascript/flavours/glitch/packs/common.js
+++ b/app/javascript/flavours/glitch/packs/common.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import { start } from '@rails/ujs';
 
 start();
diff --git a/app/javascript/flavours/glitch/packs/error.js b/app/javascript/flavours/glitch/packs/error.js
index 81c86c3ab..9f692ad37 100644
--- a/app/javascript/flavours/glitch/packs/error.js
+++ b/app/javascript/flavours/glitch/packs/error.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import ready from 'flavours/glitch/util/ready';
 
 ready(() => {
diff --git a/app/javascript/flavours/glitch/packs/home.js b/app/javascript/flavours/glitch/packs/home.js
index b8f7b7d8e..d06688985 100644
--- a/app/javascript/flavours/glitch/packs/home.js
+++ b/app/javascript/flavours/glitch/packs/home.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 
 loadPolyfills().then(() => {
diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js
index b9b588dc6..c74e5c9af 100644
--- a/app/javascript/flavours/glitch/packs/public.js
+++ b/app/javascript/flavours/glitch/packs/public.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 import ready from 'flavours/glitch/util/ready';
 import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions';
diff --git a/app/javascript/flavours/glitch/packs/settings.js b/app/javascript/flavours/glitch/packs/settings.js
index 8a9f23505..9c4d119c1 100644
--- a/app/javascript/flavours/glitch/packs/settings.js
+++ b/app/javascript/flavours/glitch/packs/settings.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 import ready from 'flavours/glitch/util/ready';
 import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions';
diff --git a/app/javascript/flavours/glitch/packs/share.js b/app/javascript/flavours/glitch/packs/share.js
index 9f2aa2553..f4a97e201 100644
--- a/app/javascript/flavours/glitch/packs/share.js
+++ b/app/javascript/flavours/glitch/packs/share.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 
 function loaded() {
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
index cadbd01a3..b1ddb769e 100644
--- a/app/javascript/flavours/glitch/reducers/index.js
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -38,6 +38,7 @@ import trends from './trends';
 import announcements from './announcements';
 import markers from './markers';
 import account_notes from './account_notes';
+import picture_in_picture from './picture_in_picture';
 
 const reducers = {
   announcements,
@@ -79,6 +80,7 @@ const reducers = {
   trends,
   markers,
   account_notes,
+  picture_in_picture,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js
index 3d94d665c..ea37ae4aa 100644
--- a/app/javascript/flavours/glitch/reducers/local_settings.js
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -49,10 +49,13 @@ const initialState = ImmutableMap({
     letterbox        : true,
     fullwidth        : true,
     reveal_behind_cw : false,
+    pop_in_player    : true,
+    pop_in_position  : 'right',
   }),
   notifications : ImmutableMap({
     favicon_badge : false,
     tab_badge     : true,
+    show_unread   : true,
   }),
 });
 
diff --git a/app/javascript/flavours/glitch/reducers/mutes.js b/app/javascript/flavours/glitch/reducers/mutes.js
index 7111bb710..d346d9a78 100644
--- a/app/javascript/flavours/glitch/reducers/mutes.js
+++ b/app/javascript/flavours/glitch/reducers/mutes.js
@@ -3,12 +3,14 @@ import Immutable from 'immutable';
 import {
   MUTES_INIT_MODAL,
   MUTES_TOGGLE_HIDE_NOTIFICATIONS,
+  MUTES_CHANGE_DURATION,
 } from 'flavours/glitch/actions/mutes';
 
 const initialState = Immutable.Map({
   new: Immutable.Map({
     account: null,
     notifications: true,
+    duration: 0,
   }),
 });
 
@@ -21,6 +23,8 @@ export default function mutes(state = initialState, action) {
     });
   case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
     return state.updateIn(['new', 'notifications'], (old) => !old);
+  case MUTES_CHANGE_DURATION:
+    return state.setIn(['new', 'duration'], Number(action.duration));
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js
index e136369ae..b4c5ef71a 100644
--- a/app/javascript/flavours/glitch/reducers/notifications.js
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -17,6 +17,8 @@ import {
   NOTIFICATIONS_ENTER_CLEARING_MODE,
   NOTIFICATIONS_MARK_ALL_FOR_DELETE,
   NOTIFICATIONS_MARK_AS_READ,
+  NOTIFICATIONS_SET_BROWSER_SUPPORT,
+  NOTIFICATIONS_SET_BROWSER_PERMISSION,
 } from 'flavours/glitch/actions/notifications';
 import {
   ACCOUNT_BLOCK_SUCCESS,
@@ -44,6 +46,8 @@ const initialState = ImmutableMap({
   isLoading: false,
   cleaningMode: false,
   isTabVisible: true,
+  browserSupport: false,
+  browserPermission: 'default',
   // notification removal mark of new notifs loaded whilst cleaningMode is true.
   markNewForDelete: false,
 });
@@ -185,7 +189,7 @@ const deleteMarkedNotifs = (state) => {
 
 const updateMounted = (state) => {
   state = state.update('mounted', count => count + 1);
-  if (!shouldCountUnreadNotifications(state)) {
+  if (!shouldCountUnreadNotifications(state, state.get('mounted') === 1)) {
     state = state.set('readMarkerId', state.get('lastReadId'));
     state = clearUnread(state);
   }
@@ -201,7 +205,7 @@ const updateVisibility = (state, visibility) => {
   return state;
 };
 
-const shouldCountUnreadNotifications = (state) => {
+const shouldCountUnreadNotifications = (state, ignoreScroll = false) => {
   const isTabVisible   = state.get('isTabVisible');
   const isOnTop        = state.get('top');
   const isMounted      = state.get('mounted') > 0;
@@ -209,7 +213,7 @@ const shouldCountUnreadNotifications = (state) => {
   const lastItem       = state.get('items').findLast(item => item !== null);
   const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0);
 
-  return !(isTabVisible && isOnTop && isMounted && lastItemReached);
+  return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached);
 };
 
 const recountUnread = (state, last_read_id) => {
@@ -275,6 +279,10 @@ export default function notifications(state = initialState, action) {
     return action.timeline === 'home' ?
       state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) :
       state;
+  case NOTIFICATIONS_SET_BROWSER_SUPPORT:
+    return state.set('browserSupport', action.value);
+  case NOTIFICATIONS_SET_BROWSER_PERMISSION:
+    return state.set('browserPermission', action.value);
 
   case NOTIFICATION_MARK_FOR_DELETE:
     return markForDelete(state, action.id, action.yes);
diff --git a/app/javascript/flavours/glitch/reducers/picture_in_picture.js b/app/javascript/flavours/glitch/reducers/picture_in_picture.js
new file mode 100644
index 000000000..f552a59c2
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/picture_in_picture.js
@@ -0,0 +1,22 @@
+import { PICTURE_IN_PICTURE_DEPLOY, PICTURE_IN_PICTURE_REMOVE } from 'flavours/glitch/actions/picture_in_picture';
+
+const initialState = {
+  statusId: null,
+  accountId: null,
+  type: null,
+  src: null,
+  muted: false,
+  volume: 0,
+  currentTime: 0,
+};
+
+export default function pictureInPicture(state = initialState, action) {
+  switch(action.type) {
+  case PICTURE_IN_PICTURE_DEPLOY:
+    return { statusId: action.statusId, accountId: action.accountId, type: action.playerType, ...action.props };
+  case PICTURE_IN_PICTURE_REMOVE:
+    return { ...initialState };
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/relationships.js b/app/javascript/flavours/glitch/reducers/relationships.js
index 33eb5b425..49dd77ef5 100644
--- a/app/javascript/flavours/glitch/reducers/relationships.js
+++ b/app/javascript/flavours/glitch/reducers/relationships.js
@@ -45,7 +45,7 @@ const initialState = ImmutableMap();
 export default function relationships(state = initialState, action) {
   switch(action.type) {
   case ACCOUNT_FOLLOW_REQUEST:
-    return state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
+    return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
   case ACCOUNT_FOLLOW_FAIL:
     return state.setIn([action.id, action.locked ? 'requested' : 'following'], false);
   case ACCOUNT_UNFOLLOW_REQUEST:
diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js
index ef99ad552..bf0545c48 100644
--- a/app/javascript/flavours/glitch/reducers/settings.js
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -33,12 +33,13 @@ const initialState = ImmutableMap({
 
   notifications: ImmutableMap({
     alerts: ImmutableMap({
-      follow: true,
+      follow: false,
       follow_request: false,
-      favourite: true,
-      reblog: true,
-      mention: true,
-      poll: true,
+      favourite: false,
+      reblog: false,
+      mention: false,
+      poll: false,
+      status: false,
     }),
 
     quickFilter: ImmutableMap({
@@ -54,6 +55,7 @@ const initialState = ImmutableMap({
       reblog: true,
       mention: true,
       poll: true,
+      status: true,
     }),
 
     sounds: ImmutableMap({
@@ -63,6 +65,7 @@ const initialState = ImmutableMap({
       reblog: true,
       mention: true,
       poll: true,
+      status: true,
     }),
   }),
 
diff --git a/app/javascript/flavours/glitch/reducers/user_lists.js b/app/javascript/flavours/glitch/reducers/user_lists.js
index 202f9198f..bfddbd246 100644
--- a/app/javascript/flavours/glitch/reducers/user_lists.js
+++ b/app/javascript/flavours/glitch/reducers/user_lists.js
@@ -53,14 +53,20 @@ import {
 } from 'flavours/glitch/actions/directory';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
+const initialListState = ImmutableMap({
+  next: null,
+  isLoading: false,
+  items: ImmutableList(),
+});
+
 const initialState = ImmutableMap({
-  followers: ImmutableMap(),
-  following: ImmutableMap(),
-  reblogged_by: ImmutableMap(),
-  favourited_by: ImmutableMap(),
-  follow_requests: ImmutableMap(),
-  blocks: ImmutableMap(),
-  mutes: ImmutableMap(),
+  followers: initialListState,
+  following: initialListState,
+  reblogged_by: initialListState,
+  favourited_by: initialListState,
+  follow_requests: initialListState,
+  blocks: initialListState,
+  mutes: initialListState,
 });
 
 const normalizeList = (state, path, accounts, next) => {
diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss
index 145219fa7..c0bbe5633 100644
--- a/app/javascript/flavours/glitch/styles/components/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -254,127 +254,6 @@
   text-align: center;
 }
 
-.column-settings__outer {
-  background: lighten($ui-base-color, 8%);
-  padding: 15px;
-}
-
-.column-settings__section {
-  color: $darker-text-color;
-  cursor: default;
-  display: block;
-  font-weight: 500;
-  margin-bottom: 10px;
-}
-
-.column-settings__hashtags {
-  .column-settings__row {
-    margin-bottom: 15px;
-  }
-
-  .column-select {
-    &__control {
-      @include search-input();
-
-      &::placeholder {
-        color: lighten($darker-text-color, 4%);
-      }
-
-      &::-moz-focus-inner {
-        border: 0;
-      }
-
-      &::-moz-focus-inner,
-      &:focus,
-      &:active {
-        outline: 0 !important;
-      }
-
-      &:focus {
-        background: lighten($ui-base-color, 4%);
-      }
-
-      @media screen and (max-width: 600px) {
-        font-size: 16px;
-      }
-    }
-
-    &__placeholder {
-      color: $dark-text-color;
-      padding-left: 2px;
-      font-size: 12px;
-    }
-
-    &__value-container {
-      padding-left: 6px;
-    }
-
-    &__multi-value {
-      background: lighten($ui-base-color, 8%);
-
-      &__remove {
-        cursor: pointer;
-
-        &:hover,
-        &:active,
-        &:focus {
-          background: lighten($ui-base-color, 12%);
-          color: lighten($darker-text-color, 4%);
-        }
-      }
-    }
-
-    &__multi-value__label,
-    &__input {
-      color: $darker-text-color;
-    }
-
-    &__clear-indicator,
-    &__dropdown-indicator {
-      cursor: pointer;
-      transition: none;
-      color: $dark-text-color;
-
-      &:hover,
-      &:active,
-      &:focus {
-        color: lighten($dark-text-color, 4%);
-      }
-    }
-
-    &__indicator-separator {
-      background-color: lighten($ui-base-color, 8%);
-    }
-
-    &__menu {
-      @include search-popout();
-      padding: 0;
-      background: $ui-secondary-color;
-    }
-
-    &__menu-list {
-      padding: 6px;
-    }
-
-    &__option {
-      color: $inverted-text-color;
-      border-radius: 4px;
-      font-size: 14px;
-
-      &--is-focused,
-      &--is-selected {
-        background: darken($ui-secondary-color, 10%);
-      }
-    }
-  }
-}
-
-.column-settings__row {
-  .text-btn {
-    margin-bottom: 15px;
-  }
-}
-
 .relationship-tag {
   color: $primary-text-color;
   margin-bottom: 4px;
diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss
index 6b657660a..be32ae52e 100644
--- a/app/javascript/flavours/glitch/styles/components/columns.scss
+++ b/app/javascript/flavours/glitch/styles/components/columns.scss
@@ -463,6 +463,15 @@
   flex: 1;
 }
 
+.column-header__issue-btn {
+  color: $warning-red;
+
+  &:hover {
+    color: $error-red;
+    text-decoration: underline;
+  }
+}
+
 .column-header__icon {
   display: inline-block;
   margin-right: 5px;
@@ -560,3 +569,150 @@
     margin: 0 5px;
   }
 }
+
+.column-settings__outer {
+  background: lighten($ui-base-color, 8%);
+  padding: 15px;
+}
+
+.column-settings__section {
+  color: $darker-text-color;
+  cursor: default;
+  display: block;
+  font-weight: 500;
+  margin-bottom: 10px;
+}
+
+.column-settings__row--with-margin {
+  margin-bottom: 15px;
+}
+
+.column-settings__hashtags {
+  .column-settings__row {
+    margin-bottom: 15px;
+  }
+
+  .column-select {
+    &__control {
+      @include search-input();
+
+      &::placeholder {
+        color: lighten($darker-text-color, 4%);
+      }
+
+      &::-moz-focus-inner {
+        border: 0;
+      }
+
+      &::-moz-focus-inner,
+      &:focus,
+      &:active {
+        outline: 0 !important;
+      }
+
+      &:focus {
+        background: lighten($ui-base-color, 4%);
+      }
+
+      @media screen and (max-width: 600px) {
+        font-size: 16px;
+      }
+    }
+
+    &__placeholder {
+      color: $dark-text-color;
+      padding-left: 2px;
+      font-size: 12px;
+    }
+
+    &__value-container {
+      padding-left: 6px;
+    }
+
+    &__multi-value {
+      background: lighten($ui-base-color, 8%);
+
+      &__remove {
+        cursor: pointer;
+
+        &:hover,
+        &:active,
+        &:focus {
+          background: lighten($ui-base-color, 12%);
+          color: lighten($darker-text-color, 4%);
+        }
+      }
+    }
+
+    &__multi-value__label,
+    &__input {
+      color: $darker-text-color;
+    }
+
+    &__clear-indicator,
+    &__dropdown-indicator {
+      cursor: pointer;
+      transition: none;
+      color: $dark-text-color;
+
+      &:hover,
+      &:active,
+      &:focus {
+        color: lighten($dark-text-color, 4%);
+      }
+    }
+
+    &__indicator-separator {
+      background-color: lighten($ui-base-color, 8%);
+    }
+
+    &__menu {
+      @include search-popout();
+      padding: 0;
+      background: $ui-secondary-color;
+    }
+
+    &__menu-list {
+      padding: 6px;
+    }
+
+    &__option {
+      color: $inverted-text-color;
+      border-radius: 4px;
+      font-size: 14px;
+
+      &--is-focused,
+      &--is-selected {
+        background: darken($ui-secondary-color, 10%);
+      }
+    }
+  }
+}
+
+.column-settings__row {
+  .text-btn {
+    margin-bottom: 15px;
+  }
+}
+
+.notifications-permission-banner {
+  padding: 30px;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+
+  h2 {
+    font-size: 16px;
+    font-weight: 500;
+    margin-bottom: 15px;
+    text-align: center;
+  }
+
+  p {
+    color: $darker-text-color;
+    margin-bottom: 15px;
+    text-align: center;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 04266c497..0614278e2 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -153,6 +153,7 @@
   cursor: pointer;
   transition: all 100ms ease-in;
   transition-property: background-color, color;
+  text-decoration: none;
 
   &:hover,
   &:active,
@@ -226,6 +227,20 @@
       background: rgba($base-overlay-background, 0.9);
     }
   }
+
+  &--with-counter {
+    display: inline-flex;
+    align-items: center;
+    width: auto !important;
+  }
+
+  &__counter {
+    display: inline-block;
+    width: 14px;
+    margin-left: 4px;
+    font-size: 12px;
+    font-weight: 500;
+  }
 }
 
 .text-icon-button {
@@ -708,6 +723,17 @@
     line-height: 14px;
     color: $primary-text-color;
   }
+
+  &__issue-badge {
+    position: absolute;
+    left: 11px;
+    bottom: 1px;
+    display: block;
+    background: $error-red;
+    border-radius: 50%;
+    width: 0.625rem;
+    height: 0.625rem;
+  }
 }
 
 .column-link--transparent .icon-with-badge__badge {
diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss
index d0be730ac..85f216887 100644
--- a/app/javascript/flavours/glitch/styles/components/modal.scss
+++ b/app/javascript/flavours/glitch/styles/components/modal.scss
@@ -785,6 +785,22 @@
       }
     }
   }
+
+  select {
+    appearance: none;
+    box-sizing: border-box;
+    font-size: 14px;
+    color: $inverted-text-color;
+    display: inline-block;
+    width: auto;
+    outline: 0;
+    font-family: inherit;
+    background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") no-repeat right 8px center / auto 16px;
+    border: 1px solid darken($simple-background-color, 14%);
+    border-radius: 4px;
+    padding: 6px 10px;
+    padding-right: 30px;
+  }
 }
 
 .confirmation-modal__container,
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index b70ff00f1..03acd72d8 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -572,28 +572,14 @@
   align-items: center;
   display: flex;
   margin-top: 8px;
-
-  &__counter {
-    display: inline-flex;
-    margin-right: 11px;
-    align-items: center;
-
-    .status__action-bar-button {
-      margin-right: 4px;
-    }
-
-    &__label {
-      display: inline-block;
-      width: 14px;
-      font-size: 12px;
-      font-weight: 500;
-      color: $action-button-color;
-    }
-  }
 }
 
 .status__action-bar-button {
   margin-right: 18px;
+
+  &.icon-button--with-counter {
+    margin-right: 14px;
+  }
 }
 
 .status__action-bar-dropdown {
@@ -1081,3 +1067,105 @@ a.status-card.compact:hover {
     }
   }
 }
+
+.picture-in-picture {
+  position: fixed;
+  bottom: 20px;
+  right: 20px;
+  width: 300px;
+
+  &.left {
+    right: unset;
+    left: 20px;
+  }
+
+  &__footer {
+    border-radius: 0 0 4px 4px;
+    background: lighten($ui-base-color, 4%);
+    padding: 10px;
+    padding-top: 12px;
+    display: flex;
+    justify-content: space-between;
+  }
+
+  &__header {
+    border-radius: 4px 4px 0 0;
+    background: lighten($ui-base-color, 4%);
+    padding: 10px;
+    display: flex;
+    justify-content: space-between;
+
+    &__account {
+      display: flex;
+      text-decoration: none;
+    }
+
+    .account__avatar {
+      margin-right: 10px;
+    }
+
+    .display-name {
+      color: $primary-text-color;
+      text-decoration: none;
+
+      strong,
+      span {
+        display: block;
+        text-overflow: ellipsis;
+        overflow: hidden;
+      }
+
+      span {
+        color: $darker-text-color;
+      }
+    }
+  }
+
+  .video-player,
+  .audio-player {
+    border-radius: 0;
+  }
+
+  @media screen and (max-width: 415px) {
+    width: 210px;
+    bottom: 10px;
+    right: 10px;
+
+    &__footer {
+      display: none;
+    }
+
+    .video-player,
+    .audio-player {
+      border-radius: 0 0 4px 4px;
+    }
+  }
+}
+
+.picture-in-picture-placeholder {
+  box-sizing: border-box;
+  border: 2px dashed lighten($ui-base-color, 8%);
+  background: $base-shadow-color;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  margin-top: 10px;
+  font-size: 16px;
+  font-weight: 500;
+  cursor: pointer;
+  color: $darker-text-color;
+
+  i {
+    display: block;
+    font-size: 24px;
+    font-weight: 400;
+    margin-bottom: 10px;
+  }
+
+  &:hover,
+  &:focus,
+  &:active {
+    border-color: lighten($ui-base-color, 12%);
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
index e5a5cc246..163b5220c 100644
--- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
@@ -385,3 +385,8 @@
 .directory__tag > div {
   box-shadow: none;
 }
+
+.mute-modal select {
+  border: 1px solid lighten($ui-base-color, 8%);
+  background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>") no-repeat right 8px center / auto 16px;
+}
diff --git a/app/javascript/flavours/glitch/util/config.js b/app/javascript/flavours/glitch/util/config.js
new file mode 100644
index 000000000..c3e2b73ae
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/config.js
@@ -0,0 +1,10 @@
+import ready from './ready';
+
+export let assetHost = '';
+
+ready(() => {
+  const cdnHost = document.querySelector('meta[name=cdn-host]');
+  if (cdnHost) {
+    assetHost = cdnHost.content || '';
+  }
+});
diff --git a/app/javascript/flavours/glitch/util/emoji/index.js b/app/javascript/flavours/glitch/util/emoji/index.js
index 233ec25e3..43ff4661c 100644
--- a/app/javascript/flavours/glitch/util/emoji/index.js
+++ b/app/javascript/flavours/glitch/util/emoji/index.js
@@ -1,11 +1,10 @@
 import { autoPlayGif, useSystemEmojiFont } from 'flavours/glitch/util/initial_state';
 import unicodeMapping from './emoji_unicode_mapping_light';
+import { assetHost } from 'flavours/glitch/util/config';
 import Trie from 'substring-trie';
 
 const trie = new Trie(Object.keys(unicodeMapping));
 
-const assetHost = process.env.CDN_HOST || '';
-
 // Convert to file names from emojis. (For different variation selector emojis)
 const emojiFilenames = (emojis) => {
   return emojis.map(v => unicodeMapping[v].filename);
diff --git a/app/javascript/flavours/glitch/util/main.js b/app/javascript/flavours/glitch/util/main.js
index 1fdb9ff2b..6577b70c2 100644
--- a/app/javascript/flavours/glitch/util/main.js
+++ b/app/javascript/flavours/glitch/util/main.js
@@ -1,4 +1,5 @@
 import * as registerPushNotifications from 'flavours/glitch/actions/push_notifications';
+import { setupBrowserNotifications } from 'flavours/glitch/actions/notifications';
 import { default as Mastodon, store } from 'flavours/glitch/containers/mastodon';
 import React from 'react';
 import ReactDOM from 'react-dom';
@@ -22,6 +23,7 @@ function main() {
     const props = JSON.parse(mountNode.getAttribute('data-props'));
 
     ReactDOM.render(<Mastodon {...props} />, mountNode);
+    store.dispatch(setupBrowserNotifications());
     if (process.env.NODE_ENV === 'production') {
       // avoid offline in dev mode because it's harder to debug
       require('offline-plugin/runtime').install();
diff --git a/app/javascript/flavours/glitch/util/notifications.js b/app/javascript/flavours/glitch/util/notifications.js
new file mode 100644
index 000000000..ab119c2e3
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/notifications.js
@@ -0,0 +1,29 @@
+// Handles browser quirks, based on
+// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
+
+const checkNotificationPromise = () => {
+  try {
+    Notification.requestPermission().then();
+  } catch(e) {
+    return false;
+  }
+
+  return true;
+};
+
+const handlePermission = (permission, callback) => {
+  // Whatever the user answers, we make sure Chrome stores the information
+  if(!('permission' in Notification)) {
+    Notification.permission = permission;
+  }
+
+  callback(Notification.permission);
+};
+
+export const requestNotificationPermission = (callback) => {
+  if (checkNotificationPromise()) {
+    Notification.requestPermission().then((permission) => handlePermission(permission, callback));
+  } else {
+    Notification.requestPermission((permission) => handlePermission(permission, callback));
+  }
+};
diff --git a/app/javascript/flavours/vanilla/names.yml b/app/javascript/flavours/vanilla/names.yml
index 1b3dab9c9..8749f2eda 100644
--- a/app/javascript/flavours/vanilla/names.yml
+++ b/app/javascript/flavours/vanilla/names.yml
@@ -14,3 +14,11 @@ pl:
   skins:
     vanilla:
       default: Domyślny
+es:
+  flavours:
+    vanilla:
+      description: El diseño predeterminado en las instancias de Mastodon. Puede que no soporte todas las características de GlitchSoc.
+      name: Mastodon Original
+  skins:
+    vanilla:
+      default: Predeterminado
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index 723c04e55..58b636602 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -257,11 +257,11 @@ export function unblockAccountFail(error) {
 };
 
 
-export function muteAccount(id, notifications) {
+export function muteAccount(id, notifications, duration=0) {
   return (dispatch, getState) => {
     dispatch(muteAccountRequest(id));
 
-    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => {
+    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => {
       // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
       dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
     }).catch(error => {
diff --git a/app/javascript/mastodon/actions/markers.js b/app/javascript/mastodon/actions/markers.js
index 41a95503e..c4b61effd 100644
--- a/app/javascript/mastodon/actions/markers.js
+++ b/app/javascript/mastodon/actions/markers.js
@@ -100,8 +100,12 @@ export function submitMarkersSuccess({ home, notifications }) {
   };
 };
 
-export function submitMarkers() {
-  return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
+export function submitMarkers(params = {}) {
+  const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
+  if (params.immediate === true) {
+    debouncedSubmitMarkers.flush();
+  }
+  return result;
 };
 
 export const fetchMarkers = () => (dispatch, getState) => {
diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js
index 9f645faee..d8874f353 100644
--- a/app/javascript/mastodon/actions/mutes.js
+++ b/app/javascript/mastodon/actions/mutes.js
@@ -13,6 +13,7 @@ export const MUTES_EXPAND_FAIL    = 'MUTES_EXPAND_FAIL';
 
 export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
 export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
+export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION';
 
 export function fetchMutes() {
   return (dispatch, getState) => {
@@ -104,3 +105,12 @@ export function toggleHideNotifications() {
     dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
   };
 }
+
+export function changeMuteDuration(duration) {
+  return dispatch => {
+    dispatch({
+      type: MUTES_CHANGE_DURATION,
+      duration,
+    });
+  };
+}
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 552def63b..c4fa66428 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -16,6 +16,7 @@ import { getFiltersRegex } from '../selectors';
 import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
 import compareId from 'mastodon/compare_id';
 import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
+import { requestNotificationPermission } from '../utils/notifications';
 
 export const NOTIFICATIONS_UPDATE      = 'NOTIFICATIONS_UPDATE';
 export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@@ -33,8 +34,12 @@ export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
 export const NOTIFICATIONS_MOUNT   = 'NOTIFICATIONS_MOUNT';
 export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
 
+
 export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
 
+export const NOTIFICATIONS_SET_BROWSER_SUPPORT    = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
+export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
+
 defineMessages({
   mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
   group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
@@ -234,3 +239,47 @@ export const mountNotifications = () => ({
 export const unmountNotifications = () => ({
   type: NOTIFICATIONS_UNMOUNT,
 });
+
+
+export const markNotificationsAsRead = () => ({
+  type: NOTIFICATIONS_MARK_AS_READ,
+});
+
+// Browser support
+export function setupBrowserNotifications() {
+  return dispatch => {
+    dispatch(setBrowserSupport('Notification' in window));
+    if ('Notification' in window) {
+      dispatch(setBrowserPermission(Notification.permission));
+    }
+
+    if ('Notification' in window && 'permissions' in navigator) {
+      navigator.permissions.query({ name: 'notifications' }).then((status) => {
+        status.onchange = () => dispatch(setBrowserPermission(Notification.permission));
+      });
+    }
+  };
+}
+
+export function requestBrowserPermission(callback = noOp) {
+  return dispatch => {
+    requestNotificationPermission((permission) => {
+      dispatch(setBrowserPermission(permission));
+      callback(permission);
+    });
+  };
+};
+
+export function setBrowserSupport (value) {
+  return {
+    type: NOTIFICATIONS_SET_BROWSER_SUPPORT,
+    value,
+  };
+}
+
+export function setBrowserPermission (value) {
+  return {
+    type: NOTIFICATIONS_SET_BROWSER_PERMISSION,
+    value,
+  };
+}
diff --git a/app/javascript/mastodon/actions/onboarding.js b/app/javascript/mastodon/actions/onboarding.js
index a1dd3a731..42d8ea33f 100644
--- a/app/javascript/mastodon/actions/onboarding.js
+++ b/app/javascript/mastodon/actions/onboarding.js
@@ -1,8 +1,21 @@
 import { changeSetting, saveSettings } from './settings';
+import { requestBrowserPermission } from './notifications';
 
 export const INTRODUCTION_VERSION = 20181216044202;
 
 export const closeOnboarding = () => dispatch => {
   dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION));
   dispatch(saveSettings());
+
+  dispatch(requestBrowserPermission((permission) => {
+    if (permission === 'granted') {
+      dispatch(changeSetting(['notifications', 'alerts', 'follow'], true));
+      dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true));
+      dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true));
+      dispatch(changeSetting(['notifications', 'alerts', 'mention'], true));
+      dispatch(changeSetting(['notifications', 'alerts', 'poll'], true));
+      dispatch(changeSetting(['notifications', 'alerts', 'status'], true));
+      dispatch(saveSettings());
+    }
+  }));
 };
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
index 2705a6001..0e40ee1d6 100644
--- a/app/javascript/mastodon/components/account.js
+++ b/app/javascript/mastodon/components/account.js
@@ -8,6 +8,7 @@ import IconButton from './icon_button';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { me } from '../initial_state';
+import RelativeTimestamp from './relative_timestamp';
 
 const messages = defineMessages({
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
@@ -107,11 +108,17 @@ class Account extends ImmutablePureComponent {
       }
     }
 
+    let mute_expires_at;
+    if (account.get('mute_expires_at')) {
+      mute_expires_at =  <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>;
+    }
+
     return (
       <div className='account'>
         <div className='account__wrapper'>
           <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
+            {mute_expires_at}
             <DisplayName account={account} />
           </Permalink>
 
diff --git a/app/javascript/mastodon/components/autosuggest_emoji.js b/app/javascript/mastodon/components/autosuggest_emoji.js
index ce4383a60..4937e4d98 100644
--- a/app/javascript/mastodon/components/autosuggest_emoji.js
+++ b/app/javascript/mastodon/components/autosuggest_emoji.js
@@ -1,8 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
-
-const assetHost = process.env.CDN_HOST || '';
+import { assetHost } from 'mastodon/utils/config';
 
 export default class AutosuggestEmoji extends React.PureComponent {
 
diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js
index 1bb583583..236e92296 100644
--- a/app/javascript/mastodon/components/column_header.js
+++ b/app/javascript/mastodon/components/column_header.js
@@ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent {
     onMove: PropTypes.func,
     onClick: PropTypes.func,
     appendContent: PropTypes.node,
+    collapseIssues: PropTypes.bool,
   };
 
   state = {
@@ -83,7 +84,7 @@ class ColumnHeader extends React.PureComponent {
   }
 
   render () {
-    const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props;
+    const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
     const { collapsed, animating } = this.state;
 
     const wrapperClassName = classNames('column-header__wrapper', {
@@ -145,7 +146,20 @@ class ColumnHeader extends React.PureComponent {
     }
 
     if (children || (multiColumn && this.props.onPin)) {
-      collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>;
+      collapseButton = (
+        <button
+          className={collapsibleButtonClassName}
+          title={formatMessage(collapsed ? messages.show : messages.hide)}
+          aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
+          aria-pressed={collapsed ? 'false' : 'true'}
+          onClick={this.handleToggleClick}
+        >
+          <i className='icon-with-badge'>
+            <Icon id='sliders' />
+            {collapseIssues && <i className='icon-with-badge__issue-badge' />}
+          </i>
+        </button>
+      );
     }
 
     const hasTitle = icon && title;
diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
index 7f83dc1b9..7ec39198a 100644
--- a/app/javascript/mastodon/components/icon_button.js
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -116,6 +116,7 @@ export default class IconButton extends React.PureComponent {
       activate,
       deactivate,
       overlayed: overlay,
+      'icon-button--with-counter': typeof counter !== 'undefined',
     });
 
     if (typeof counter !== 'undefined') {
diff --git a/app/javascript/mastodon/components/icon_with_badge.js b/app/javascript/mastodon/components/icon_with_badge.js
index 7851eb4be..4214eccfd 100644
--- a/app/javascript/mastodon/components/icon_with_badge.js
+++ b/app/javascript/mastodon/components/icon_with_badge.js
@@ -4,16 +4,18 @@ import Icon from 'mastodon/components/icon';
 
 const formatNumber = num => num > 40 ? '40+' : num;
 
-const IconWithBadge = ({ id, count, className }) => (
+const IconWithBadge = ({ id, count, issueBadge, className }) => (
   <i className='icon-with-badge'>
     <Icon id={id} fixedWidth className={className} />
     {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
+    {issueBadge && <i className='icon-with-badge__issue-badge' />}
   </i>
 );
 
 IconWithBadge.propTypes = {
   id: PropTypes.string.isRequired,
   count: PropTypes.number.isRequired,
+  issueBadge: PropTypes.bool,
   className: PropTypes.string,
 };
 
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 e8a36a923..bac136e5e 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -7,6 +7,7 @@ import classNames from 'classnames';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import detectPassiveEvents from 'detect-passive-events';
 import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
+import { assetHost } from 'mastodon/utils/config';
 
 const messages = defineMessages({
   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@@ -25,7 +26,6 @@ const messages = defineMessages({
   flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
 });
 
-const assetHost = process.env.CDN_HOST || '';
 let EmojiPicker, Emoji; // load asynchronously
 
 const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js
index 5d9dad097..4e37f3a80 100644
--- a/app/javascript/mastodon/features/emoji/emoji.js
+++ b/app/javascript/mastodon/features/emoji/emoji.js
@@ -1,11 +1,10 @@
 import { autoPlayGif } from '../../initial_state';
 import unicodeMapping from './emoji_unicode_mapping_light';
+import { assetHost } from 'mastodon/utils/config';
 import Trie from 'substring-trie';
 
 const trie = new Trie(Object.keys(unicodeMapping));
 
-const assetHost = process.env.CDN_HOST || '';
-
 // Convert to file names from emojis. (For different variation selector emojis)
 const emojiFilenames = (emojis) => {
   return emojis.map(v => unicodeMapping[v].filename);
diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js
index 1896994da..4853c3935 100644
--- a/app/javascript/mastodon/features/getting_started/components/announcements.js
+++ b/app/javascript/mastodon/features/getting_started/components/announcements.js
@@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
 import IconButton from 'mastodon/components/icon_button';
 import Icon from 'mastodon/components/icon';
 import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
-import { autoPlayGif, reduceMotion } from 'mastodon/initial_state';
+import { autoPlayGif, reduceMotion, disableSwiping } from 'mastodon/initial_state';
 import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
 import { mascot } from 'mastodon/initial_state';
 import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
@@ -15,6 +15,7 @@ import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_pick
 import AnimatedNumber from 'mastodon/components/animated_number';
 import TransitionMotion from 'react-motion/lib/TransitionMotion';
 import spring from 'react-motion/lib/spring';
+import { assetHost } from 'mastodon/utils/config';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -153,8 +154,6 @@ class Content extends ImmutablePureComponent {
 
 }
 
-const assetHost = process.env.CDN_HOST || '';
-
 class Emoji extends React.PureComponent {
 
   static propTypes = {
@@ -436,6 +435,7 @@ class Announcements extends ImmutablePureComponent {
                 removeReaction={this.props.removeReaction}
                 intl={intl}
                 selected={index === idx}
+                disabled={disableSwiping}
               />
             ))}
           </ReactSwipeableViews>
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index 02e1bbba5..1b9994612 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -10,7 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import { me, profile_directory, showTrends } from '../../initial_state';
 import { fetchFollowRequests } from 'mastodon/actions/accounts';
 import { List as ImmutableList } from 'immutable';
-import NavigationBar from '../compose/components/navigation_bar';
+import NavigationContainer from '../compose/containers/navigation_container';
 import Icon from 'mastodon/components/icon';
 import LinkFooter from 'mastodon/features/ui/components/link_footer';
 import TrendsContainer from './containers/trends_container';
@@ -168,7 +168,7 @@ class GettingStarted extends ImmutablePureComponent {
 
         <div className='getting-started'>
           <div className='getting-started__wrapper' style={{ height }}>
-            {!multiColumn && <NavigationBar account={myAccount} />}
+            {!multiColumn && <NavigationContainer />}
             {navItems}
           </div>
 
diff --git a/app/javascript/mastodon/features/introduction/index.js b/app/javascript/mastodon/features/introduction/index.js
index 754477bb9..5820750a4 100644
--- a/app/javascript/mastodon/features/introduction/index.js
+++ b/app/javascript/mastodon/features/introduction/index.js
@@ -9,6 +9,7 @@ import screenHello from '../../../images/screen_hello.svg';
 import screenFederation from '../../../images/screen_federation.svg';
 import screenInteractions from '../../../images/screen_interactions.svg';
 import logoTransparent from '../../../images/logo_transparent.svg';
+import { disableSwiping } from 'mastodon/initial_state';
 
 const FrameWelcome = ({ domain, onNext }) => (
   <div className='introduction__frame'>
@@ -171,7 +172,7 @@ class Introduction extends React.PureComponent {
 
     return (
       <div className='introduction'>
-        <ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} className='introduction__pager'>
+        <ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} disabled={disableSwiping} className='introduction__pager'>
           {pages.map((page, i) => (
             <div key={i} className={classNames('introduction__frame-wrapper', { 'active': i === currentIndex })}>{page}</div>
           ))}
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
index 8bd03fbda..169e4b44d 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -12,6 +12,10 @@ export default class ColumnSettings extends React.PureComponent {
     pushSettings: ImmutablePropTypes.map.isRequired,
     onChange: PropTypes.func.isRequired,
     onClear: PropTypes.func.isRequired,
+    onRequestNotificationPermission: PropTypes.func,
+    alertsEnabled: PropTypes.bool,
+    browserSupport: PropTypes.bool,
+    browserPermission: PropTypes.bool,
   };
 
   onPushChange = (path, checked) => {
@@ -19,7 +23,7 @@ export default class ColumnSettings extends React.PureComponent {
   }
 
   render () {
-    const { settings, pushSettings, onChange, onClear } = this.props;
+    const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission } = this.props;
 
     const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
     const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
@@ -32,6 +36,12 @@ export default class ColumnSettings extends React.PureComponent {
 
     return (
       <div>
+        {alertsEnabled && browserSupport && browserPermission === 'denied' && (
+          <div className='column-settings__row column-settings__row--with-margin'>
+            <span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span>
+          </div>
+        )}
+
         <div className='column-settings__row'>
           <ClearColumnButton onClick={onClear} />
         </div>
@@ -40,6 +50,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-filter-bar' className='column-settings__section'>
             <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
           </span>
+
           <div className='column-settings__row'>
             <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
             <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
@@ -50,7 +61,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
@@ -61,7 +72,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
@@ -72,7 +83,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
@@ -83,7 +94,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} />
@@ -94,7 +105,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
@@ -105,12 +116,23 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
           </div>
         </div>
+
+        <div role='group' aria-labelledby='notifications-status'>
+          <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js b/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js
new file mode 100644
index 000000000..766c9bb5b
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import Icon from 'mastodon/components/icon';
+import Button from 'mastodon/components/button';
+import { requestBrowserPermission } from 'mastodon/actions/notifications';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+export default @connect(() => {})
+class NotificationsPermissionBanner extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.dispatch(requestBrowserPermission());
+  }
+
+  render () {
+    return (
+      <div className='notifications-permission-banner'>
+        <h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2>
+        <p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p>
+        <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
index e6f593ef8..c4c8bffbe 100644
--- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js
+++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
@@ -12,6 +12,7 @@ export default class SettingToggle extends React.PureComponent {
     label: PropTypes.node.isRequired,
     onChange: PropTypes.func.isRequired,
     defaultValue: PropTypes.bool,
+    disabled: PropTypes.bool,
   }
 
   onChange = ({ target }) => {
@@ -19,12 +20,12 @@ export default class SettingToggle extends React.PureComponent {
   }
 
   render () {
-    const { prefix, settings, settingPath, label, defaultValue } = this.props;
+    const { prefix, settings, settingPath, label, defaultValue, disabled } = this.props;
     const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-');
 
     return (
       <div className='setting-toggle'>
-        <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
+        <Toggle disabled={disabled} id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
         <label htmlFor={id} className='setting-toggle__label'>{label}</label>
       </div>
     );
diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
index a67f26295..9a70bd4f3 100644
--- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
+++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
@@ -3,28 +3,55 @@ import { defineMessages, injectIntl } from 'react-intl';
 import ColumnSettings from '../components/column_settings';
 import { changeSetting } from '../../../actions/settings';
 import { setFilter } from '../../../actions/notifications';
-import { clearNotifications } from '../../../actions/notifications';
+import { clearNotifications, requestBrowserPermission } from '../../../actions/notifications';
 import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications';
 import { openModal } from '../../../actions/modal';
+import { showAlert } from '../../../actions/alerts';
 
 const messages = defineMessages({
   clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' },
   clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' },
+  permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' },
 });
 
 const mapStateToProps = state => ({
   settings: state.getIn(['settings', 'notifications']),
   pushSettings: state.get('push_notifications'),
+  alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true),
+  browserSupport: state.getIn(['notifications', 'browserSupport']),
+  browserPermission: state.getIn(['notifications', 'browserPermission']),
 });
 
 const mapDispatchToProps = (dispatch, { intl }) => ({
 
   onChange (path, checked) {
     if (path[0] === 'push') {
-      dispatch(changePushNotifications(path.slice(1), checked));
+      if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+        dispatch(requestBrowserPermission((permission) => {
+          if (permission === 'granted') {
+            dispatch(changePushNotifications(path.slice(1), checked));
+          } else {
+            dispatch(showAlert(undefined, messages.permissionDenied));
+          }
+        }));
+      } else {
+        dispatch(changePushNotifications(path.slice(1), checked));
+      }
     } else if (path[0] === 'quickFilter') {
       dispatch(changeSetting(['notifications', ...path], checked));
       dispatch(setFilter('all'));
+    } else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+      if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+        dispatch(requestBrowserPermission((permission) => {
+          if (permission === 'granted') {
+            dispatch(changeSetting(['notifications', ...path], checked));
+          } else {
+            dispatch(showAlert(undefined, messages.permissionDenied));
+          }
+        }));
+      } else {
+        dispatch(changeSetting(['notifications', ...path], checked));
+      }
     } else {
       dispatch(changeSetting(['notifications', ...path], checked));
     }
@@ -38,6 +65,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }));
   },
 
+  onRequestNotificationPermission () {
+    dispatch(requestBrowserPermission());
+  },
+
 });
 
 export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index 41a45b2b6..73df7f49d 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -12,6 +12,7 @@ import {
   unmountNotifications,
   markNotificationsAsRead,
 } from '../../actions/notifications';
+import { submitMarkers } from '../../actions/markers';
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import NotificationContainer from './containers/notification_container';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
@@ -24,6 +25,7 @@ import ScrollableList from '../../components/scrollable_list';
 import LoadGap from '../../components/load_gap';
 import Icon from 'mastodon/components/icon';
 import compareId from 'mastodon/compare_id';
+import NotificationsPermissionBanner from './components/notifications_permission_banner';
 
 const messages = defineMessages({
   title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@@ -54,6 +56,7 @@ const mapStateToProps = state => ({
   numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
   lastReadId: state.getIn(['notifications', 'readMarkerId']),
   canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
+  needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default',
 });
 
 export default @connect(mapStateToProps)
@@ -74,6 +77,7 @@ class Notifications extends React.PureComponent {
     numPending: PropTypes.number,
     lastReadId: PropTypes.string,
     canMarkAsRead: PropTypes.bool,
+    needsNotificationPermission: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -162,10 +166,11 @@ class Notifications extends React.PureComponent {
 
   handleMarkAsRead = () => {
     this.props.dispatch(markNotificationsAsRead());
+    this.props.dispatch(submitMarkers({ immediate: true }));
   };
 
   render () {
-    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props;
+    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
     const pinned = !!columnId;
     const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
 
@@ -209,6 +214,8 @@ class Notifications extends React.PureComponent {
         showLoading={isLoading && notifications.size === 0}
         hasMore={hasMore}
         numPending={numPending}
+        prepend={needsNotificationPermission && <NotificationsPermissionBanner />}
+        alwaysPrepend
         emptyMessage={emptyMessage}
         onLoadMore={this.handleLoadOlder}
         onLoadPending={this.handleLoadPending}
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 9b03cf26d..ecc0b8f0b 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -8,6 +8,8 @@ import ReactSwipeableViews from 'react-swipeable-views';
 import TabsBar, { links, getIndex, getLink } from './tabs_bar';
 import { Link } from 'react-router-dom';
 
+import { disableSwiping } from 'mastodon/initial_state';
+
 import BundleContainer from '../containers/bundle_container';
 import ColumnLoading from './column_loading';
 import DrawerLoading from './drawer_loading';
@@ -185,7 +187,7 @@ class ColumnsArea extends ImmutablePureComponent {
       const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
 
       const content = columnIndex !== -1 ? (
-        <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }}>
+        <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={disableSwiping}>
           {links.map(this.renderView)}
         </ReactSwipeableViews>
       ) : (
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
index 926a495d1..e19f277d8 100644
--- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js
+++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
@@ -20,6 +20,7 @@ import GIFV from 'mastodon/components/gifv';
 import { me } from 'mastodon/initial_state';
 import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
 import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
+import { assetHost } from 'mastodon/utils/config';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -50,8 +51,6 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
   .replace(/\n/g, ' ')
   .replace(/\*\*\*\*\*\*/g, '\n\n');
 
-const assetHost = process.env.CDN_HOST || '';
-
 class ImageLoader extends React.PureComponent {
 
   static propTypes = {
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
index a02e3bbd7..54ec51fcf 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.js
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -10,6 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import ImageLoader from './image_loader';
 import Icon from 'mastodon/components/icon';
 import GIFV from 'mastodon/components/gifv';
+import { disableSwiping } from 'mastodon/initial_state';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -212,6 +213,7 @@ class MediaModal extends ImmutablePureComponent {
             containerStyle={containerStyle}
             onChangeIndex={this.handleSwipe}
             index={index}
+            disabled={disableSwiping}
           >
             {content}
           </ReactSwipeableViews>
diff --git a/app/javascript/mastodon/features/ui/components/mute_modal.js b/app/javascript/mastodon/features/ui/components/mute_modal.js
index 852830c3c..d8d8e68c3 100644
--- a/app/javascript/mastodon/features/ui/components/mute_modal.js
+++ b/app/javascript/mastodon/features/ui/components/mute_modal.js
@@ -1,25 +1,32 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
-import { injectIntl, FormattedMessage } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import Toggle from 'react-toggle';
 import Button from '../../../components/button';
 import { closeModal } from '../../../actions/modal';
 import { muteAccount } from '../../../actions/accounts';
-import { toggleHideNotifications } from '../../../actions/mutes';
+import { toggleHideNotifications, changeMuteDuration } from '../../../actions/mutes';
 
+const messages = defineMessages({
+  minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
+  hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
+  days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
+  indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' },
+});
 
 const mapStateToProps = state => {
   return {
     account: state.getIn(['mutes', 'new', 'account']),
     notifications: state.getIn(['mutes', 'new', 'notifications']),
+    muteDuration: state.getIn(['mutes', 'new', 'duration']),
   };
 };
 
 const mapDispatchToProps = dispatch => {
   return {
-    onConfirm(account, notifications) {
-      dispatch(muteAccount(account.get('id'), notifications));
+    onConfirm(account, notifications, muteDuration) {
+      dispatch(muteAccount(account.get('id'), notifications, muteDuration));
     },
 
     onClose() {
@@ -29,6 +36,10 @@ const mapDispatchToProps = dispatch => {
     onToggleNotifications() {
       dispatch(toggleHideNotifications());
     },
+
+    onChangeMuteDuration(e) {
+      dispatch(changeMuteDuration(e.target.value));
+    },
   };
 };
 
@@ -43,6 +54,8 @@ class MuteModal extends React.PureComponent {
     onConfirm: PropTypes.func.isRequired,
     onToggleNotifications: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
+    muteDuration: PropTypes.number.isRequired,
+    onChangeMuteDuration: PropTypes.func.isRequired,
   };
 
   componentDidMount() {
@@ -51,7 +64,7 @@ class MuteModal extends React.PureComponent {
 
   handleClick = () => {
     this.props.onClose();
-    this.props.onConfirm(this.props.account, this.props.notifications);
+    this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration);
   }
 
   handleCancel = () => {
@@ -66,8 +79,12 @@ class MuteModal extends React.PureComponent {
     this.props.onToggleNotifications();
   }
 
+  changeMuteDuration = (e) => {
+    this.props.onChangeMuteDuration(e);
+  }
+
   render () {
-    const { account, notifications } = this.props;
+    const { account, notifications, muteDuration, intl } = this.props;
 
     return (
       <div className='modal-root__modal mute-modal'>
@@ -91,6 +108,21 @@ class MuteModal extends React.PureComponent {
               <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
             </label>
           </div>
+          <div>
+            <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span>
+
+            {/* eslint-disable-next-line jsx-a11y/no-onchange */}
+            <select value={muteDuration} onChange={this.changeMuteDuration}>
+              <option value={0}>{intl.formatMessage(messages.indefinite)}</option>
+              <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
+              <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
+              <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
+              <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
+              <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
+              <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
+              <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
+            </select>
+          </div>
         </div>
 
         <div className='mute-modal__action-bar'>
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index d05133507..c6df49a5f 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -266,7 +266,7 @@ class UI extends React.PureComponent {
 
   handleWindowFocus = () => {
     this.props.dispatch(focusApp());
-    this.props.dispatch(submitMarkers());
+    this.props.dispatch(submitMarkers({ immediate: true }));
   }
 
   handleWindowBlur = () => {
@@ -366,10 +366,6 @@ class UI extends React.PureComponent {
       navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
     }
 
-    if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
-      window.setTimeout(() => Notification.requestPermission(), 120 * 1000);
-    }
-
     this.props.dispatch(fetchMarkers());
     this.props.dispatch(expandHomeTimeline());
     this.props.dispatch(expandNotifications());
@@ -379,7 +375,7 @@ class UI extends React.PureComponent {
 
   componentDidMount () {
     this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
-      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey;
+      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
     };
   }
 
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
index 847d29dea..89b59051c 100644
--- a/app/javascript/mastodon/initial_state.js
+++ b/app/javascript/mastodon/initial_state.js
@@ -26,5 +26,6 @@ export const usePendingItems = getMeta('use_pending_items');
 export const showTrends = getMeta('trends');
 export const title = getMeta('title');
 export const cropImages = getMeta('crop_images');
+export const disableSwiping = getMeta('disable_swiping');
 
 export default initialState;
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index e519f665d..d8c04854e 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -168,10 +168,18 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.",
+        "id": "error.unexpected_crash.explanation_addons"
+      },
+      {
         "defaultMessage": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.",
         "id": "error.unexpected_crash.explanation"
       },
       {
+        "defaultMessage": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
+        "id": "error.unexpected_crash.next_steps_addons"
+      },
+      {
         "defaultMessage": "Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
         "id": "error.unexpected_crash.next_steps"
       },
@@ -268,6 +276,15 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Put it back",
+        "id": "picture_in_picture.restore"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/picture_in_picture_placeholder.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Closed",
         "id": "poll.closed"
       },
@@ -636,6 +653,15 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Profile unavailable",
+        "id": "empty_column.account_unavailable"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/account_gallery/index.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Toots",
         "id": "account.posts"
       },
@@ -797,6 +823,14 @@
         "id": "account.show_reblogs"
       },
       {
+        "defaultMessage": "Notify me when @{name} posts",
+        "id": "account.enable_notifications"
+      },
+      {
+        "defaultMessage": "Stop notifying me when @{name} posts",
+        "id": "account.disable_notifications"
+      },
+      {
         "defaultMessage": "Pinned toots",
         "id": "navigation_bar.pins"
       },
@@ -2126,6 +2160,18 @@
         "id": "confirmations.delete_list.confirm"
       },
       {
+        "defaultMessage": "Any followed user",
+        "id": "lists.replies_policy.all_replies"
+      },
+      {
+        "defaultMessage": "No one",
+        "id": "lists.replies_policy.no_replies"
+      },
+      {
+        "defaultMessage": "Members of the list",
+        "id": "lists.replies_policy.list_replies"
+      },
+      {
         "defaultMessage": "Edit list",
         "id": "lists.edit"
       },
@@ -2134,6 +2180,10 @@
         "id": "lists.delete"
       },
       {
+        "defaultMessage": "Show replies to:",
+        "id": "lists.replies_policy.title"
+      },
+      {
         "defaultMessage": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
         "id": "empty_column.list"
       }
@@ -2219,6 +2269,10 @@
         "id": "notifications.column_settings.push"
       },
       {
+        "defaultMessage": "Desktop notifications are unavailable due to previously denied browser permissions request",
+        "id": "notifications.permission_denied"
+      },
+      {
         "defaultMessage": "Quick filter bar",
         "id": "notifications.column_settings.filter_bar.category"
       },
@@ -2245,6 +2299,10 @@
       {
         "defaultMessage": "Poll results:",
         "id": "notifications.column_settings.poll"
+      },
+      {
+        "defaultMessage": "New toots:",
+        "id": "notifications.column_settings.status"
       }
     ],
     "path": "app/javascript/mastodon/features/notifications/components/column_settings.json"
@@ -2272,6 +2330,10 @@
         "id": "notifications.filter.follows"
       },
       {
+        "defaultMessage": "Updates from people you follow",
+        "id": "notifications.filter.statuses"
+      },
+      {
         "defaultMessage": "All",
         "id": "notifications.filter.all"
       }
@@ -2314,6 +2376,10 @@
         "id": "notification.reblog"
       },
       {
+        "defaultMessage": "{name} just posted",
+        "id": "notification.status"
+      },
+      {
         "defaultMessage": "{name} has requested to follow you",
         "id": "notification.follow_request"
       }
@@ -2323,12 +2389,33 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Never miss a thing",
+        "id": "notifications_permission_banner.title"
+      },
+      {
+        "defaultMessage": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
+        "id": "notifications_permission_banner.how_to_control"
+      },
+      {
+        "defaultMessage": "Enable desktop notifications",
+        "id": "notifications_permission_banner.enable"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/notifications/components/notifications_permission_banner.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Are you sure you want to permanently clear all your notifications?",
         "id": "notifications.clear_confirmation"
       },
       {
         "defaultMessage": "Clear notifications",
         "id": "notifications.clear"
+      },
+      {
+        "defaultMessage": "Desktop notifications can't be enabled, as browser permission has been denied before",
+        "id": "notifications.permission_denied_alert"
       }
     ],
     "path": "app/javascript/mastodon/features/notifications/containers/column_settings_container.json"
@@ -2340,6 +2427,10 @@
         "id": "column.notifications"
       },
       {
+        "defaultMessage": "Mark every notification as read",
+        "id": "notifications.mark_as_read"
+      },
+      {
         "defaultMessage": "You don't have any notifications yet. Interact with others to start the conversation.",
         "id": "empty_column.notifications"
       }
@@ -2349,6 +2440,47 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Reply",
+        "id": "status.reply"
+      },
+      {
+        "defaultMessage": "Reply to thread",
+        "id": "status.replyAll"
+      },
+      {
+        "defaultMessage": "Boost",
+        "id": "status.reblog"
+      },
+      {
+        "defaultMessage": "Boost with original visibility",
+        "id": "status.reblog_private"
+      },
+      {
+        "defaultMessage": "Unboost",
+        "id": "status.cancel_reblog_private"
+      },
+      {
+        "defaultMessage": "This post cannot be boosted",
+        "id": "status.cannot_reblog"
+      },
+      {
+        "defaultMessage": "Favourite",
+        "id": "status.favourite"
+      },
+      {
+        "defaultMessage": "Reply",
+        "id": "confirmations.reply.confirm"
+      },
+      {
+        "defaultMessage": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
+        "id": "confirmations.reply.message"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/picture_in_picture/components/footer.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Pinned toot",
         "id": "column.pins"
       }
@@ -2799,6 +2931,14 @@
         "id": "upload_form.description"
       },
       {
+        "defaultMessage": "Analyzing picture…",
+        "id": "upload_modal.analyzing_picture"
+      },
+      {
+        "defaultMessage": "Preparing OCR…",
+        "id": "upload_modal.preparing_ocr"
+      },
+      {
         "defaultMessage": "Edit media",
         "id": "upload_modal.edit_media"
       },
@@ -2811,10 +2951,6 @@
         "id": "upload_form.thumbnail"
       },
       {
-        "defaultMessage": "Analyzing picture…",
-        "id": "upload_modal.analyzing_picture"
-      },
-      {
         "defaultMessage": "Detect text from picture",
         "id": "upload_modal.detect_text"
       },
@@ -2911,6 +3047,22 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "{number, plural, one {# minute} other {# minutes}}",
+        "id": "intervals.full.minutes"
+      },
+      {
+        "defaultMessage": "{number, plural, one {# hour} other {# hours}}",
+        "id": "intervals.full.hours"
+      },
+      {
+        "defaultMessage": "{number, plural, one {# day} other {# days}}",
+        "id": "intervals.full.days"
+      },
+      {
+        "defaultMessage": "Indefinite",
+        "id": "mute_modal.indefinite"
+      },
+      {
         "defaultMessage": "Are you sure you want to mute {name}?",
         "id": "confirmations.mute.message"
       },
@@ -2923,6 +3075,10 @@
         "id": "mute_modal.hide_notifications"
       },
       {
+        "defaultMessage": "Duration",
+        "id": "mute_modal.duration"
+      },
+      {
         "defaultMessage": "Cancel",
         "id": "confirmation_modal.cancel"
       },
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 4d4bf7277..5b7fbaffd 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -9,8 +9,10 @@
   "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "Direct message @{name}",
+  "account.disable_notifications": "Stop notifying me when @{name} posts",
   "account.domain_blocked": "Domain blocked",
   "account.edit_profile": "Edit profile",
+  "account.enable_notifications": "Notify me when @{name} posts",
   "account.endorse": "Feature on profile",
   "account.follow": "Follow",
   "account.followers": "Followers",
@@ -170,7 +172,9 @@
   "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
   "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up",
   "error.unexpected_crash.explanation": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.",
+  "error.unexpected_crash.explanation_addons": "This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.",
   "error.unexpected_crash.next_steps": "Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
+  "error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
   "errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard",
   "errors.unexpected_crash.report_issue": "Report issue",
   "follow_request.authorize": "Authorize",
@@ -264,6 +268,10 @@
   "lists.edit.submit": "Change title",
   "lists.new.create": "Add list",
   "lists.new.title_placeholder": "New list title",
+  "lists.replies_policy.all_replies": "Any followed user",
+  "lists.replies_policy.list_replies": "Members of the list",
+  "lists.replies_policy.no_replies": "No one",
+  "lists.replies_policy.title": "Show replies to:",
   "lists.search": "Search among people you follow",
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
@@ -271,7 +279,9 @@
   "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
+  "mute_modal.duration": "Duration",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
+  "mute_modal.indefinite": "Indefinite",
   "navigation_bar.apps": "Mobile apps",
   "navigation_bar.blocks": "Blocked users",
   "navigation_bar.bookmarks": "Bookmarks",
@@ -303,6 +313,7 @@
   "notification.own_poll": "Your poll has ended",
   "notification.poll": "A poll you have voted in has ended",
   "notification.reblog": "{name} boosted your toot",
+  "notification.status": "{name} just posted",
   "notifications.clear": "Clear notifications",
   "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
   "notifications.column_settings.alert": "Desktop notifications",
@@ -318,13 +329,22 @@
   "notifications.column_settings.reblog": "Boosts:",
   "notifications.column_settings.show": "Show in column",
   "notifications.column_settings.sound": "Play sound",
+  "notifications.column_settings.status": "New toots:",
   "notifications.filter.all": "All",
   "notifications.filter.boosts": "Boosts",
   "notifications.filter.favourites": "Favourites",
   "notifications.filter.follows": "Follows",
   "notifications.filter.mentions": "Mentions",
   "notifications.filter.polls": "Poll results",
+  "notifications.filter.statuses": "Updates from people you follow",
   "notifications.group": "{count} notifications",
+  "notifications.mark_as_read": "Mark every notification as read",
+  "notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request",
+  "notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before",
+  "notifications_permission_banner.enable": "Enable desktop notifications",
+  "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
+  "notifications_permission_banner.title": "Never miss a thing",
+  "picture_in_picture.restore": "Put it back",
   "poll.closed": "Closed",
   "poll.refresh": "Refresh",
   "poll.total_people": "{count, plural, one {# person} other {# people}}",
@@ -451,6 +471,7 @@
   "upload_modal.detect_text": "Detect text from picture",
   "upload_modal.edit_media": "Edit media",
   "upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.",
+  "upload_modal.preparing_ocr": "Preparing OCR…",
   "upload_modal.preview_label": "Preview ({ratio})",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index af2f5a53f..6ee1c0819 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -272,6 +272,8 @@
   "missing_indicator.label": "見つかりません",
   "missing_indicator.sublabel": "見つかりませんでした",
   "mute_modal.hide_notifications": "このユーザーからの通知を隠しますか?",
+  "mute_modal.duration": "ミュートする期間",
+  "mute_modal.indefinite": "無期限",
   "navigation_bar.apps": "アプリ",
   "navigation_bar.blocks": "ブロックしたユーザー",
   "navigation_bar.bookmarks": "ブックマーク",
diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js
index da4884fd3..bda51f692 100644
--- a/app/javascript/mastodon/main.js
+++ b/app/javascript/mastodon/main.js
@@ -1,4 +1,5 @@
 import * as registerPushNotifications from './actions/push_notifications';
+import { setupBrowserNotifications } from './actions/notifications';
 import { default as Mastodon, store } from './containers/mastodon';
 import React from 'react';
 import ReactDOM from 'react-dom';
@@ -22,6 +23,7 @@ function main() {
     const props = JSON.parse(mountNode.getAttribute('data-props'));
 
     ReactDOM.render(<Mastodon {...props} />, mountNode);
+    store.dispatch(setupBrowserNotifications());
     if (process.env.NODE_ENV === 'production') {
       // avoid offline in dev mode because it's harder to debug
       require('offline-plugin/runtime').install();
diff --git a/app/javascript/mastodon/reducers/mutes.js b/app/javascript/mastodon/reducers/mutes.js
index 4672e5097..a9eb61ff8 100644
--- a/app/javascript/mastodon/reducers/mutes.js
+++ b/app/javascript/mastodon/reducers/mutes.js
@@ -3,12 +3,14 @@ import Immutable from 'immutable';
 import {
   MUTES_INIT_MODAL,
   MUTES_TOGGLE_HIDE_NOTIFICATIONS,
+  MUTES_CHANGE_DURATION,
 } from '../actions/mutes';
 
 const initialState = Immutable.Map({
   new: Immutable.Map({
     account: null,
     notifications: true,
+    duration: 0,
   }),
 });
 
@@ -21,6 +23,8 @@ export default function mutes(state = initialState, action) {
     });
   case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
     return state.updateIn(['new', 'notifications'], (old) => !old);
+  case MUTES_CHANGE_DURATION:
+    return state.setIn(['new', 'duration'], Number(action.duration));
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index b01db806f..1d4874717 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -10,6 +10,8 @@ import {
   NOTIFICATIONS_MOUNT,
   NOTIFICATIONS_UNMOUNT,
   NOTIFICATIONS_MARK_AS_READ,
+  NOTIFICATIONS_SET_BROWSER_SUPPORT,
+  NOTIFICATIONS_SET_BROWSER_PERMISSION,
 } from '../actions/notifications';
 import {
   ACCOUNT_BLOCK_SUCCESS,
@@ -40,6 +42,8 @@ const initialState = ImmutableMap({
   readMarkerId: '0',
   isTabVisible: true,
   isLoading: false,
+  browserSupport: false,
+  browserPermission: 'default',
 });
 
 const notificationToMap = notification => ImmutableMap({
@@ -151,7 +155,7 @@ const deleteByStatus = (state, statusId) => {
 
 const updateMounted = (state) => {
   state = state.update('mounted', count => count + 1);
-  if (!shouldCountUnreadNotifications(state)) {
+  if (!shouldCountUnreadNotifications(state, state.get('mounted') === 1)) {
     state = state.set('readMarkerId', state.get('lastReadId'));
     state = clearUnread(state);
   }
@@ -167,14 +171,15 @@ const updateVisibility = (state, visibility) => {
   return state;
 };
 
-const shouldCountUnreadNotifications = (state) => {
+const shouldCountUnreadNotifications = (state, ignoreScroll = false) => {
   const isTabVisible   = state.get('isTabVisible');
   const isOnTop        = state.get('top');
   const isMounted      = state.get('mounted') > 0;
   const lastReadId     = state.get('lastReadId');
-  const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (!state.get('items').isEmpty() && compareId(state.get('items').last().get('id'), lastReadId) <= 0);
+  const lastItem       = state.get('items').findLast(item => item !== null);
+  const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0);
 
-  return !(isTabVisible && isOnTop && isMounted && lastItemReached);
+  return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached);
 };
 
 const recountUnread = (state, last_read_id) => {
@@ -241,6 +246,10 @@ export default function notifications(state = initialState, action) {
   case NOTIFICATIONS_MARK_AS_READ:
     const lastNotification = state.get('items').find(item => item !== null);
     return lastNotification ? recountUnread(state, lastNotification.get('id')) : state;
+  case NOTIFICATIONS_SET_BROWSER_SUPPORT:
+    return state.set('browserSupport', action.value);
+  case NOTIFICATIONS_SET_BROWSER_PERMISSION:
+    return state.set('browserPermission', action.value);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
index 1d050cc63..53949258a 100644
--- a/app/javascript/mastodon/reducers/relationships.js
+++ b/app/javascript/mastodon/reducers/relationships.js
@@ -45,7 +45,7 @@ const initialState = ImmutableMap();
 export default function relationships(state = initialState, action) {
   switch(action.type) {
   case ACCOUNT_FOLLOW_REQUEST:
-    return state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
+    return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
   case ACCOUNT_FOLLOW_FAIL:
     return state.setIn([action.id, action.locked ? 'requested' : 'following'], false);
   case ACCOUNT_UNFOLLOW_REQUEST:
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
index efef2ad9a..057fa353a 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -29,12 +29,13 @@ const initialState = ImmutableMap({
 
   notifications: ImmutableMap({
     alerts: ImmutableMap({
-      follow: true,
+      follow: false,
       follow_request: false,
-      favourite: true,
-      reblog: true,
-      mention: true,
-      poll: true,
+      favourite: false,
+      reblog: false,
+      mention: false,
+      poll: false,
+      status: false,
     }),
 
     quickFilter: ImmutableMap({
@@ -50,6 +51,7 @@ const initialState = ImmutableMap({
       reblog: true,
       mention: true,
       poll: true,
+      status: true,
     }),
 
     sounds: ImmutableMap({
@@ -59,6 +61,7 @@ const initialState = ImmutableMap({
       reblog: true,
       mention: true,
       poll: true,
+      status: true,
     }),
   }),
 
diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js
index 8165952a7..10aaa2d68 100644
--- a/app/javascript/mastodon/reducers/user_lists.js
+++ b/app/javascript/mastodon/reducers/user_lists.js
@@ -53,14 +53,20 @@ import {
 } from 'mastodon/actions/directory';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
+const initialListState = ImmutableMap({
+  next: null,
+  isLoading: false,
+  items: ImmutableList(),
+});
+
 const initialState = ImmutableMap({
-  followers: ImmutableMap(),
-  following: ImmutableMap(),
-  reblogged_by: ImmutableMap(),
-  favourited_by: ImmutableMap(),
-  follow_requests: ImmutableMap(),
-  blocks: ImmutableMap(),
-  mutes: ImmutableMap(),
+  followers: initialListState,
+  following: initialListState,
+  reblogged_by: initialListState,
+  favourited_by: initialListState,
+  follow_requests: initialListState,
+  blocks: initialListState,
+  mutes: initialListState,
 });
 
 const normalizeList = (state, path, accounts, next) => {
diff --git a/app/javascript/mastodon/utils/config.js b/app/javascript/mastodon/utils/config.js
new file mode 100644
index 000000000..932cd0cbf
--- /dev/null
+++ b/app/javascript/mastodon/utils/config.js
@@ -0,0 +1,10 @@
+import ready from '../ready';
+
+export let assetHost = '';
+
+ready(() => {
+  const cdnHost = document.querySelector('meta[name=cdn-host]');
+  if (cdnHost) {
+    assetHost = cdnHost.content || '';
+  }
+});
diff --git a/app/javascript/mastodon/utils/notifications.js b/app/javascript/mastodon/utils/notifications.js
new file mode 100644
index 000000000..ab119c2e3
--- /dev/null
+++ b/app/javascript/mastodon/utils/notifications.js
@@ -0,0 +1,29 @@
+// Handles browser quirks, based on
+// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
+
+const checkNotificationPromise = () => {
+  try {
+    Notification.requestPermission().then();
+  } catch(e) {
+    return false;
+  }
+
+  return true;
+};
+
+const handlePermission = (permission, callback) => {
+  // Whatever the user answers, we make sure Chrome stores the information
+  if(!('permission' in Notification)) {
+    Notification.permission = permission;
+  }
+
+  callback(Notification.permission);
+};
+
+export const requestNotificationPermission = (callback) => {
+  if (checkNotificationPromise()) {
+    Notification.requestPermission().then((permission) => handlePermission(permission, callback));
+  } else {
+    Notification.requestPermission((permission) => handlePermission(permission, callback));
+  }
+};
diff --git a/app/javascript/packs/about.js b/app/javascript/packs/about.js
index 843cb2c87..892d825ec 100644
--- a/app/javascript/packs/about.js
+++ b/app/javascript/packs/about.js
@@ -1,3 +1,4 @@
+import './public-path';
 import loadPolyfills from '../mastodon/load_polyfills';
 import { start } from '../mastodon/common';
 
diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js
index c65ebed74..91240aecf 100644
--- a/app/javascript/packs/application.js
+++ b/app/javascript/packs/application.js
@@ -1,3 +1,4 @@
+import './public-path';
 import loadPolyfills from '../mastodon/load_polyfills';
 import { start } from '../mastodon/common';
 
diff --git a/app/javascript/packs/common.js b/app/javascript/packs/common.js
index 5d42509c5..05dff8e49 100644
--- a/app/javascript/packs/common.js
+++ b/app/javascript/packs/common.js
@@ -1 +1,2 @@
+import './public-path';
 import 'styles/application.scss';
diff --git a/app/javascript/packs/error.js b/app/javascript/packs/error.js
index 685c89065..6376dc2f5 100644
--- a/app/javascript/packs/error.js
+++ b/app/javascript/packs/error.js
@@ -1,3 +1,4 @@
+import './public-path';
 import ready from '../mastodon/ready';
 
 ready(() => {
diff --git a/app/javascript/packs/public-path.js b/app/javascript/packs/public-path.js
new file mode 100644
index 000000000..f96109f4f
--- /dev/null
+++ b/app/javascript/packs/public-path.js
@@ -0,0 +1,21 @@
+// Dynamically set webpack's loading path depending on a meta header, in order
+// to share the same assets regardless of instance configuration.
+// See https://webpack.js.org/guides/public-path/#on-the-fly
+
+function removeOuterSlashes(string) {
+  return string.replace(/^\/*/, '').replace(/\/*$/, '');
+}
+
+function formatPublicPath(host = '', path = '') {
+  let formattedHost = removeOuterSlashes(host);
+  if (formattedHost && !/^http/i.test(formattedHost)) {
+    formattedHost = `//${formattedHost}`;
+  }
+  const formattedPath = removeOuterSlashes(path);
+  return `${formattedHost}/${formattedPath}/`;
+}
+
+const cdnHost = document.querySelector('meta[name=cdn-host]');
+
+// eslint-disable-next-line camelcase, no-undef, no-unused-vars
+__webpack_public_path__ = formatPublicPath(cdnHost ? cdnHost.content : '', process.env.PUBLIC_OUTPUT_PATH);
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 5ad25a9b0..3f6700195 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -1,3 +1,4 @@
+import './public-path';
 import loadPolyfills from '../mastodon/load_polyfills';
 import ready from '../mastodon/ready';
 import { start } from '../mastodon/common';
diff --git a/app/javascript/packs/share.js b/app/javascript/packs/share.js
index 4ef23e1b2..1225d7b52 100644
--- a/app/javascript/packs/share.js
+++ b/app/javascript/packs/share.js
@@ -1,3 +1,4 @@
+import './public-path';
 import loadPolyfills from '../mastodon/load_polyfills';
 import { start } from '../mastodon/common';
 
diff --git a/app/javascript/skins/glitch/contrast/names.yml b/app/javascript/skins/glitch/contrast/names.yml
index f60c4acd0..570e30d3b 100644
--- a/app/javascript/skins/glitch/contrast/names.yml
+++ b/app/javascript/skins/glitch/contrast/names.yml
@@ -2,3 +2,7 @@ en:
   skins:
     glitch:
       contrast: High contrast
+es:
+  skins:
+    glitch:
+      contrast: Alto contraste
diff --git a/app/javascript/skins/glitch/mastodon-light/names.yml b/app/javascript/skins/glitch/mastodon-light/names.yml
index f15424f2b..891271e6e 100644
--- a/app/javascript/skins/glitch/mastodon-light/names.yml
+++ b/app/javascript/skins/glitch/mastodon-light/names.yml
@@ -2,4 +2,7 @@ en:
   skins:
     glitch:
       mastodon-light: Mastodon (light)
-
+es:
+  skins:
+    glitch:
+      mastodon-light: Mastodon (claro)
diff --git a/app/javascript/skins/vanilla/contrast/names.yml b/app/javascript/skins/vanilla/contrast/names.yml
index d9b930e86..da8974742 100644
--- a/app/javascript/skins/vanilla/contrast/names.yml
+++ b/app/javascript/skins/vanilla/contrast/names.yml
@@ -2,3 +2,7 @@ en:
   skins:
     vanilla:
       contrast: High contrast
+es:
+  skins:
+    vanilla:
+      contrast: Alto contraste
diff --git a/app/javascript/skins/vanilla/mastodon-light/names.yml b/app/javascript/skins/vanilla/mastodon-light/names.yml
index bb14ad2e0..5e41d1051 100644
--- a/app/javascript/skins/vanilla/mastodon-light/names.yml
+++ b/app/javascript/skins/vanilla/mastodon-light/names.yml
@@ -2,3 +2,7 @@ en:
   skins:
     vanilla:
       mastodon-light: Mastodon (light)
+es:
+  skins:
+    glitch:
+      mastodon-light: Mastodon (claro)
diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
index 0c0eb281a..7f7eee6fe 100644
--- a/app/javascript/styles/mastodon-light/diff.scss
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -753,3 +753,8 @@ html {
 .compose-form .compose-form__warning {
   box-shadow: none;
 }
+
+.mute-modal select {
+  border: 1px solid lighten($ui-base-color, 8%);
+  background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>") no-repeat right 8px center / auto 16px;
+}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index ff4bb3428..be554a502 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -163,8 +163,7 @@
 }
 
 .icon-button {
-  display: inline-flex;
-  align-items: center;
+  display: inline-block;
   padding: 0;
   color: $action-button-color;
   border: 0;
@@ -173,6 +172,7 @@
   cursor: pointer;
   transition: all 100ms ease-in;
   transition-property: background-color, color;
+  text-decoration: none;
 
   &:hover,
   &:active,
@@ -247,6 +247,12 @@
     }
   }
 
+  &--with-counter {
+    display: inline-flex;
+    align-items: center;
+    width: auto !important;
+  }
+
   &__counter {
     display: inline-block;
     width: 14px;
@@ -1152,6 +1158,10 @@
 
 .status__action-bar-button {
   margin-right: 18px;
+
+  &.icon-button--with-counter {
+    margin-right: 14px;
+  }
 }
 
 .status__action-bar-dropdown {
@@ -2408,6 +2418,17 @@ a.account__display-name {
     line-height: 14px;
     color: $primary-text-color;
   }
+
+  &__issue-badge {
+    position: absolute;
+    left: 11px;
+    bottom: 1px;
+    display: block;
+    background: $error-red;
+    border-radius: 50%;
+    width: 0.625rem;
+    height: 0.625rem;
+  }
 }
 
 .column-link--transparent .icon-with-badge__badge {
@@ -3443,6 +3464,15 @@ a.status-card.compact:hover {
   cursor: pointer;
 }
 
+.column-header__issue-btn {
+  color: $warning-red;
+
+  &:hover {
+    color: $error-red;
+    text-decoration: underline;
+  }
+}
+
 .column-header__icon {
   display: inline-block;
   margin-right: 5px;
@@ -3702,6 +3732,10 @@ a.status-card.compact:hover {
   margin-bottom: 10px;
 }
 
+.column-settings__row--with-margin {
+  margin-bottom: 15px;
+}
+
 .column-settings__hashtags {
   .column-settings__row {
     margin-bottom: 15px;
@@ -5044,6 +5078,22 @@ a.status-card.compact:hover {
       }
     }
   }
+
+  select {
+    appearance: none;
+    box-sizing: border-box;
+    font-size: 14px;
+    color: $inverted-text-color;
+    display: inline-block;
+    width: auto;
+    outline: 0;
+    font-family: inherit;
+    background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") no-repeat right 8px center / auto 16px;
+    border: 1px solid darken($simple-background-color, 14%);
+    border-radius: 4px;
+    padding: 6px 10px;
+    padding-right: 30px;
+  }
 }
 
 .confirmation-modal__container,
@@ -7122,3 +7172,25 @@ noscript {
     border-color: lighten($ui-base-color, 12%);
   }
 }
+
+.notifications-permission-banner {
+  padding: 30px;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+
+  h2 {
+    font-size: 16px;
+    font-weight: 500;
+    margin-bottom: 15px;
+    text-align: center;
+  }
+
+  p {
+    color: $darker-text-color;
+    margin-bottom: 15px;
+    text-align: center;
+  }
+}
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index 224451f41..2b5d3ffc2 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -74,7 +74,7 @@ class ActivityPub::Activity
     @object_uri ||= begin
       str = value_or_id(@object)
 
-      if str.start_with?('bear:')
+      if str&.start_with?('bear:')
         Addressable::URI.parse(str).query_values['u']
       else
         str
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index 3f98dad2e..3f2ae1106 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -40,6 +40,10 @@ class ActivityPub::TagManager
     end
   end
 
+  def uri_for_username(username)
+    account_url(username: username)
+  end
+
   def generate_uri_for(_target)
     URI.join(root_url, 'payloads', SecureRandom.uuid)
   end
diff --git a/app/lib/fast_ip_map.rb b/app/lib/fast_ip_map.rb
new file mode 100644
index 000000000..ba30b45f3
--- /dev/null
+++ b/app/lib/fast_ip_map.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class FastIpMap
+  MAX_IPV4_PREFIX = 32
+  MAX_IPV6_PREFIX = 128
+
+  # @param [Enumerable<IPAddr>] addresses
+  def initialize(addresses)
+    @fast_lookup = {}
+    @ranges      = []
+
+    # Hash look-up is faster but only works for exact matches, so we split
+    # exact addresses from non-exact ones
+    addresses.each do |address|
+      if (address.ipv4? && address.prefix == MAX_IPV4_PREFIX) || (address.ipv6? && address.prefix == MAX_IPV6_PREFIX)
+        @fast_lookup[address.to_s] = true
+      else
+        @ranges << address
+      end
+    end
+
+    # We're more likely to hit wider-reaching ranges when checking for
+    # inclusion, so make sure they're sorted first
+    @ranges.sort_by!(&:prefix)
+  end
+
+  # @param [IPAddr] address
+  # @return [Boolean]
+  def include?(address)
+    @fast_lookup[address.to_s] || @ranges.any? { |cidr| cidr.include?(address) }
+  end
+end
diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb
index 80e8f6cc7..89373664a 100644
--- a/app/lib/sanitize_config.rb
+++ b/app/lib/sanitize_config.rb
@@ -19,6 +19,7 @@ class Sanitize
       gopher
       xmpp
       magnet
+      gemini
     ).freeze
 
     CLASS_WHITELIST_TRANSFORMER = lambda do |env|
diff --git a/app/lib/settings/scoped_settings.rb b/app/lib/settings/scoped_settings.rb
index 8aa826561..9889940f3 100644
--- a/app/lib/settings/scoped_settings.rb
+++ b/app/lib/settings/scoped_settings.rb
@@ -12,7 +12,6 @@ module Settings
       @object = object
     end
 
-    # rubocop:disable Style/MethodMissingSuper
     def method_missing(method, *args)
       method_name = method.to_s
       # set a value for a variable
@@ -25,7 +24,6 @@ module Settings
         self[method_name]
       end
     end
-    # rubocop:enable Style/MethodMissingSuper
 
     def respond_to_missing?(*)
       true
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index 2f9cfe3ad..581101782 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -28,6 +28,7 @@ class UserSettingsDecorator
     user.settings['display_media']       = display_media_preference if change?('setting_display_media')
     user.settings['expand_spoilers']     = expand_spoilers_preference if change?('setting_expand_spoilers')
     user.settings['reduce_motion']       = reduce_motion_preference if change?('setting_reduce_motion')
+    user.settings['disable_swiping']     = disable_swiping_preference if change?('setting_disable_swiping')
     user.settings['system_font_ui']      = system_font_ui_preference if change?('setting_system_font_ui')
     user.settings['system_emoji_font']   = system_emoji_font_preference if change?('setting_system_emoji_font')
     user.settings['noindex']             = noindex_preference if change?('setting_noindex')
@@ -101,6 +102,10 @@ class UserSettingsDecorator
     boolean_cast_setting 'setting_reduce_motion'
   end
 
+  def disable_swiping_preference
+    boolean_cast_setting 'setting_disable_swiping'
+  end
+
   def noindex_preference
     boolean_cast_setting 'setting_noindex'
   end
diff --git a/app/lib/webfinger.rb b/app/lib/webfinger.rb
new file mode 100644
index 000000000..b2374c494
--- /dev/null
+++ b/app/lib/webfinger.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+class Webfinger
+  class Error < StandardError; end
+
+  class Response
+    def initialize(body)
+      @json = Oj.load(body, mode: :strict)
+    end
+
+    def subject
+      @json['subject']
+    end
+
+    def link(rel, attribute)
+      links.dig(rel, attribute)
+    end
+
+    private
+
+    def links
+      @links ||= @json['links'].map { |link| [link['rel'], link] }.to_h
+    end
+  end
+
+  def initialize(uri)
+    _, @domain = uri.split('@')
+
+    raise ArgumentError, 'Webfinger requested for local account' if @domain.nil?
+
+    @uri = uri
+  end
+
+  def perform
+    Response.new(body_from_webfinger)
+  rescue Oj::ParseError
+    raise Webfinger::Error, "Invalid JSON in response for #{@uri}"
+  rescue Addressable::URI::InvalidURIError
+    raise Webfinger::Error, "Invalid URI for #{@uri}"
+  end
+
+  private
+
+  def body_from_webfinger(url = standard_url, use_fallback = true)
+    webfinger_request(url).perform do |res|
+      if res.code == 200
+        res.body_with_limit
+      elsif res.code == 404 && use_fallback
+        body_from_host_meta
+      else
+        raise Webfinger::Error, "Request for #{@uri} returned HTTP #{res.code}"
+      end
+    end
+  end
+
+  def body_from_host_meta
+    host_meta_request.perform do |res|
+      if res.code == 200
+        body_from_webfinger(url_from_template(res.body_with_limit), false)
+      else
+        raise Webfinger::Error, "Request for #{@uri} returned HTTP #{res.code}"
+      end
+    end
+  end
+
+  def url_from_template(str)
+    link = Nokogiri::XML(str).at_xpath('//xmlns:Link[@rel="lrdd"]')
+
+    if link.present?
+      link['template'].gsub('{uri}', @uri)
+    else
+      raise Webfinger::Error, "Request for #{@uri} returned host-meta without link to Webfinger"
+    end
+  rescue Nokogiri::XML::XPath::SyntaxError
+    raise Webfinger::Error, "Invalid XML encountered in host-meta for #{@uri}"
+  end
+
+  def host_meta_request
+    Request.new(:get, host_meta_url).add_headers('Accept' => 'application/xrd+xml, application/xml, text/xml')
+  end
+
+  def webfinger_request(url)
+    Request.new(:get, url).add_headers('Accept' => 'application/jrd+json, application/json')
+  end
+
+  def standard_url
+    "https://#{@domain}/.well-known/webfinger?resource=#{@uri}"
+  end
+
+  def host_meta_url
+    "https://#{@domain}/.well-known/host-meta"
+  end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
index 3a6b38181..38f235baa 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -354,6 +354,12 @@ class Account < ApplicationRecord
     shared_inbox_url.presence || inbox_url
   end
 
+  def synchronization_uri_prefix
+    return 'local' if local?
+
+    @synchronization_uri_prefix ||= uri[/http(s?):\/\/[^\/]+\//]
+  end
+
   class Field < ActiveModelSerializers::Model
     attributes :name, :value, :verified_at, :account, :errors
 
diff --git a/app/models/account_alias.rb b/app/models/account_alias.rb
index 792e9e8d4..3d659142a 100644
--- a/app/models/account_alias.rb
+++ b/app/models/account_alias.rb
@@ -33,7 +33,7 @@ class AccountAlias < ApplicationRecord
   def set_uri
     target_account = ResolveAccountService.new.call(acct)
     self.uri       = ActivityPub::TagManager.instance.uri_for(target_account) unless target_account.nil?
-  rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
+  rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
     # Validation will take care of it
   end
 
diff --git a/app/models/account_migration.rb b/app/models/account_migration.rb
index 681b5b2cd..4fae98ed7 100644
--- a/app/models/account_migration.rb
+++ b/app/models/account_migration.rb
@@ -54,7 +54,7 @@ class AccountMigration < ApplicationRecord
 
   def set_target_account
     self.target_account = ResolveAccountService.new.call(acct)
-  rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
+  rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
     # Validation will take care of it
   end
 
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 427ebdae2..e2c4b8acf 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -131,9 +131,12 @@ module AccountInteractions
                        .find_or_create_by!(target_account: other_account)
   end
 
-  def mute!(other_account, notifications: nil)
+  def mute!(other_account, notifications: nil, duration: 0)
     notifications = true if notifications.nil?
-    mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account)
+    mute = mute_relationships.create_with(hide_notifications: notifications).find_or_initialize_by(target_account: other_account)
+    mute.expires_in = duration.zero? ? nil : duration
+    mute.save!
+
     remove_potential_friendship(other_account)
 
     # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't.
@@ -240,6 +243,26 @@ module AccountInteractions
          .where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
   end
 
+  def remote_followers_hash(url_prefix)
+    Rails.cache.fetch("followers_hash:#{id}:#{url_prefix}") do
+      digest = "\x00" * 32
+      followers.where(Account.arel_table[:uri].matches(url_prefix + '%', false, true)).pluck_each(:uri) do |uri|
+        Xorcist.xor!(digest, Digest::SHA256.digest(uri))
+      end
+      digest.unpack('H*')[0]
+    end
+  end
+
+  def local_followers_hash
+    Rails.cache.fetch("followers_hash:#{id}:local") do
+      digest = "\x00" * 32
+      followers.where(domain: nil).pluck_each(:username) do |username|
+        Xorcist.xor!(digest, Digest::SHA256.digest(ActivityPub::TagManager.instance.uri_for_username(username)))
+      end
+      digest.unpack('H*')[0]
+    end
+  end
+
   private
 
   def remove_potential_friendship(other_account, mutual = false)
diff --git a/app/models/concerns/expireable.rb b/app/models/concerns/expireable.rb
index f7d2bab49..a66a4661b 100644
--- a/app/models/concerns/expireable.rb
+++ b/app/models/concerns/expireable.rb
@@ -6,7 +6,15 @@ module Expireable
   included do
     scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) }
 
-    attr_reader :expires_in
+    def expires_in
+      return @expires_in if defined?(@expires_in)
+
+      if expires_at.nil?
+        nil
+      else
+        (expires_at - created_at).to_i
+      end
+    end
 
     def expires_in=(interval)
       self.expires_at = interval.to_i.seconds.from_now if interval.present?
diff --git a/app/models/follow.rb b/app/models/follow.rb
index 0b4ddbf3f..55a9da792 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -41,8 +41,10 @@ class Follow < ApplicationRecord
 
   before_validation :set_uri, only: :create
   after_create :increment_cache_counters
+  after_create :invalidate_hash_cache
   after_destroy :remove_endorsements
   after_destroy :decrement_cache_counters
+  after_destroy :invalidate_hash_cache
 
   private
 
@@ -63,4 +65,10 @@ class Follow < ApplicationRecord
     account&.decrement_count!(:following_count)
     target_account&.decrement_count!(:followers_count)
   end
+
+  def invalidate_hash_cache
+    return if account.local? && target_account.local?
+
+    Rails.cache.delete("followers_hash:#{target_account_id}:#{account.synchronization_uri_prefix}")
+  end
 end
diff --git a/app/models/form/ip_block_batch.rb b/app/models/form/ip_block_batch.rb
new file mode 100644
index 000000000..f6fe9b593
--- /dev/null
+++ b/app/models/form/ip_block_batch.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class Form::IpBlockBatch
+  include ActiveModel::Model
+  include Authorization
+  include AccountableConcern
+
+  attr_accessor :ip_block_ids, :action, :current_account
+
+  def save
+    case action
+    when 'delete'
+      delete!
+    end
+  end
+
+  private
+
+  def ip_blocks
+    @ip_blocks ||= IpBlock.where(id: ip_block_ids)
+  end
+
+  def delete!
+    ip_blocks.each { |ip_block| authorize(ip_block, :destroy?) }
+
+    ip_blocks.each do |ip_block|
+      ip_block.destroy
+      log_action :destroy, ip_block
+    end
+  end
+end
diff --git a/app/models/form/redirect.rb b/app/models/form/redirect.rb
index a7961f8e8..19ee9faed 100644
--- a/app/models/form/redirect.rb
+++ b/app/models/form/redirect.rb
@@ -32,7 +32,7 @@ class Form::Redirect
 
   def set_target_account
     @target_account = ResolveAccountService.new.call(acct)
-  rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
+  rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
     # Validation will take care of it
   end
 
diff --git a/app/models/ip_block.rb b/app/models/ip_block.rb
new file mode 100644
index 000000000..aedd3ca0d
--- /dev/null
+++ b/app/models/ip_block.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: ip_blocks
+#
+#  id         :bigint(8)        not null, primary key
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#  expires_at :datetime
+#  ip         :inet             default(#<IPAddr: IPv4:0.0.0.0/255.255.255.255>), not null
+#  severity   :integer          default(NULL), not null
+#  comment    :text             default(""), not null
+#
+
+class IpBlock < ApplicationRecord
+  CACHE_KEY = 'blocked_ips'
+
+  include Expireable
+
+  enum severity: {
+    sign_up_requires_approval: 5000,
+    no_access: 9999,
+  }
+
+  validates :ip, :severity, presence: true
+
+  after_commit :reset_cache
+
+  class << self
+    def blocked?(remote_ip)
+      blocked_ips_map = Rails.cache.fetch(CACHE_KEY) { FastIpMap.new(IpBlock.where(severity: :no_access).pluck(:ip)) }
+      blocked_ips_map.include?(remote_ip)
+    end
+  end
+
+  private
+
+  def reset_cache
+    Rails.cache.delete(CACHE_KEY)
+  end
+end
diff --git a/app/models/mute.rb b/app/models/mute.rb
index 639120f7d..fe8b6f42c 100644
--- a/app/models/mute.rb
+++ b/app/models/mute.rb
@@ -9,11 +9,14 @@
 #  hide_notifications :boolean          default(TRUE), not null
 #  account_id         :bigint(8)        not null
 #  target_account_id  :bigint(8)        not null
+#  hide_notifications :boolean          default(TRUE), not null
+#  expires_at         :datetime
 #
 
 class Mute < ApplicationRecord
   include Paginable
   include RelationshipCacheable
+  include Expireable
 
   belongs_to :account
   belongs_to :target_account, class_name: 'Account'
diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb
index 30b84f7d5..911c06713 100644
--- a/app/models/remote_follow.rb
+++ b/app/models/remote_follow.rb
@@ -56,7 +56,7 @@ class RemoteFollow
 
     if domain.nil?
       @addressable_template = Addressable::Template.new("#{authorize_interaction_url}?uri={uri}")
-    elsif redirect_url_link.nil? || redirect_url_link.template.nil?
+    elsif redirect_uri_template.nil?
       missing_resource_error
     else
       @addressable_template = Addressable::Template.new(redirect_uri_template)
@@ -64,16 +64,12 @@ class RemoteFollow
   end
 
   def redirect_uri_template
-    redirect_url_link.template
-  end
-
-  def redirect_url_link
-    acct_resource&.link('http://ostatus.org/schema/1.0/subscribe')
+    acct_resource&.link('http://ostatus.org/schema/1.0/subscribe', 'template')
   end
 
   def acct_resource
     @acct_resource ||= webfinger!("acct:#{acct}")
-  rescue Goldfinger::Error, HTTP::ConnectionError
+  rescue Webfinger::Error, HTTP::ConnectionError
     nil
   end
 
diff --git a/app/models/user.rb b/app/models/user.rb
index 6cd2ca6bd..3dcfd820e 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -41,6 +41,7 @@
 #  sign_in_token             :string
 #  sign_in_token_sent_at     :datetime
 #  webauthn_id               :string
+#  sign_up_ip                :inet
 #
 
 class User < ApplicationRecord
@@ -97,7 +98,7 @@ class User < ApplicationRecord
   scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) }
   scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where(accounts: { suspended_at: nil }) }
   scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) }
-  scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) }
+  scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.sign_up_ip <<= ?', value)).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) }
   scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }
 
   before_validation :sanitize_languages
@@ -115,7 +116,7 @@ class User < ApplicationRecord
            :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_media, :hide_network, :hide_followers_count,
            :expand_spoilers, :default_language, :aggregate_reblogs, :show_application,
            :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images,
-           :default_content_type, :system_emoji_font,
+           :disable_swiping, :default_content_type, :system_emoji_font,
            to: :settings, prefix: :setting, allow_nil: false
 
   attr_reader :invite_code, :sign_in_token_attempt
@@ -331,6 +332,7 @@ class User < ApplicationRecord
 
       arr << [current_sign_in_at, current_sign_in_ip] if current_sign_in_ip.present?
       arr << [last_sign_in_at, last_sign_in_ip] if last_sign_in_ip.present?
+      arr << [created_at, sign_up_ip] if sign_up_ip.present?
 
       arr.sort_by { |pair| pair.first || Time.now.utc }.uniq(&:last).reverse!
     end
@@ -385,7 +387,17 @@ class User < ApplicationRecord
   end
 
   def set_approved
-    self.approved = open_registrations? || valid_invitation? || external?
+    self.approved = begin
+      if sign_up_from_ip_requires_approval?
+        false
+      else
+        open_registrations? || valid_invitation? || external?
+      end
+    end
+  end
+
+  def sign_up_from_ip_requires_approval?
+    !sign_up_ip.nil? && IpBlock.where(severity: :sign_up_requires_approval).where('ip >>= ?', sign_up_ip.to_s).exists?
   end
 
   def open_registrations?
diff --git a/app/policies/ip_block_policy.rb b/app/policies/ip_block_policy.rb
new file mode 100644
index 000000000..34dbd746a
--- /dev/null
+++ b/app/policies/ip_block_policy.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class IpBlockPolicy < ApplicationPolicy
+  def index?
+    admin?
+  end
+
+  def create?
+    admin?
+  end
+
+  def destroy?
+    admin?
+  end
+end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index c520c9bcb..470cec8a1 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -48,6 +48,7 @@ class InitialStateSerializer < ActiveModel::Serializer
       store[:display_media]     = object.current_account.user.setting_display_media
       store[:expand_spoilers]   = object.current_account.user.setting_expand_spoilers
       store[:reduce_motion]     = object.current_account.user.setting_reduce_motion
+      store[:disable_swiping]   = object.current_account.user.setting_disable_swiping
       store[:advanced_layout]   = object.current_account.user.setting_advanced_layout
       store[:use_blurhash]      = object.current_account.user.setting_use_blurhash
       store[:use_pending_items] = object.current_account.user.setting_use_pending_items
diff --git a/app/serializers/rest/muted_account_serializer.rb b/app/serializers/rest/muted_account_serializer.rb
new file mode 100644
index 000000000..3ddd706dc
--- /dev/null
+++ b/app/serializers/rest/muted_account_serializer.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class REST::MutedAccountSerializer < REST::AccountSerializer
+  attribute :mute_expires_at
+
+  def mute_expires_at
+    mute = current_user.account.mute_relationships.find_by(target_account_id: object.id)
+    mute && !mute.expired? ? mute.expires_at : nil
+  end
+end
diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb
index 83fbf6d07..e5bd0c47c 100644
--- a/app/services/activitypub/fetch_remote_account_service.rb
+++ b/app/services/activitypub/fetch_remote_account_service.rb
@@ -39,17 +39,16 @@ class ActivityPub::FetchRemoteAccountService < BaseService
     webfinger                            = webfinger!("acct:#{@username}@#{@domain}")
     confirmed_username, confirmed_domain = split_acct(webfinger.subject)
 
-    return webfinger.link('self')&.href == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
+    return webfinger.link('self', 'href') == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
 
     webfinger                            = webfinger!("acct:#{confirmed_username}@#{confirmed_domain}")
     @username, @domain                   = split_acct(webfinger.subject)
-    self_reference                       = webfinger.link('self')
 
     return false unless @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
-    return false if self_reference&.href != @uri
+    return false if webfinger.link('self', 'href') != @uri
 
     true
-  rescue Goldfinger::Error
+  rescue Webfinger::Error
     false
   end
 
diff --git a/app/services/activitypub/prepare_followers_synchronization_service.rb b/app/services/activitypub/prepare_followers_synchronization_service.rb
new file mode 100644
index 000000000..2d22ed701
--- /dev/null
+++ b/app/services/activitypub/prepare_followers_synchronization_service.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ActivityPub::PrepareFollowersSynchronizationService < BaseService
+  include JsonLdHelper
+
+  def call(account, params)
+    @account = account
+
+    return if params['collectionId'] != @account.followers_url || invalid_origin?(params['url']) || @account.local_followers_hash == params['digest']
+
+    ActivityPub::FollowersSynchronizationWorker.perform_async(@account.id, params['url'])
+  end
+end
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index 23234c1ae..a66aee226 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -198,7 +198,7 @@ class ActivityPub::ProcessAccountService < BaseService
     total_items = collection.is_a?(Hash) && collection['totalItems'].present? && collection['totalItems'].is_a?(Numeric) ? collection['totalItems'] : nil
     has_first_page = collection.is_a?(Hash) && collection['first'].present?
     @collections[type] = [total_items, has_first_page]
-  rescue HTTP::Error, OpenSSL::SSL::SSLError
+  rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::LengthValidationError
     @collections[type] = [nil, nil]
   end
 
diff --git a/app/services/activitypub/synchronize_followers_service.rb b/app/services/activitypub/synchronize_followers_service.rb
new file mode 100644
index 000000000..d83fcf55e
--- /dev/null
+++ b/app/services/activitypub/synchronize_followers_service.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+class ActivityPub::SynchronizeFollowersService < BaseService
+  include JsonLdHelper
+  include Payloadable
+
+  def call(account, partial_collection_url)
+    @account = account
+
+    items = collection_items(partial_collection_url)
+    return if items.nil?
+
+    # There could be unresolved accounts (hence the call to .compact) but this
+    # should never happen in practice, since in almost all cases we keep an
+    # Account record, and should we not do that, we should have sent a Delete.
+    # In any case there is not much we can do if that occurs.
+    @expected_followers = items.map { |uri| ActivityPub::TagManager.instance.uri_to_resource(uri, Account) }.compact
+
+    remove_unexpected_local_followers!
+    handle_unexpected_outgoing_follows!
+  end
+
+  private
+
+  def remove_unexpected_local_followers!
+    @account.followers.local.where.not(id: @expected_followers.map(&:id)).each do |unexpected_follower|
+      UnfollowService.new.call(unexpected_follower, @account)
+    end
+  end
+
+  def handle_unexpected_outgoing_follows!
+    @expected_followers.each do |expected_follower|
+      next if expected_follower.following?(@account)
+
+      if expected_follower.requested?(@account)
+        # For some reason the follow request went through but we missed it
+        expected_follower.follow_requests.find_by(target_account: @account)&.authorize!
+      else
+        # Since we were not aware of the follow from our side, we do not have an
+        # ID for it that we can include in the Undo activity. For this reason,
+        # the Undo may not work with software that relies exclusively on
+        # matching activity IDs and not the actor and target
+        follow = Follow.new(account: expected_follower, target_account: @account)
+        ActivityPub::DeliveryWorker.perform_async(build_undo_follow_json(follow), follow.account_id, follow.target_account.inbox_url)
+      end
+    end
+  end
+
+  def build_undo_follow_json(follow)
+    Oj.dump(serialize_payload(follow, ActivityPub::UndoFollowSerializer))
+  end
+
+  def collection_items(collection_or_uri)
+    collection = fetch_collection(collection_or_uri)
+    return unless collection.is_a?(Hash)
+
+    collection = fetch_collection(collection['first']) if collection['first'].present?
+    return unless collection.is_a?(Hash)
+
+    case collection['type']
+    when 'Collection', 'CollectionPage'
+      collection['items']
+    when 'OrderedCollection', 'OrderedCollectionPage'
+      collection['orderedItems']
+    end
+  end
+
+  def fetch_collection(collection_or_uri)
+    return collection_or_uri if collection_or_uri.is_a?(Hash)
+    return if invalid_origin?(collection_or_uri)
+
+    fetch_resource_without_id_validation(collection_or_uri, nil, true)
+  end
+end
diff --git a/app/services/app_sign_up_service.rb b/app/services/app_sign_up_service.rb
index c9739c77d..e00694157 100644
--- a/app/services/app_sign_up_service.rb
+++ b/app/services/app_sign_up_service.rb
@@ -1,13 +1,13 @@
 # frozen_string_literal: true
 
 class AppSignUpService < BaseService
-  def call(app, params)
+  def call(app, remote_ip, params)
     return unless allowed_registrations?
 
     user_params           = params.slice(:email, :password, :agreement, :locale)
     account_params        = params.slice(:username)
     invite_request_params = { text: params[:reason] }
-    user                  = User.create!(user_params.merge(created_by_application: app, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params))
+    user                  = User.create!(user_params.merge(created_by_application: app, sign_up_ip: remote_ip, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params))
 
     Doorkeeper::AccessToken.create!(application: app,
                                     resource_owner_id: user.id,
diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb
index 676804cb9..9ae9afd62 100644
--- a/app/services/mute_service.rb
+++ b/app/services/mute_service.rb
@@ -1,10 +1,10 @@
 # frozen_string_literal: true
 
 class MuteService < BaseService
-  def call(account, target_account, notifications: nil)
+  def call(account, target_account, notifications: nil, duration: 0)
     return if account.id == target_account.id
 
-    mute = account.mute!(target_account, notifications: notifications)
+    mute = account.mute!(target_account, notifications: notifications, duration: duration)
 
     if mute.hide_notifications?
       BlockWorker.perform_async(account.id, target_account.id)
@@ -12,6 +12,8 @@ class MuteService < BaseService
       MuteWorker.perform_async(account.id, target_account.id)
     end
 
+    DeleteMuteWorker.perform_at(duration.seconds, mute.id) if duration != 0
+
     mute
   end
 end
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index feffb872b..570cd8272 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -29,7 +29,7 @@ class ProcessMentionsService < BaseService
       if mention_undeliverable?(mentioned_account)
         begin
           mentioned_account = resolve_account_service.call(Regexp.last_match(1))
-        rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError
+        rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError
           mentioned_account = nil
         end
       end
@@ -60,7 +60,7 @@ class ProcessMentionsService < BaseService
     if mentioned_account.local?
       LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name, :mention)
     elsif mentioned_account.activitypub? && !@status.local_only?
-      ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url)
+      ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url, { synchronize_followers: !mention.status.distributable? })
     end
   end
 
diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb
index ba77552c6..3f7bb7cc5 100644
--- a/app/services/resolve_account_service.rb
+++ b/app/services/resolve_account_service.rb
@@ -26,11 +26,10 @@ class ResolveAccountService < BaseService
 
     @account ||= Account.find_remote(@username, @domain)
 
-    return @account if @account&.local? || !webfinger_update_due?
+    return @account if @account&.local? || @domain.nil? || !webfinger_update_due?
 
     # At this point we are in need of a Webfinger query, which may
     # yield us a different username/domain through a redirect
-
     process_webfinger!(@uri)
 
     # Because the username/domain pair may be different than what
@@ -47,7 +46,7 @@ class ResolveAccountService < BaseService
     # either needs to be created, or updated from fresh data
 
     process_account!
-  rescue Goldfinger::Error, WebfingerRedirectError, Oj::ParseError => e
+  rescue Webfinger::Error, WebfingerRedirectError, Oj::ParseError => e
     Rails.logger.debug "Webfinger query for #{@uri} failed: #{e}"
     nil
   end
@@ -118,11 +117,11 @@ class ResolveAccountService < BaseService
   end
 
   def activitypub_ready?
-    !@webfinger.link('self').nil? && ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type)
+    ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self', 'type'))
   end
 
   def actor_url
-    @actor_url ||= @webfinger.link('self').href
+    @actor_url ||= @webfinger.link('self', 'href')
   end
 
   def actor_json
diff --git a/app/views/admin/ip_blocks/_ip_block.html.haml b/app/views/admin/ip_blocks/_ip_block.html.haml
new file mode 100644
index 000000000..e07e2b444
--- /dev/null
+++ b/app/views/admin/ip_blocks/_ip_block.html.haml
@@ -0,0 +1,11 @@
+.batch-table__row
+  %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
+    = f.check_box :ip_block_ids, { multiple: true, include_hidden: false }, ip_block.id
+  .batch-table__row__content
+    .batch-table__row__content__text
+      %samp= "#{ip_block.ip}/#{ip_block.ip.prefix}"
+      - if ip_block.comment.present?
+        •
+        = ip_block.comment
+      %br/
+      = t("simple_form.labels.ip_block.severities.#{ip_block.severity}")
diff --git a/app/views/admin/ip_blocks/index.html.haml b/app/views/admin/ip_blocks/index.html.haml
new file mode 100644
index 000000000..00593840c
--- /dev/null
+++ b/app/views/admin/ip_blocks/index.html.haml
@@ -0,0 +1,25 @@
+- content_for :page_title do
+  = t('admin.ip_blocks.title')
+
+- if can?(:create, :ip_block)
+  - content_for :heading_actions do
+    = link_to t('admin.ip_blocks.add_new'), new_admin_ip_block_path, class: 'button'
+
+= form_for(@form, url: batch_admin_ip_blocks_path) do |f|
+  = hidden_field_tag :page, params[:page] || 1
+
+  .batch-table
+    .batch-table__toolbar
+      %label.batch-table__toolbar__select.batch-checkbox-all
+        = check_box_tag :batch_checkbox_all, nil, false
+      .batch-table__toolbar__actions
+        - if can?(:destroy, :ip_block)
+          = f.button safe_join([fa_icon('times'), t('admin.ip_blocks.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+    .batch-table__body
+      - if @ip_blocks.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'ip_block', collection: @ip_blocks, locals: { f: f }
+
+= paginate @ip_blocks
+
diff --git a/app/views/admin/ip_blocks/new.html.haml b/app/views/admin/ip_blocks/new.html.haml
new file mode 100644
index 000000000..69f6b98b9
--- /dev/null
+++ b/app/views/admin/ip_blocks/new.html.haml
@@ -0,0 +1,20 @@
+- content_for :page_title do
+  = t('.title')
+
+= simple_form_for @ip_block, url: admin_ip_blocks_path do |f|
+  = render 'shared/error_messages', object: @ip_block
+
+  .fields-group
+    = f.input :ip, as: :string, wrapper: :with_block_label, input_html: { placeholder: '192.0.2.0/24' }
+
+  .fields-group
+    = f.input :expires_in, wrapper: :with_block_label, collection: [1.day, 2.weeks, 1.month, 6.months, 1.year, 3.years].map(&:to_i), label_method: lambda { |i| I18n.t("admin.ip_blocks.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt')
+
+  .fields-group
+    = f.input :severity, as: :radio_buttons, collection: IpBlock.severities.keys, include_blank: false, wrapper: :with_block_label, label_method: lambda { |severity| safe_join([I18n.t("simple_form.labels.ip_block.severities.#{severity}"), content_tag(:span, I18n.t("simple_form.hints.ip_block.severities.#{severity}"), class: 'hint')]) }
+
+  .fields-group
+    = f.input :comment, as: :string, wrapper: :with_block_label
+
+  .actions
+    = f.button :button, t('admin.ip_blocks.add_new'), type: :submit
diff --git a/app/views/admin/pending_accounts/_account.html.haml b/app/views/admin/pending_accounts/_account.html.haml
index 7a9796a67..5b475b59a 100644
--- a/app/views/admin/pending_accounts/_account.html.haml
+++ b/app/views/admin/pending_accounts/_account.html.haml
@@ -7,7 +7,7 @@
         %strong= account.user_email
         = "(@#{account.username})"
       %br/
-      = account.user_current_sign_in_ip
+      %samp= account.user_current_sign_in_ip

       = t 'admin.accounts.time_in_queue', time: time_ago_in_words(account.user&.created_at)
 
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 3336cf391..1481f6973 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -6,6 +6,7 @@
 
     - if cdn_host?
       %link{ rel: 'dns-prefetch', href: cdn_host }/
+      %meta{ name: 'cdn-host', content: cdn_host }/
 
     - if storage_host?
       %link{ rel: 'dns-prefetch', href: storage_host }/
diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml
index 75441b452..69b206f69 100644
--- a/app/views/layouts/embedded.html.haml
+++ b/app/views/layouts/embedded.html.haml
@@ -6,6 +6,7 @@
 
     - if cdn_host?
       %link{ rel: 'dns-prefetch', href: cdn_host }/
+      %meta{ name: 'cdn-host', content: cdn_host }/
 
     - if storage_host?
       %link{ rel: 'dns-prefetch', href: storage_host }/
diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml
index e613f7399..4170c9e44 100644
--- a/app/views/settings/preferences/appearance/show.html.haml
+++ b/app/views/settings/preferences/appearance/show.html.haml
@@ -27,6 +27,7 @@
   .fields-group
     = f.input :setting_auto_play_gif, as: :boolean, wrapper: :with_label
     = f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label
+    = f.input :setting_disable_swiping, as: :boolean, wrapper: :with_label
     = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label
     = f.input :setting_system_emoji_font, as: :boolean, wrapper: :with_label
 
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index f2b6866e9..0e5ca41d1 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -52,13 +52,12 @@
       = t 'statuses.show_thread'
 
   .status__action-bar
-    .status__action-bar__counter
-      = link_to remote_interaction_path(status, type: :reply), class: 'status__action-bar-button icon-button modal-button' do
-        - if status.in_reply_to_id.nil?
-          = fa_icon 'reply fw'
-        - else
-          = fa_icon 'reply-all fw'
-      .status__action-bar__counter__label= obscured_counter status.replies_count
+    = link_to remote_interaction_path(status, type: :reply), class: 'status__action-bar-button icon-button icon-button--with-counter modal-button' do
+      - if status.in_reply_to_id.nil?
+        = fa_icon 'reply fw'
+      - else
+        = fa_icon 'reply-all fw'
+      %span.icon-button__counter= obscured_counter status.replies_count
     = link_to remote_interaction_path(status, type: :reblog), class: 'status__action-bar-button icon-button modal-button' do
       - if status.distributable?
         = fa_icon 'retweet fw'
diff --git a/app/views/well_known/host_meta/show.xml.ruby b/app/views/well_known/host_meta/show.xml.ruby
index 0a6bdc322..b4e867c5f 100644
--- a/app/views/well_known/host_meta/show.xml.ruby
+++ b/app/views/well_known/host_meta/show.xml.ruby
@@ -5,7 +5,6 @@ doc << Ox::Element.new('XRD').tap do |xrd|
 
   xrd << Ox::Element.new('Link').tap do |link|
     link['rel']      = 'lrdd'
-    link['type']     = 'application/xrd+xml'
     link['template'] = @webfinger_template
   end
 end
diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb
index 60775787a..6c5a576a7 100644
--- a/app/workers/activitypub/delivery_worker.rb
+++ b/app/workers/activitypub/delivery_worker.rb
@@ -2,6 +2,7 @@
 
 class ActivityPub::DeliveryWorker
   include Sidekiq::Worker
+  include RoutingHelper
   include JsonLdHelper
 
   STOPLIGHT_FAILURE_THRESHOLD = 10
@@ -38,9 +39,18 @@ class ActivityPub::DeliveryWorker
     Request.new(:post, @inbox_url, body: @json, http_client: http_client).tap do |request|
       request.on_behalf_of(@source_account, :uri, sign_with: @options[:sign_with])
       request.add_headers(HEADERS)
+      request.add_headers({ 'Collection-Synchronization' => synchronization_header }) if ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] != 'true' && @options[:synchronize_followers]
     end
   end
 
+  def synchronization_header
+    "collectionId=\"#{account_followers_url(@source_account)}\", digest=\"#{@source_account.remote_followers_hash(inbox_url_prefix)}\", url=\"#{account_followers_synchronization_url(@source_account)}\""
+  end
+
+  def inbox_url_prefix
+    @inbox_url[/http(s?):\/\/[^\/]+\//]
+  end
+
   def perform_request
     light = Stoplight(@inbox_url) do
       request_pool.with(@host) do |http_client|
diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb
index e4997ba0e..9b4814644 100644
--- a/app/workers/activitypub/distribution_worker.rb
+++ b/app/workers/activitypub/distribution_worker.rb
@@ -13,7 +13,7 @@ class ActivityPub::DistributionWorker
     return if skip_distribution?
 
     ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
-      [payload, @account.id, inbox_url]
+      [payload, @account.id, inbox_url, { synchronize_followers: !@status.distributable? }]
     end
 
     relay! if relayable?
diff --git a/app/workers/activitypub/followers_synchronization_worker.rb b/app/workers/activitypub/followers_synchronization_worker.rb
new file mode 100644
index 000000000..35a3ef0b9
--- /dev/null
+++ b/app/workers/activitypub/followers_synchronization_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class ActivityPub::FollowersSynchronizationWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push', lock: :until_executed
+
+  def perform(account_id, url)
+    @account = Account.find_by(id: account_id)
+    return true if @account.nil?
+
+    ActivityPub::SynchronizeFollowersService.new.call(@account, url)
+  end
+end
diff --git a/app/workers/delete_mute_worker.rb b/app/workers/delete_mute_worker.rb
new file mode 100644
index 000000000..eb031020e
--- /dev/null
+++ b/app/workers/delete_mute_worker.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class DeleteMuteWorker
+  include Sidekiq::Worker
+
+  def perform(mute_id)
+    mute = Mute.find_by(id: mute_id)
+    UnmuteService.new.call(mute.account, mute.target_account) if mute&.expired?
+  end
+end
diff --git a/app/workers/scheduler/ip_cleanup_scheduler.rb b/app/workers/scheduler/ip_cleanup_scheduler.rb
index 6d38b52a2..853f20e25 100644
--- a/app/workers/scheduler/ip_cleanup_scheduler.rb
+++ b/app/workers/scheduler/ip_cleanup_scheduler.rb
@@ -3,13 +3,23 @@
 class Scheduler::IpCleanupScheduler
   include Sidekiq::Worker
 
-  RETENTION_PERIOD = 1.year
+  IP_RETENTION_PERIOD = 1.year.freeze
 
   sidekiq_options lock: :until_executed, retry: 0
 
   def perform
-    time_ago = RETENTION_PERIOD.ago
-    SessionActivation.where('updated_at < ?', time_ago).in_batches.destroy_all
-    User.where('last_sign_in_at < ?', time_ago).where.not(last_sign_in_ip: nil).in_batches.update_all(last_sign_in_ip: nil)
+    clean_ip_columns!
+    clean_expired_ip_blocks!
+  end
+
+  private
+
+  def clean_ip_columns!
+    SessionActivation.where('updated_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all
+    User.where('current_sign_in_at < ?', IP_RETENTION_PERIOD.ago).in_batches.update_all(last_sign_in_ip: nil, current_sign_in_ip: nil, sign_up_ip: nil)
+  end
+
+  def clean_expired_ip_blocks!
+    IpBlock.expired.in_batches.destroy_all
   end
 end